Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
def3ef8e15 | ||
|
|
2e709b102f | ||
|
|
875310f4d9 | ||
|
|
82064b2874 | ||
|
|
f1bed389e3 | ||
|
|
b6c817123f | ||
|
|
062ab1ad34 | ||
|
|
b7b7105f2d | ||
|
|
f0e20eb3da |
No files matched your search
@@ -33,11 +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
|
||||
- 1.61.0
|
||||
|
||||
@@ -66,7 +66,7 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
if: env.SKIP_RELEASE == 'false'
|
||||
with:
|
||||
node-version: 24.17.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
@@ -1,2 +1,2 @@
|
||||
[tools]
|
||||
node = "24.17.0"
|
||||
node = "24.16.0"
|
||||
Vendored
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"js/ts.tsdk.path": "node_modules\\typescript\\lib",
|
||||
"typescript.tsdk": "node_modules\\typescript\\lib",
|
||||
"js/ts.implicitProjectConfig.experimentalDecorators": true,
|
||||
"cSpell.words": [
|
||||
"ajnart",
|
||||
|
||||
@@ -1,42 +1,3 @@
|
||||
## [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
@@ -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
-15
@@ -1,4 +1,4 @@
|
||||
FROM node:24.17.0-alpine AS base
|
||||
FROM node:24.16.0-alpine AS base
|
||||
|
||||
FROM base AS builder
|
||||
RUN apk add --no-cache libc6-compat
|
||||
@@ -20,17 +20,6 @@ ARG DISABLE_REDIS_LOGS='true'
|
||||
|
||||
RUN corepack enable pnpm && pnpm build
|
||||
|
||||
# The standalone output tracing omits server chunk source maps (.next/server/**/*.map), which is
|
||||
# where production Node stack frames point. Copy the .map files into the standalone tree (preserving
|
||||
# directory structure) so the existing standalone COPY ships them, keeping traces readable.
|
||||
# See https://github.com/homarr-labs/homarr/issues/5891
|
||||
RUN cd apps/nextjs/.next/server && \
|
||||
find . -name '*.map' | while IFS= read -r map; do \
|
||||
dest="../standalone/apps/nextjs/.next/server/$map"; \
|
||||
mkdir -p "$(dirname "$dest")"; \
|
||||
cp "$map" "$dest"; \
|
||||
done
|
||||
|
||||
FROM base AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -75,9 +64,6 @@ ENV DB_DRIVER='better-sqlite3'
|
||||
ENV AUTH_PROVIDERS='credentials'
|
||||
ENV REDIS_IS_EXTERNAL='false'
|
||||
ENV NODE_ENV='production'
|
||||
# Tell Node to symbolicate stack traces using the shipped .map files so production logs
|
||||
# show original source locations instead of minified frames, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
ENV NODE_OPTIONS='--enable-source-maps'
|
||||
|
||||
ENTRYPOINT [ "/app/entrypoint.sh" ]
|
||||
CMD ["sh", "run.sh"]
|
||||
@@ -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>
|
||||
@@ -125,8 +124,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
|
||||
Roles are fetched from group claims. This can also be changed to roles for example added to a azure app registration by using the `AUTH_OIDC_GROUPS_ATTRIBUTE`.
|
||||
|
||||
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
|
||||
|
||||
<details>
|
||||
<summary>
|
||||
Example Setup
|
||||
@@ -200,7 +197,6 @@ Homarr supports multiple authentication options, from internal userbase (credent
|
||||
| ``AUTH_OIDC_AUTO_LOGIN`` | Automatically redirect to OIDC login | false |
|
||||
| ``AUTH_OIDC_SCOPE_OVERWRITE`` | Overwrite default scopes (openid, profile, email) | openid email profile groups |
|
||||
| ``AUTH_OIDC_GROUPS_ATTRIBUTE`` | Attribute used for groups (roles) claim | groups |
|
||||
| ``AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT`` | Stop syncing group memberships of OIDC users from the groups claim and manage them locally via the group members page instead | false |
|
||||
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
|
||||
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
|
||||
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
A Helm chart to deploy homarr for Kubernetes
|
||||
|
||||
@@ -383,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,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,33 +0,0 @@
|
||||
---
|
||||
title: 'Gotify'
|
||||
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time."
|
||||
hide_title: true
|
||||
---
|
||||
|
||||
import { notificationsWidget } from '@site/docs/widgets/notifications';
|
||||
import { AddingIntegration } from '@site/src/components/integrations/adding';
|
||||
import { IntegrationHeader } from '@site/src/components/integrations/header';
|
||||
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
|
||||
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
|
||||
import { gotifyIntegration } from '.';
|
||||
|
||||
<IntegrationHeader
|
||||
integration={gotifyIntegration}
|
||||
categories={['Notifications', 'Messaging']}
|
||||
/>
|
||||
|
||||
### Widgets & Capabilities
|
||||
<IntegrationCapabilites
|
||||
items={[{
|
||||
widget: notificationsWidget
|
||||
}]}
|
||||
/>
|
||||
|
||||
### Adding the integration
|
||||
<AddingIntegration />
|
||||
|
||||
### Secrets
|
||||
<IntegrationSecrets secrets={[{
|
||||
credentials: ['username', 'password'],
|
||||
steps: []
|
||||
}]} />
|
||||
@@ -1,8 +0,0 @@
|
||||
import { IntegrationDefinition } from "@site/src/types";
|
||||
|
||||
export const gotifyIntegration: IntegrationDefinition = {
|
||||
name: "Gotify",
|
||||
description: "Gotify is a simple, self-hosted server for sending and receiving messages in real time.",
|
||||
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gotify.svg",
|
||||
path: "../../integrations/gotify",
|
||||
};
|
||||
@@ -130,7 +130,6 @@ Homarr exposes 50+ tools organized by namespace. Each tool is either a **query**
|
||||
| ------------------ | -------------------------------------------------------- | ---------- |
|
||||
| `app` | List all apps, get app by ID, create/update/delete app | GET + POST |
|
||||
| `apiKeys` | List API keys | GET |
|
||||
| `beszel` | Systems status, alerts, historical system metrics | GET |
|
||||
| `board` | List boards, get board by ID, create/update/delete board | GET + POST |
|
||||
| `docker` | List containers, inspect container, start/stop/restart | GET + POST |
|
||||
| `icon` | Search icons | GET |
|
||||
@@ -163,4 +162,3 @@ The tools table on the MCP tab is dynamically generated - it always reflects the
|
||||
- **"Create a new app pointing to my Grafana instance"** - AI creates an app with the correct icon and URL
|
||||
- **"How many users are on my Homarr instance?"** - AI lists all users (admin only)
|
||||
- **"Toggle Pi-hole blocking for 5 minutes"** - AI interacts with DNS controls
|
||||
- **"How are my servers doing?"** - AI fetches Beszel system stats including CPU, memory, disk, and alerts
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"label": "Tools",
|
||||
"collapsed": true,
|
||||
"position": 10
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
---
|
||||
title: SQLite Backup & Restore
|
||||
tags:
|
||||
- Management
|
||||
- Backup
|
||||
- Restore
|
||||
- SQLite
|
||||
---
|
||||
|
||||
The SQLite Backup & Restore feature allows you to export and import your entire Homarr database as a portable ZIP file. This is useful for creating backups, migrating between instances, or recovering from failures.
|
||||
|
||||
:::info SQLite only
|
||||
This feature is only available when using the SQLite database driver (`DB_DRIVER=better-sqlite3`). MySQL and PostgreSQL users should use their respective database backup tools.
|
||||
:::
|
||||
|
||||
## Accessing Backup & Restore
|
||||
|
||||
Navigate to **Management → Tools → Backup** from the sidebar. This page is only visible when running with SQLite.
|
||||
|
||||
## Exporting a Backup
|
||||
|
||||
Click the **Export** button to download a ZIP archive containing:
|
||||
|
||||
- **`db.sqlite`** — A consistent snapshot of your database (created via `VACUUM INTO`)
|
||||
- **`metadata.json`** — Contains:
|
||||
- `homarrVersion` — The version of Homarr that created the backup
|
||||
- `exportedAt` — Timestamp of the export
|
||||
- `dbDialect` — The database dialect (`sqlite`)
|
||||
- `encryptionKey` — Your `SECRET_ENCRYPTION_KEY` (needed to decrypt integration secrets)
|
||||
|
||||
:::caution
|
||||
The exported ZIP contains your encryption key and database. Store it securely and do not share it publicly.
|
||||
:::
|
||||
|
||||
A confirmation dialog will ask you to type **"I understand"** before the export proceeds.
|
||||
|
||||
## Importing a Backup
|
||||
|
||||
1. Click the **Import** button and upload a previously exported ZIP file
|
||||
2. Homarr analyzes the backup **entirely in your browser** using WebAssembly (sql.js) — no data is sent to the server during preview
|
||||
3. The preview shows:
|
||||
- **Entity counts** — How many boards, apps, users, integrations, etc. are in the backup
|
||||
- **Board names** — A list of all boards contained in the backup
|
||||
- **Migration status** — If the backup is from an older version, Homarr shows each pending database migration as animated timeline steps
|
||||
4. Review the preview and type **"I understand"** to confirm
|
||||
5. Homarr applies any pending migrations, re-encrypts integration secrets if the `SECRET_ENCRYPTION_KEY` differs between instances, and replaces the live database
|
||||
6. The server automatically restarts to pick up the new database
|
||||
|
||||
:::tip
|
||||
The import process is **backward-compatible** with older backups that used a separate `encryption-key.txt` file instead of embedding the key in `metadata.json`.
|
||||
:::
|
||||
|
||||
## Onboarding Integration
|
||||
|
||||
When setting up a fresh Homarr instance with SQLite, the onboarding wizard includes a **"Restore from SQLite backup"** option. This lets you upload a backup ZIP to bootstrap your new instance with data from a previous installation.
|
||||
|
||||
## Docker Restart Behavior
|
||||
|
||||
After a successful import, only the Next.js process restarts — nginx and Redis remain running. The `run.sh` script automatically restarts Next.js in a loop, so the application comes back online within seconds. Running `docker stop` still works gracefully.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Back up regularly** — Create exports before major updates or configuration changes
|
||||
- **Test restores** — Periodically verify your backups by importing them on a test instance
|
||||
- **Secure storage** — Keep backups in an encrypted or access-controlled location since they contain your encryption key
|
||||
- **Check versions** — Backups from newer versions cannot be imported into older Homarr instances; always import into the same or newer version
|
||||
@@ -8,19 +8,12 @@ export const beszelSystemStatsWidget: WidgetDefinition = {
|
||||
path: "../../widgets/beszel-system-stats",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "System",
|
||||
description: "The Beszel system to display stats for. Searchable select populated from the integration.",
|
||||
values: { type: "string" },
|
||||
defaultValue: "First available system",
|
||||
},
|
||||
{
|
||||
name: "Time period",
|
||||
description:
|
||||
"The time range for chart data. '1 Minute' enables live streaming via PocketBase SSE (requires Beszel agent >= 0.13.0)",
|
||||
description: "The time range for chart data",
|
||||
values: {
|
||||
type: "select",
|
||||
options: ["1 Minute", "1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
|
||||
options: ["1 Hour", "12 Hours", "24 Hours", "1 Week", "30 Days"],
|
||||
},
|
||||
defaultValue: "1 Hour",
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconDeviceGamepad } from "@tabler/icons-react";
|
||||
import { integrations } from "../dns-hole-summary";
|
||||
|
||||
export const dnsHoleControlsWidget: WidgetDefinition = {
|
||||
icon: IconDeviceGamepad,
|
||||
name: "DNS Hole Controls",
|
||||
description: `Control the blocking feature of your ${integrations} from your dashboard`,
|
||||
description: "Control PiHole or AdGuard from your dashboard",
|
||||
path: "../../widgets/dns-hole-controls",
|
||||
configuration: {
|
||||
items: [
|
||||
{
|
||||
name: "Show Toggle All Buttons",
|
||||
description: "Show or not the 2 buttons that may be redundant when having only one DNS Hole.",
|
||||
description: "Show or not the 2 buttons that may be redundant when having only one DNS-hole.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { WidgetDefinition } from "@site/src/types";
|
||||
import { IconAd } from "@tabler/icons-react";
|
||||
|
||||
export const integrations = "Pi-hole, AdGuard Home or Technitium DNS";
|
||||
|
||||
export const dnsHoleSummaryWidget: WidgetDefinition = {
|
||||
icon: IconAd,
|
||||
name: "DNS Hole Summary",
|
||||
description: `Displays the summary of your ${integrations} blocks and queries statistics`,
|
||||
description: "Displays the summary of your DNS Hole",
|
||||
path: "../../widgets/dns-hole-summary",
|
||||
configuration: {
|
||||
items: [
|
||||
@@ -21,7 +19,7 @@ export const dnsHoleSummaryWidget: WidgetDefinition = {
|
||||
},
|
||||
{
|
||||
name: "Use Pi-Hole colors",
|
||||
description: `Color the tiles using the background color, that is used by default in ${integrations}.`,
|
||||
description: "Color the tiles using the background color, that is used by default in PiHole.",
|
||||
values: { type: "boolean" },
|
||||
defaultValue: "yes",
|
||||
},
|
||||
|
||||
@@ -13,8 +13,7 @@ import Tabs from '@theme/Tabs';
|
||||
import TabItem from '@theme/TabItem';
|
||||
import { notificationsWidget } from '.';
|
||||
import { ntfyIntegration } from '@site/docs/integrations/ntfy';
|
||||
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
|
||||
import { gotifyIntegration } from '@site/docs/integrations/gotify';
|
||||
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'
|
||||
|
||||
<WidgetHeader
|
||||
widget={notificationsWidget}
|
||||
@@ -34,8 +33,7 @@ import notificationsScreenshot from './img/notifications.png';
|
||||
<WidgetIntegrations
|
||||
items={[
|
||||
{ integration: ntfyIntegration },
|
||||
{ integration: nextcloudIntegration },
|
||||
{ integration: gotifyIntegration }
|
||||
{ integration: nextcloudIntegration }
|
||||
]}
|
||||
/>
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -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 />
|
||||
@@ -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",
|
||||
};
|
||||
@@ -32,19 +32,19 @@
|
||||
"@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",
|
||||
"leader-line-new": "^1.1.9",
|
||||
"postcss": "^8.5.15",
|
||||
"postcss": "^8.5.10",
|
||||
"posthog-docusaurus": "^2.0.5",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"random-word-slugs": "^0.1.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",
|
||||
|
||||
@@ -31,8 +31,6 @@ const nextConfig: NextConfig = {
|
||||
},
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
// Ship browser source maps so production client stack traces are readable, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
productionBrowserSourceMaps: true,
|
||||
// react compiler breaks mantine-react-table, so disabled for now
|
||||
//reactCompiler: true,
|
||||
/** We already do typechecking as separate tasks in CI */
|
||||
@@ -43,8 +41,6 @@ const nextConfig: NextConfig = {
|
||||
*/
|
||||
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
|
||||
experimental: {
|
||||
// Ship server source maps so production Node stack traces in logs reference original source, see https://github.com/homarr-labs/homarr/issues/5891
|
||||
serverSourceMaps: true,
|
||||
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
|
||||
turbopackFileSystemCacheForDev: true,
|
||||
preloadEntriesOnStart: false,
|
||||
|
||||
@@ -89,7 +89,6 @@
|
||||
"mcp-handler": "catalog:",
|
||||
"next": "catalog:",
|
||||
"postcss-preset-mantine": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"prismjs": "catalog:",
|
||||
"react": "catalog:",
|
||||
"react-dom": "catalog:",
|
||||
|
||||
@@ -139,11 +139,11 @@ export default async function Layout(props: {
|
||||
suppressHydrationWarning
|
||||
>
|
||||
<head>
|
||||
<Analytics />
|
||||
<SearchEngineOptimization />
|
||||
<CrowdinLiveTranslation locale={locale} />
|
||||
</head>
|
||||
<body className={["font-sans", fontSans.variable].join(" ")}>
|
||||
<Analytics enabled={serverSettings.analytics.enableGeneral} />
|
||||
<StackedProvider>
|
||||
<Notifications pauseResetOnHover="notification" />
|
||||
<ServiceWorkerRegistration />
|
||||
|
||||
@@ -1,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}
|
||||
|
||||
@@ -57,13 +57,13 @@ export const CustomWidgetList = ({ definitions }: CustomWidgetListProps) => {
|
||||
{definitions.map((def) => (
|
||||
<Table.Tr key={def.id} style={{ opacity: def.enabled ? undefined : 0.5 }}>
|
||||
<Table.Td>
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
<Group gap="xs">
|
||||
{def.iconUrl ? (
|
||||
<Avatar size={20} radius="sm" src={def.iconUrl} styles={{ image: { objectFit: "contain" } }} />
|
||||
) : (
|
||||
<IconApi size={16} />
|
||||
)}
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"} style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} lineClamp={1} c={def.enabled ? undefined : "dimmed"}>
|
||||
{def.name}
|
||||
</Text>
|
||||
</Group>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
-2
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useCallback } from "react";
|
||||
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import { revalidatePathActionAsync } from "@homarr/common/client";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { useModalAction } from "@homarr/modals";
|
||||
import { useScopedI18n } from "@homarr/translation/client";
|
||||
|
||||
@@ -14,10 +13,9 @@ import { MobileAffixButton } from "~/components/manage/mobile-affix-button";
|
||||
interface AddGroupMemberProps {
|
||||
groupId: string;
|
||||
presentUserIds: string[];
|
||||
allowedProviders: SupportedAuthProvider[];
|
||||
}
|
||||
|
||||
export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: AddGroupMemberProps) => {
|
||||
export const AddGroupMember = ({ groupId, presentUserIds }: AddGroupMemberProps) => {
|
||||
const tMembersAdd = useScopedI18n("group.action.addMember");
|
||||
const { mutateAsync } = clientApi.group.addMember.useMutation();
|
||||
const { openModal } = useModalAction(UserSelectModal);
|
||||
@@ -34,13 +32,13 @@ export const AddGroupMember = ({ groupId, presentUserIds, allowedProviders }: Ad
|
||||
await revalidatePathActionAsync(`/manage/users/groups/${groupId}}/members`);
|
||||
},
|
||||
presentUserIds,
|
||||
allowedProviders,
|
||||
excludeExternalProviders: true,
|
||||
},
|
||||
{
|
||||
title: tMembersAdd("label"),
|
||||
},
|
||||
);
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd, allowedProviders]);
|
||||
}, [openModal, presentUserIds, groupId, mutateAsync, tMembersAdd]);
|
||||
|
||||
return <MobileAffixButton onClick={handleAddMember}>{tMembersAdd("label")}</MobileAffixButton>;
|
||||
};
|
||||
@@ -4,12 +4,9 @@ import { IconExclamationCircle } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { api } from "@homarr/api/server";
|
||||
import { env } from "@homarr/auth/env";
|
||||
import { auth } from "@homarr/auth/next";
|
||||
import {
|
||||
getGroupMemberManagementType,
|
||||
getLocallyManageableProviders,
|
||||
isGroupMembershipManagedLocally,
|
||||
} from "@homarr/auth/server";
|
||||
import { isProviderEnabled } from "@homarr/auth/server";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { getI18n, getScopedI18n } from "@homarr/translation/server";
|
||||
import { Link, SearchInput, UserAvatar } from "@homarr/ui";
|
||||
@@ -41,15 +38,16 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
const group = await api.group.getById({ id: params.id });
|
||||
const isReserved = group.name === everyoneGroup;
|
||||
|
||||
const searchTerm = searchParams.search?.trim().toLowerCase();
|
||||
const filteredMembers = group.members.filter(
|
||||
(member) => !searchTerm || member.name?.toLowerCase().includes(searchTerm),
|
||||
);
|
||||
const filteredMembers = searchParams.search
|
||||
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
group.members.filter((member) => member.name?.toLowerCase().includes(searchParams.search!.trim().toLowerCase()))
|
||||
: group.members;
|
||||
|
||||
// "local" = every enabled provider managed locally, "external" = none, "mixed" = some.
|
||||
const managementType = getGroupMemberManagementType();
|
||||
const canManageMembers = managementType !== "external";
|
||||
const allowedProviders = getLocallyManageableProviders();
|
||||
const providerTypes = isProviderEnabled("credentials")
|
||||
? env.AUTH_PROVIDERS.length > 1
|
||||
? "mixed"
|
||||
: "credentials"
|
||||
: "external";
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -58,21 +56,17 @@ export default async function GroupsDetailPage(props: GroupsDetailPageProps) {
|
||||
{isReserved ? (
|
||||
<ReservedGroupAlert />
|
||||
) : (
|
||||
managementType !== "local" && (
|
||||
providerTypes !== "credentials" && (
|
||||
<Alert variant="light" color="yellow" icon={<IconExclamationCircle size="1rem" stroke={1.5} />}>
|
||||
{t(`group.memberNotice.${managementType}`)}
|
||||
{t(`group.memberNotice.${providerTypes}`)}
|
||||
</Alert>
|
||||
)
|
||||
)}
|
||||
|
||||
<Group justify="space-between">
|
||||
<SearchInput placeholder={`${tMembers("search")}...`} defaultValue={searchParams.search} />
|
||||
{canManageMembers && !isReserved && (
|
||||
<AddGroupMember
|
||||
groupId={group.id}
|
||||
presentUserIds={group.members.map((member) => member.id)}
|
||||
allowedProviders={allowedProviders}
|
||||
/>
|
||||
{isProviderEnabled("credentials") && !isReserved && (
|
||||
<AddGroupMember groupId={group.id} presentUserIds={group.members.map((member) => member.id)} />
|
||||
)}
|
||||
</Group>
|
||||
{filteredMembers.length === 0 && (
|
||||
@@ -100,8 +94,6 @@ interface RowProps {
|
||||
}
|
||||
|
||||
const Row = ({ member, groupId, disabled }: RowProps) => {
|
||||
const canBeRemoved = isGroupMembershipManagedLocally(member.provider);
|
||||
|
||||
return (
|
||||
<TableTr>
|
||||
<TableTd>
|
||||
@@ -112,7 +104,9 @@ const Row = ({ member, groupId, disabled }: RowProps) => {
|
||||
</Anchor>
|
||||
</Group>
|
||||
</TableTd>
|
||||
<TableTd w={100}>{canBeRemoved && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}</TableTd>
|
||||
<TableTd w={100}>
|
||||
{member.provider === "credentials" && !disabled && <RemoveGroupMember user={member} groupId={groupId} />}
|
||||
</TableTd>
|
||||
</TableTr>
|
||||
);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -148,8 +148,6 @@ If \`hasUseAccess\` is false, the API key owner lacks permission for that integr
|
||||
|
||||
**"Check server health"** → Use \`healthMonitoring_getSystemHealthStatus\` for NAS/server metrics or \`healthMonitoring_getClusterHealthStatus\` for Proxmox clusters.
|
||||
|
||||
**"Check Beszel systems"** → Call \`integration_all\` to get Beszel integrationIds, then use \`beszel_getSystems\` to list all monitored systems with CPU/memory/disk/network status. Use \`beszel_getAlerts\` for active alerts and history. Use \`beszel_getSystemStats\` for historical metrics of a specific system (requires systemId from \`beszel_getSystems\`).
|
||||
|
||||
**"Manage Docker containers"** → Use \`docker_getContainers\` to list containers with status/CPU/memory, then \`docker_startAll\`/\`docker_stopAll\`/\`docker_restartAll\` with container IDs.
|
||||
|
||||
**"Control smart home"** → Use \`smartHome_entityState\` to check a Home Assistant entity, \`smartHome_switchEntity\` to toggle it, or \`smartHome_executeAutomation\` to trigger an automation.
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IconCheck } from "@tabler/icons-react";
|
||||
|
||||
import type { RouterOutputs } from "@homarr/api";
|
||||
import { clientApi } from "@homarr/api/client";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { useForm } from "@homarr/form";
|
||||
import { createModal } from "@homarr/modals";
|
||||
import { useI18n } from "@homarr/translation/client";
|
||||
@@ -13,7 +12,7 @@ import { UserAvatar } from "@homarr/ui";
|
||||
|
||||
interface InnerProps {
|
||||
presentUserIds: string[];
|
||||
allowedProviders?: SupportedAuthProvider[];
|
||||
excludeExternalProviders?: boolean;
|
||||
onSelect: (props: { id: string; name: string; image: string; email: string | null }) => void | Promise<void>;
|
||||
confirmLabel?: string;
|
||||
}
|
||||
@@ -25,7 +24,7 @@ interface UserSelectFormType {
|
||||
export const UserSelectModal = createModal<InnerProps>(({ actions, innerProps }) => {
|
||||
const t = useI18n();
|
||||
const { data: users, isPending } = clientApi.user.selectable.useQuery({
|
||||
providers: innerProps.allowedProviders,
|
||||
excludeExternalProviders: innerProps.excludeExternalProviders,
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const form = useForm<UserSelectFormType>();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -46,12 +46,7 @@ export const WidgetHoverOverlay = ({ item, integrations }: WidgetHoverOverlayPro
|
||||
<Avatar.Group spacing="xs" className={classes.integrations}>
|
||||
{connectedIntegrations.map((integration) => (
|
||||
<Tooltip key={integration.id} label={integration.name} withArrow position="top">
|
||||
<Avatar
|
||||
src={getIconUrl(integration.kind)}
|
||||
size={18}
|
||||
radius="sm"
|
||||
className={classes.integrationAvatar}
|
||||
/>
|
||||
<Avatar src={getIconUrl(integration.kind)} size={18} radius="sm" className={classes.integrationAvatar} />
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
|
||||
@@ -57,7 +57,6 @@ export const categoryTranslationKeys: Record<string, string> = {
|
||||
mediaMonitoring: "integration.category.mediaMonitoring",
|
||||
speedtest: "integration.category.speedtest",
|
||||
analytics: "integration.category.analytics",
|
||||
vpn: "integration.category.vpn",
|
||||
documents: "integration.category.documents",
|
||||
mediaLibrary: "integration.category.mediaLibrary",
|
||||
uptimeMonitoring: "integration.category.uptimeMonitoring",
|
||||
|
||||
@@ -1,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}>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"~/*": ["./src/*"],
|
||||
"@static-data/*": ["../../static-data/*"],
|
||||
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
|
||||
},
|
||||
"plugins": [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:24.17.0-trixie AS base
|
||||
FROM node:24.16.0-trixie AS base
|
||||
WORKDIR /app
|
||||
COPY package.json .
|
||||
COPY pnpm-lock.yaml .
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"better-sqlite3": "^12.10.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0"
|
||||
"packageManager": "pnpm@11.5.3"
|
||||
}
|
||||
+30
-44
@@ -74,13 +74,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png" alt="ArchiveTeam Warrior" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">ArchiveTeam<br/>Warrior</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/aria2" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/PapirusDevelopmentTeam/papirus_icons@latest/src/system_downloads_3.svg" alt="Aria2" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -107,15 +100,15 @@
|
||||
<br/>
|
||||
<p align="center">Codeberg</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/coolify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/coolify.svg" alt="Coolify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Coolify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/dash-dot" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/dashdot.png" alt="Dash." width="90" height="90" />
|
||||
<br/>
|
||||
@@ -156,15 +149,15 @@
|
||||
<br/>
|
||||
<p align="center">GitHub<br/>Container<br/>Registry</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gitlab" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gitlab.svg" alt="Gitlab" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Gitlab</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/glances.svg" alt="Glances" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -172,13 +165,6 @@
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/gotify" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gotify.svg" alt="Gotify" width="90" height="90" />
|
||||
<br/>
|
||||
<p align="center">Gotify</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/home-assistant" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/home-assistant.svg" alt="Home Assistant" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -205,8 +191,8 @@
|
||||
<br/>
|
||||
<p align="center">Jellyfin</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/jellyseerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/jellyseerr.svg" alt="Jellyseerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -219,8 +205,8 @@
|
||||
<br/>
|
||||
<p align="center">Lidarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/linux-server-io" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/linuxserver-io.svg" alt="LinuxServer.io" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -254,8 +240,8 @@
|
||||
<br/>
|
||||
<p align="center">ntfy</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/nzbget" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/nzbget.svg" alt="NZBGet" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -268,8 +254,8 @@
|
||||
<br/>
|
||||
<p align="center">OpenMediaVault</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/opnsense" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/opnsense.svg" alt="OPNsense" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -303,8 +289,8 @@
|
||||
<br/>
|
||||
<p align="center">Pi-hole</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/plex" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/plex.svg" alt="Plex" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -317,8 +303,8 @@
|
||||
<br/>
|
||||
<p align="center">Prowlarr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/proxmox" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/proxmox.svg" alt="Proxmox" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -352,8 +338,8 @@
|
||||
<br/>
|
||||
<p align="center">Readarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/sabnzbd" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/sabnzbd.svg" alt="SABnzbd" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -366,8 +352,8 @@
|
||||
<br/>
|
||||
<p align="center">Search.ch</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/seerr" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/seerr.svg" alt="Seerr" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -401,8 +387,8 @@
|
||||
<br/>
|
||||
<p align="center">Tdarr</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/technitium-dns" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/selfhst/icons/svg/technitium.svg" alt="Technitium DNS" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -415,8 +401,8 @@
|
||||
<br/>
|
||||
<p align="center">Tracearr</p>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
<a href="https://homarr.dev/docs/integrations/transmission" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/transmission.svg" alt="Transmission" width="90" height="90" />
|
||||
<br/>
|
||||
@@ -450,8 +436,8 @@
|
||||
<br/>
|
||||
<p align="center">Unraid</p>
|
||||
</a>
|
||||
</td></tr>
|
||||
<tr><td align="center">
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="null" target="_blank" rel="noreferrer noopener">
|
||||
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/uptime-kuma.svg" alt="Uptime Kuma" width="90" height="90" />
|
||||
<br/>
|
||||
|
||||
+4
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "homarr",
|
||||
"version": "1.68.0",
|
||||
"version": "1.65.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "cross-env CI=true turbo build",
|
||||
@@ -61,10 +61,10 @@
|
||||
"vitest": "catalog:"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24.17.0",
|
||||
"pnpm": ">=11.9.0"
|
||||
"node": ">=24.16.0",
|
||||
"pnpm": ">=11.0.0"
|
||||
},
|
||||
"packageManager": "pnpm@11.9.0",
|
||||
"packageManager": "pnpm@11.5.3",
|
||||
"dependencies": {
|
||||
"jsonpath-plus": "catalog:"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
|
||||
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
|
||||
@@ -1,2 +1,2 @@
|
||||
export { trackEvent } from "./client";
|
||||
export { sendServerAnalyticsAsync } from "./send-server-analytics";
|
||||
export * from "./constants";
|
||||
export * from "./send-server-analytics";
|
||||
@@ -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()}`);
|
||||
};
|
||||
@@ -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",
|
||||
|
||||
@@ -9,7 +9,6 @@ import { iconsRouter } from "./router/icons";
|
||||
import { infoRouter } from "./router/info";
|
||||
import { integrationRouter } from "./router/integration/integration-router";
|
||||
import { inviteRouter } from "./router/invite";
|
||||
import { beszelRouter } from "./router/widgets/beszel";
|
||||
import { calendarRouter } from "./router/widgets/calendar";
|
||||
import { dnsHoleRouter } from "./router/widgets/dns-hole";
|
||||
import { downloadsRouter } from "./router/widgets/downloads";
|
||||
@@ -27,7 +26,6 @@ export const mcpRouter = createTRPCRouter({
|
||||
info: infoRouter,
|
||||
integration: integrationRouter,
|
||||
invite: inviteRouter,
|
||||
beszel: beszelRouter,
|
||||
calendar: calendarRouter,
|
||||
dnsHole: dnsHoleRouter,
|
||||
downloads: downloadsRouter,
|
||||
|
||||
@@ -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)),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { canManageGroupMembersLocally, isGroupMembershipManagedLocally } from "@homarr/auth/server";
|
||||
import { createId } from "@homarr/common";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like, not } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { groupMembers, groupPermissions, groups, users } from "@homarr/db/schema";
|
||||
import { groupMembers, groupPermissions, groups } from "@homarr/db/schema";
|
||||
import { everyoneGroup } from "@homarr/definitions";
|
||||
import { byIdSchema, paginatedSchema } from "@homarr/validation/common";
|
||||
import {
|
||||
@@ -19,6 +18,7 @@ import {
|
||||
} from "@homarr/validation/group";
|
||||
|
||||
import { createTRPCRouter, onboardingProcedure, permissionRequiredProcedure, protectedProcedure } from "../trpc";
|
||||
import { throwIfCredentialsDisabled } from "./invite/checks";
|
||||
import { nextOnboardingStepAsync } from "./onboard/onboard-queries";
|
||||
|
||||
export const groupRouter = createTRPCRouter({
|
||||
@@ -315,10 +315,10 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfGroupMembersCannotBeManagedLocally();
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
const user = await ctx.db.query.users.findFirst({
|
||||
where: eq(users.id, input.userId),
|
||||
where: eq(groups.id, input.userId),
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
@@ -328,13 +328,6 @@ export const groupRouter = createTRPCRouter({
|
||||
});
|
||||
}
|
||||
|
||||
if (!isGroupMembershipManagedLocally(user.provider)) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "User's provider is not managed locally",
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.insert(groupMembers).values({
|
||||
groupId: input.groupId,
|
||||
userId: input.userId,
|
||||
@@ -346,7 +339,7 @@ export const groupRouter = createTRPCRouter({
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
await throwIfGroupNotFoundAsync(ctx.db, input.groupId);
|
||||
await throwIfGroupNameIsReservedAsync(ctx.db, input.groupId);
|
||||
throwIfGroupMembersCannotBeManagedLocally();
|
||||
throwIfCredentialsDisabled();
|
||||
|
||||
await ctx.db
|
||||
.delete(groupMembers)
|
||||
@@ -378,15 +371,6 @@ const throwIfGroupNameIsReservedAsync = async (db: Database, id: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupMembersCannotBeManagedLocally = () => {
|
||||
if (!canManageGroupMembersLocally()) {
|
||||
throw new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Group members cannot be managed locally",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const throwIfGroupNotFoundAsync = async (db: Database, id: string) => {
|
||||
const group = await db.query.groups.findFirst({
|
||||
where: eq(groups.id, id),
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
|
||||
@@ -748,45 +748,7 @@ describe("addMember should add member to group", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
|
||||
});
|
||||
|
||||
test("with a user from an externally managed provider it should throw FORBIDDEN error", async () => {
|
||||
// Arrange
|
||||
const db = createDb();
|
||||
const spy = vi.spyOn(env, "env", "get");
|
||||
spy.mockReturnValue({ AUTH_PROVIDERS: ["credentials", "ldap"] } as never);
|
||||
const caller = groupRouter.createCaller({ db, deviceType: undefined, session: adminSession });
|
||||
|
||||
const groupId = createId();
|
||||
const userId = createId();
|
||||
await db.insert(users).values([
|
||||
{
|
||||
id: userId,
|
||||
name: "User",
|
||||
provider: "ldap",
|
||||
},
|
||||
{
|
||||
id: defaultOwnerId,
|
||||
name: "Creator",
|
||||
},
|
||||
]);
|
||||
await db.insert(groups).values({
|
||||
id: groupId,
|
||||
name: "Group",
|
||||
ownerId: defaultOwnerId,
|
||||
position: 1,
|
||||
});
|
||||
|
||||
// Act
|
||||
const actAsync = async () =>
|
||||
await caller.addMember({
|
||||
groupId,
|
||||
userId,
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("User's provider is not managed locally");
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -910,7 +872,7 @@ describe("removeMember should remove member from group", () => {
|
||||
});
|
||||
|
||||
// Assert
|
||||
await expect(actAsync()).rejects.toThrow("Group members cannot be managed locally");
|
||||
await expect(actAsync()).rejects.toThrow("Credentials provider is disabled");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -7,12 +7,12 @@ import { comparePasswordsAsync, hashPasswordAsync } from "@homarr/auth";
|
||||
import { createId } from "@homarr/common";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
import type { Database } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, inArray, like } from "@homarr/db";
|
||||
import { and, eq, handleTransactionsAsync, like } from "@homarr/db";
|
||||
import { getMaxGroupPositionAsync } from "@homarr/db/queries";
|
||||
import { boards, groupMembers, groupPermissions, groups, invites, users } from "@homarr/db/schema";
|
||||
import { selectUserSchema } from "@homarr/db/validationSchemas";
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup, supportedAuthProviders } from "@homarr/definitions";
|
||||
import { credentialsAdminGroup } from "@homarr/definitions";
|
||||
import { byIdSchema } from "@homarr/validation/common";
|
||||
import type { userBaseCreateSchema } from "@homarr/validation/user";
|
||||
import {
|
||||
@@ -257,7 +257,7 @@ export const userRouter = createTRPCRouter({
|
||||
}),
|
||||
// Is protected because also used in board access / integration access forms
|
||||
selectable: protectedProcedure
|
||||
.input(z.object({ providers: z.array(z.enum(supportedAuthProviders)).optional() }).optional())
|
||||
.input(z.object({ excludeExternalProviders: z.boolean().default(false) }).optional())
|
||||
.output(
|
||||
z.array(
|
||||
selectUserSchema.pick({
|
||||
@@ -284,7 +284,7 @@ export const userRouter = createTRPCRouter({
|
||||
image: true,
|
||||
email: true,
|
||||
},
|
||||
where: input?.providers ? inArray(users.provider, input.providers) : undefined,
|
||||
where: input?.excludeExternalProviders ? eq(users.provider, "credentials") : undefined,
|
||||
});
|
||||
}),
|
||||
search: permissionRequiredProcedure
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import { observable } from "@trpc/server/observable";
|
||||
|
||||
import type { ArchiveTeamWarriorStatus } from "@homarr/integrations";
|
||||
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
|
||||
|
||||
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const archiveTeamWarriorRouter = createTRPCRouter({
|
||||
getStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.query(async ({ ctx }) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const { data, timestamp } = await handler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
|
||||
return {
|
||||
status: data,
|
||||
updatedAt: timestamp,
|
||||
};
|
||||
}),
|
||||
|
||||
subscribeStatus: publicProcedure
|
||||
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{ status: ArchiveTeamWarriorStatus; updatedAt: Date }>((emit) => {
|
||||
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
|
||||
const unsubscribe = handler.subscribe((status) => {
|
||||
emit.next({
|
||||
status,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
});
|
||||
|
||||
return () => {
|
||||
unsubscribe();
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -1,10 +1,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 {
|
||||
beszelAlertsRequestHandler,
|
||||
beszelStatsRequestHandler,
|
||||
@@ -15,27 +11,14 @@ 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({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get all Beszel-monitored systems with CPU, memory, disk, GPU, network, temperature, and status. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all)",
|
||||
},
|
||||
})
|
||||
.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({
|
||||
forceUpdate: false,
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
@@ -45,40 +28,13 @@ 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;
|
||||
}),
|
||||
|
||||
subscribeSystems: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.subscription(({ ctx }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
systems: BeszelSystemRow[];
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
return observable<{ integrationId: string; systems: BeszelSystemRow[]; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
|
||||
return innerHandler.subscribe((systems) => {
|
||||
@@ -96,13 +52,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
getAlerts: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get Beszel alerts and optional alert history for all monitored systems. REQUIRED: integrationIds (array of Beszel integration IDs from integration_all). OPTIONAL: includeHistory (default true), maxHistoryItems (default 10)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
@@ -111,14 +60,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 +77,6 @@ export const beszelRouter = createTRPCRouter({
|
||||
}
|
||||
return {
|
||||
integrationId: integration.id,
|
||||
integrationName: integration.name,
|
||||
alerts: alertsResult.data.alerts,
|
||||
history: alertsResult.data.history,
|
||||
systemNameMap,
|
||||
@@ -143,30 +84,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;
|
||||
}),
|
||||
|
||||
@@ -179,22 +96,14 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
integrationId: string;
|
||||
alerts: BeszelAlertsData;
|
||||
timestamp: Date;
|
||||
}>((emit) => {
|
||||
return observable<{ integrationId: string; alerts: BeszelAlertsData; timestamp: Date }>((emit) => {
|
||||
const unsubscribes = ctx.integrations.map((integration) => {
|
||||
const innerHandler = beszelAlertsRequestHandler.handler(integration, {
|
||||
includeHistory: input.includeHistory,
|
||||
maxHistoryItems: input.maxHistoryItems,
|
||||
});
|
||||
return innerHandler.subscribe((data) => {
|
||||
emit.next({
|
||||
integrationId: integration.id,
|
||||
alerts: data,
|
||||
timestamp: new Date(),
|
||||
});
|
||||
emit.next({ integrationId: integration.id, alerts: data, timestamp: new Date() });
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
@@ -204,117 +113,23 @@ export const beszelRouter = createTRPCRouter({
|
||||
}),
|
||||
|
||||
getSystemStats: publicProcedure
|
||||
.meta({
|
||||
mcp: {
|
||||
enabled: true,
|
||||
description:
|
||||
"Get historical Beszel system metrics (CPU, memory, disk, network, temperature) and optional Docker container stats. REQUIRED: integrationIds (pass the single integrationId from the beszel_getSystems entry containing the target system — only the first ID is used), systemId (from beszel_getSystems). OPTIONAL: timePeriod (1m/1h/12h/24h/1w/30d, default 1h), includeDocker (default true)",
|
||||
},
|
||||
})
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
timePeriod: z.enum(["1m", "1h", "12h", "24h", "1w", "30d"]).default("1h"),
|
||||
timePeriod: z.enum(["1h", "12h", "24h", "1w", "30d"]),
|
||||
includeDocker: z.boolean().default(true),
|
||||
}),
|
||||
)
|
||||
.query(async ({ ctx, input }) => {
|
||||
const integration = ctx.integrations[0];
|
||||
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,
|
||||
if (!integration) return null;
|
||||
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),
|
||||
};
|
||||
}
|
||||
}),
|
||||
|
||||
subscribeSystemStats: publicProcedure
|
||||
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
|
||||
.input(
|
||||
z.object({
|
||||
systemId: z.string(),
|
||||
}),
|
||||
)
|
||||
.subscription(({ ctx, input }) => {
|
||||
return observable<{
|
||||
stats: BeszelSystemStatsRecord;
|
||||
containerStats: BeszelContainerStatsRecord | null;
|
||||
}>((emit) => {
|
||||
const integration = ctx.integrations[0];
|
||||
if (!integration) {
|
||||
emit.error(new Error("No Beszel integration configured"));
|
||||
return () => {};
|
||||
}
|
||||
logger.debug("subscribeSystemStats opened", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const instance = await createIntegrationAsync(integration);
|
||||
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
|
||||
} catch (error) {
|
||||
if (!abortController.signal.aborted) {
|
||||
logger.warn("subscribeSystemStats error", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
error,
|
||||
});
|
||||
if (error instanceof Error) {
|
||||
emit.error(error);
|
||||
} else {
|
||||
emit.error(new Error(String(error)));
|
||||
}
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
abortController.abort();
|
||||
logger.debug("subscribeSystemStats closed", {
|
||||
userId: ctx.session?.user?.id,
|
||||
integrationId: integration.id,
|
||||
systemId: input.systemId,
|
||||
});
|
||||
};
|
||||
});
|
||||
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
|
||||
return { integrationId: integration.id, ...data, updatedAt: timestamp };
|
||||
}),
|
||||
});
|
||||
@@ -3,7 +3,6 @@ import { lazy } from "@trpc/server";
|
||||
import { createTRPCRouter } from "../../trpc";
|
||||
|
||||
export const widgetRouter = createTRPCRouter({
|
||||
archiveTeamWarrior: lazy(() => import("./archive-team-warrior").then((mod) => mod.archiveTeamWarriorRouter)),
|
||||
beszel: lazy(() => import("./beszel").then((mod) => mod.beszelRouter)),
|
||||
anchorNotes: lazy(() => import("./anchor-notes").then((mod) => mod.anchorNotesRouter)),
|
||||
coolify: lazy(() => import("./coolify").then((mod) => mod.coolifyRouter)),
|
||||
@@ -36,7 +35,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)),
|
||||
});
|
||||
@@ -1,14 +1,7 @@
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import SuperJSON from "superjson";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
import { eq } from "@homarr/db";
|
||||
import { getServerSettingsAsync } from "@homarr/db/queries";
|
||||
import { boards, items } from "@homarr/db/schema";
|
||||
|
||||
import type { WidgetOptionsSettings } from "../../../../widgets/src";
|
||||
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
|
||||
import { throwIfActionForbiddenAsync } from "../board/board-access";
|
||||
import { createTRPCRouter, publicProcedure } from "../../trpc";
|
||||
|
||||
export const optionsRouter = createTRPCRouter({
|
||||
getWidgetOptionSettings: publicProcedure.query(async ({ ctx }): Promise<WidgetOptionsSettings> => {
|
||||
@@ -23,33 +16,4 @@ export const optionsRouter = createTRPCRouter({
|
||||
},
|
||||
};
|
||||
}),
|
||||
saveItemOptions: protectedProcedure
|
||||
.input(
|
||||
z.object({
|
||||
itemId: z.string(),
|
||||
boardId: z.string(),
|
||||
newOptions: z.record(z.string(), z.unknown()),
|
||||
}),
|
||||
)
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
|
||||
|
||||
const item = await ctx.db.query.items.findFirst({
|
||||
where: eq(items.id, input.itemId),
|
||||
});
|
||||
|
||||
if (item?.boardId !== input.boardId) {
|
||||
throw new TRPCError({
|
||||
code: "NOT_FOUND",
|
||||
message: "Specified item was not found",
|
||||
});
|
||||
}
|
||||
|
||||
const options = SuperJSON.parse<Record<string, unknown>>(item.options);
|
||||
Object.assign(options, input.newOptions);
|
||||
await ctx.db
|
||||
.update(items)
|
||||
.set({ options: SuperJSON.stringify(options) })
|
||||
.where(eq(items.id, input.itemId));
|
||||
}),
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
};
|
||||
});
|
||||
}),
|
||||
});
|
||||
@@ -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)),
|
||||
|
||||
@@ -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(),
|
||||
@@ -42,7 +37,6 @@ export const env = createEnv({
|
||||
AUTH_OIDC_AUTO_LOGIN: createBooleanSchema(false),
|
||||
AUTH_OIDC_SCOPE_OVERWRITE: z.string().min(1).default("openid email profile groups"),
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: z.string().default("groups"), // Is used in the signIn event to assign the correct groups, key is from object of decoded id_token
|
||||
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: createBooleanSchema(false), // When enabled, group memberships of oidc users are managed locally instead of synced from the groups claim
|
||||
AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE: z.string().optional(),
|
||||
AUTH_OIDC_FORCE_USERINFO: createBooleanSchema(false),
|
||||
AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING: createBooleanSchema(false),
|
||||
|
||||
@@ -31,12 +31,7 @@ export const createSignInEventHandler = (db: Database): Exclude<NextAuthConfig["
|
||||
|
||||
const groupsKey = env.AUTH_OIDC_GROUPS_ATTRIBUTE;
|
||||
// Groups from oidc provider are provided from the profile, it's not typed.
|
||||
if (
|
||||
!env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT &&
|
||||
profile &&
|
||||
groupsKey in profile &&
|
||||
Array.isArray(profile[groupsKey])
|
||||
) {
|
||||
if (profile && groupsKey in profile && Array.isArray(profile[groupsKey])) {
|
||||
logger.debug(`Using profile groups (${groupsKey}): ${JSON.stringify(profile[groupsKey])}`);
|
||||
await synchronizeGroupsWithExternalForUserAsync(db, user.id, profile[groupsKey] as string[]);
|
||||
}
|
||||
|
||||
@@ -1,57 +1,9 @@
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
import { supportedAuthProviders } from "@homarr/definitions";
|
||||
|
||||
import { env } from "../env";
|
||||
|
||||
export type GroupMemberManagementType = "local" | "mixed" | "external";
|
||||
|
||||
export const isProviderEnabled = (provider: SupportedAuthProvider) => {
|
||||
// The question mark is placed there because isProviderEnabled is called during static build of about page
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return env.AUTH_PROVIDERS?.includes(provider);
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a single provider's group memberships are managed locally (manually via UI/API).
|
||||
* True for credentials users, and for oidc users when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled.
|
||||
*/
|
||||
export const isGroupMembershipManagedLocally = (provider: SupportedAuthProvider): boolean => {
|
||||
switch (provider) {
|
||||
case "credentials":
|
||||
return true;
|
||||
case "oidc":
|
||||
return env.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT;
|
||||
default:
|
||||
// ldap + any provider added later: externally managed (synced, not editable
|
||||
// here) until explicitly given a case above. New providers safe-by-default.
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const getEnabledProviders = (): SupportedAuthProvider[] => supportedAuthProviders.filter(isProviderEnabled);
|
||||
|
||||
/**
|
||||
* The enabled providers whose group memberships can be managed locally.
|
||||
*/
|
||||
export const getLocallyManageableProviders = (): SupportedAuthProvider[] =>
|
||||
getEnabledProviders().filter(isGroupMembershipManagedLocally);
|
||||
|
||||
/**
|
||||
* Classifies group-member management across all enabled providers:
|
||||
* - "local": every enabled provider is managed locally (no member is synced externally)
|
||||
* - "external": no enabled provider is managed locally (every member is synced externally)
|
||||
* - "mixed": some are managed locally, some externally (only a subset of members is editable here)
|
||||
*/
|
||||
export const getGroupMemberManagementType = (): GroupMemberManagementType => {
|
||||
const enabled = getEnabledProviders();
|
||||
const manageable = enabled.filter(isGroupMembershipManagedLocally);
|
||||
|
||||
if (manageable.length === enabled.length) return "local";
|
||||
if (manageable.length === 0) return "external";
|
||||
return "mixed";
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether group memberships can be managed locally for at least one enabled provider.
|
||||
*/
|
||||
export const canManageGroupMembersLocally = (): boolean => getLocallyManageableProviders().length > 0;
|
||||
@@ -1,105 +0,0 @@
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { SupportedAuthProvider } from "@homarr/definitions";
|
||||
|
||||
import * as env from "../../env";
|
||||
import {
|
||||
canManageGroupMembersLocally,
|
||||
getGroupMemberManagementType,
|
||||
getLocallyManageableProviders,
|
||||
isGroupMembershipManagedLocally,
|
||||
} from "../check-provider";
|
||||
|
||||
const mockEnv = (providers: SupportedAuthProvider[], oidcLocalManagement = false) => {
|
||||
vi.spyOn(env, "env", "get").mockReturnValue({
|
||||
AUTH_PROVIDERS: providers,
|
||||
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: oidcLocalManagement,
|
||||
} as never);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("isGroupMembershipManagedLocally", () => {
|
||||
test("credentials is always managed locally", () => {
|
||||
mockEnv(["credentials"]);
|
||||
expect(isGroupMembershipManagedLocally("credentials")).toBe(true);
|
||||
});
|
||||
|
||||
test("oidc follows AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT", () => {
|
||||
mockEnv(["oidc"], true);
|
||||
expect(isGroupMembershipManagedLocally("oidc")).toBe(true);
|
||||
|
||||
mockEnv(["oidc"], false);
|
||||
expect(isGroupMembershipManagedLocally("oidc")).toBe(false);
|
||||
});
|
||||
|
||||
test("ldap is never managed locally", () => {
|
||||
mockEnv(["ldap"]);
|
||||
expect(isGroupMembershipManagedLocally("ldap")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getGroupMemberManagementType", () => {
|
||||
test("credentials only is local", () => {
|
||||
mockEnv(["credentials"]);
|
||||
expect(getGroupMemberManagementType()).toBe("local");
|
||||
});
|
||||
|
||||
test("credentials + oidc with local management is local", () => {
|
||||
mockEnv(["credentials", "oidc"], true);
|
||||
expect(getGroupMemberManagementType()).toBe("local");
|
||||
});
|
||||
|
||||
test("credentials + oidc without local management is mixed", () => {
|
||||
mockEnv(["credentials", "oidc"], false);
|
||||
expect(getGroupMemberManagementType()).toBe("mixed");
|
||||
});
|
||||
|
||||
test("credentials + ldap is mixed", () => {
|
||||
mockEnv(["credentials", "ldap"]);
|
||||
expect(getGroupMemberManagementType()).toBe("mixed");
|
||||
});
|
||||
|
||||
test("ldap only is external", () => {
|
||||
mockEnv(["ldap"]);
|
||||
expect(getGroupMemberManagementType()).toBe("external");
|
||||
});
|
||||
|
||||
test("oidc only without local management is external", () => {
|
||||
mockEnv(["oidc"], false);
|
||||
expect(getGroupMemberManagementType()).toBe("external");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getLocallyManageableProviders", () => {
|
||||
test("returns only the enabled, locally managed providers", () => {
|
||||
mockEnv(["credentials", "oidc", "ldap"], true);
|
||||
expect(getLocallyManageableProviders()).toEqual(["credentials", "oidc"]);
|
||||
|
||||
mockEnv(["credentials", "oidc", "ldap"], false);
|
||||
expect(getLocallyManageableProviders()).toEqual(["credentials"]);
|
||||
|
||||
mockEnv(["ldap"]);
|
||||
expect(getLocallyManageableProviders()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("canManageGroupMembersLocally", () => {
|
||||
test("is true when at least one enabled provider is managed locally", () => {
|
||||
mockEnv(["credentials", "ldap"]);
|
||||
expect(canManageGroupMembersLocally()).toBe(true);
|
||||
|
||||
mockEnv(["oidc"], true);
|
||||
expect(canManageGroupMembersLocally()).toBe(true);
|
||||
});
|
||||
|
||||
test("is false when every enabled provider is external", () => {
|
||||
mockEnv(["ldap"]);
|
||||
expect(canManageGroupMembersLocally()).toBe(false);
|
||||
|
||||
mockEnv(["oidc"], false);
|
||||
expect(canManageGroupMembersLocally()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,4 @@
|
||||
export { hasQueryAccessToIntegrationsAsync } from "./permissions/integration-query-permissions";
|
||||
export { getIntegrationsWithPermissionsAsync } from "./permissions/integrations-with-permissions";
|
||||
export {
|
||||
isProviderEnabled,
|
||||
isGroupMembershipManagedLocally,
|
||||
getLocallyManageableProviders,
|
||||
getGroupMemberManagementType,
|
||||
canManageGroupMembersLocally,
|
||||
} from "./providers/check-provider";
|
||||
export type { GroupMemberManagementType } from "./providers/check-provider";
|
||||
export { isProviderEnabled } from "./providers/check-provider";
|
||||
export { createSessionCallback, createSessionAsync } from "./callbacks";
|
||||
@@ -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);
|
||||
|
||||
@@ -12,13 +12,11 @@ import { colorSchemeCookieKey, everyoneGroup } from "@homarr/definitions";
|
||||
import { createSignInEventHandler } from "../events";
|
||||
|
||||
vi.mock("next-auth", () => ({}));
|
||||
const mockEnv = vi.hoisted(() => ({
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
|
||||
AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT: false,
|
||||
}));
|
||||
vi.mock("../env", () => {
|
||||
return {
|
||||
env: mockEnv,
|
||||
env: {
|
||||
AUTH_OIDC_GROUPS_ATTRIBUTE: "someRandomGroupsKey",
|
||||
},
|
||||
};
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
@@ -200,30 +198,6 @@ describe("createSignInEventHandler should create signInEventHandler", () => {
|
||||
});
|
||||
expect(dbGroupMembers?.groupId).toBe("1");
|
||||
});
|
||||
test("should not synchronize groups when AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT is enabled", async () => {
|
||||
// Arrange
|
||||
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = true;
|
||||
const db = createDb();
|
||||
await createUserAsync(db);
|
||||
await createGroupAsync(db);
|
||||
const eventHandler = createSignInEventHandler(db);
|
||||
|
||||
// Act
|
||||
await eventHandler?.({
|
||||
user: { id: "1", name: "test" },
|
||||
profile: { preferred_username: "test", someRandomGroupsKey: ["test"] },
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Assert
|
||||
const dbGroupMembers = await db.query.groupMembers.findFirst({
|
||||
where: eq(groupMembers.userId, "1"),
|
||||
});
|
||||
expect(dbGroupMembers).toBeUndefined();
|
||||
|
||||
// Cleanup
|
||||
mockEnv.AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT = false;
|
||||
});
|
||||
});
|
||||
test.each([
|
||||
["ldap" as const, { name: "test-new" }, undefined],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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: () => ({}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
@@ -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) => {
|
||||
|
||||
@@ -98,6 +98,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/tags/puid"
|
||||
| "/docs/tags/redis"
|
||||
| "/docs/tags/responsive"
|
||||
| "/docs/tags/restore"
|
||||
| "/docs/tags/roles"
|
||||
| "/docs/tags/search-engines"
|
||||
| "/docs/tags/search"
|
||||
@@ -165,7 +166,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,8 +180,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"
|
||||
| "/docs/integrations/immich"
|
||||
@@ -235,6 +233,7 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/management/search-engines"
|
||||
| "/docs/management/settings"
|
||||
| "/docs/management/tasks"
|
||||
| "/docs/management/tools/backup"
|
||||
| "/docs/management/users"
|
||||
| "/docs/widgets/anchor-note"
|
||||
| "/docs/widgets/app"
|
||||
@@ -282,7 +281,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/widgets/ups"
|
||||
| "/docs/widgets/uptime-kuma"
|
||||
| "/docs/widgets/video"
|
||||
| "/docs/widgets/vpn"
|
||||
| "/docs/widgets/weather"
|
||||
| ""
|
||||
| "/sitemap.xml"
|
||||
@@ -325,7 +323,6 @@ export type HomarrDocumentationPath =
|
||||
| "/docs/integrations/github-containerregistry"
|
||||
| "/docs/integrations/quay"
|
||||
| "/docs/integrations/ntfy"
|
||||
| "/docs/integrations/gotify"
|
||||
| "/docs/integrations/ical"
|
||||
| "/docs/integrations/anchor"
|
||||
| "/docs/integrations/truenas"
|
||||
@@ -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"
|
||||
|
||||
@@ -40,7 +40,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
gitHubContainerRegistry: "github-containerregistry",
|
||||
quay: "quay",
|
||||
ntfy: "ntfy",
|
||||
gotify: "gotify",
|
||||
ical: "ical",
|
||||
anchor: "anchor",
|
||||
truenas: "truenas",
|
||||
@@ -55,9 +54,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
|
||||
audiobookshelf: "audiobookshelf",
|
||||
navidrome: "navidrome",
|
||||
umami: "umami",
|
||||
gluetun: "gluetun",
|
||||
peaNut: "peanut",
|
||||
beszel: "beszel",
|
||||
archiveTeamWarrior: null,
|
||||
mock: null,
|
||||
};
|
||||
@@ -43,9 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
|
||||
uptimeKuma: "uptime-kuma",
|
||||
audioStats: "audio-stats",
|
||||
umami: "umami",
|
||||
vpn: "vpn",
|
||||
ups: "ups",
|
||||
archiveTeamWarrior: null,
|
||||
beszelSystemTable: "beszel-system-table",
|
||||
beszelSystemGrid: "beszel-system-grid",
|
||||
beszelAlerts: "beszel-alerts",
|
||||
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user