Compare commits

...
Author SHA1 Message Date
manuel-rw 83738bfebf style: auto-fix lint and format 2026-07-01 10:23:39 +00:00
Manuel 5089a2ae6b feat: add fileflows integration 2026-07-01 12:22:12 +02:00
homarr-renovate[bot] b83c8b6546 chore(deps): update dependency posthog-js to ^1.393.5 (#6135)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 21:53:10 +00:00
homarr-renovate[bot] 0e4189f693 chore(deps): update dependency dockerode to ^5.0.1 (#6130)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 20:09:36 +00:00
homarr-renovate[bot] 1cf010e9fb chore(deps): update node.js to v24.18.0 (#6107)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-30 18:11:58 +00:00
Thomasandgithub-actions[bot] 7e58858353 feat(widgets): right-click context menu with auto-generated toggle actions (#6126)
* docs(helm): Update Helm chart documentation

* feat(widgets): add right-click context menu with auto-generated toggle actions

- Add WidgetContextMenu component using Mantine 9.3 Menu.ContextMenu
- Extend WidgetDefinition with contextActions API for widget-specific actions
- Auto-generate context menu toggle items for switch-type widget options
- Add 'Search' context action to media-requests widgets (list + stats)
- Keep existing dots-button menu for discoverability in edit mode
- Add widgetStateRef to WidgetComponentProps for client-side-only state
- Add skipContextMenu opt-out on switch option definitions

* style: auto-fix lint and format

* fix(widgets): address CodeRabbit review — badge in context menu target, edit-only pending gate

- Move title badge inside WidgetContextMenu wrapper so right-clicking the title overlay also triggers the context menu
- Remove full-menu hide during integration loading; only disable 'Edit' action while isPending
- Add isPending to allActions useMemo dependency array

* style: auto-fix lint and format

* fix(widgets): gate context menu behind authenticated session

- Only show right-click context menu for logged-in users
- Unauthenticated users see plain children without menu wrapper
- Matches existing BoardItemMenu session gating pattern

* style: auto-fix lint and format

* chore: revert unrelated sitemap change from auto-fix

* style: auto-fix lint and format

* fix(context-menu): persist widget option changes to server

Toggle switches and edit modal changes from the context menu now call
saveBoard mutation to persist to DB. Previously only the client cache
was updated, losing changes on reload. Removes unnecessary invalidation
that caused cascading refetch loops.

* style: auto-fix lint and format

* fix: use explicit React type imports instead of global namespace

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-06-30 18:10:08 +02:00
9ed55d57dd Main (#6138)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-30 17:52:37 +02:00
homarr-crowdin[bot] 8cac0b11c1 chore(lang): updated translations from crowdin (#6061) 2026-06-30 14:02:29 +02:00
homarr-renovate[bot] 5f011e8456 chore(deps): update turbo monorepo to ^2.10.0 (#6129)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:22:40 +00:00
homarr-renovate[bot] 9e1b529288 chore(deps): update dependency nanoid@>=4.0.0 <5.0.9 to >=5.1.16 (#6131)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:14:32 +00:00
homarr-renovate[bot] 4ad115dd2d chore(deps): update dependency posthog-js to ^1.393.4 (#6127)
* chore(deps): update dependency posthog-js to ^1.393.4

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-29 20:01:36 +00:00
27209a5dde chore: add integration CODEOWNERS (#6086)
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
Co-authored-by: Afonso Jorge Ramos <afonsojorgeramos@gmail.com>
2026-06-29 18:56:44 +02:00
Thomas ed33748a49 Merge pull request #6118 from darbyjack/docs/warrior 2026-06-29 15:12:57 +02:00
homarr-renovate[bot] 7a21e2a9c3 chore(deps): update dependency @playwright/test to ^1.61.1 (#6120)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 21:14:08 +00:00
darbyjack f66469d797 feat(docs): addd in documentation for archiveteam warrior 2026-06-28 15:45:52 -05:00
homarr-renovate[bot] c4a0c818d0 chore(deps): update mantine monorepo to ^9.4.0 (#6102)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 19:32:49 +00:00
homarr-renovate[bot] ef016061ae chore(deps): update pnpm to v11.9.0 (#6117)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 17:26:09 +00:00
homarr-renovate[bot] 3492700e6b chore(deps): update dependency vite to ^8.1.0 (#6114)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:06:36 +00:00
homarr-renovate[bot] 590d316671 chore(deps): update dependency vite@>=5.0.0 <=5.4.18 to >=8.1.0 (#6115)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:03:05 +00:00
homarr-renovate[bot] 6e9d20e651 chore(deps): update dependency recharts to ^3.9.0 (#6113)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 14:00:52 +00:00
homarr-renovate[bot] ed829fddfa chore(deps): update dependency posthog-js to ^1.393.0 (#6112)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 13:57:57 +00:00
homarr-renovate[bot] ed8b8a40eb chore(deps): update dependency @vitejs/plugin-react to ^6.0.3 (#6110)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 11:18:49 +00:00
homarr-renovate[bot] 7bc3bbb8c0 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.6 (#6109)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 09:03:24 +00:00
homarr-renovate[bot] 74943ef9bc chore(deps): update dependency hono@<4.6.5 to >=4.12.27 (#6108)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 05:28:04 +00:00
homarr-renovate[bot] 5dd7f839ee chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.18.1 (#6104)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:16:33 +00:00
homarr-renovate[bot] d8c6c0f978 chore(deps): update dependency posthog-js to ^1.391.4 (#6094)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:16:09 +00:00
homarr-renovate[bot] 75b2d1bd1b chore(deps): update dependency oxlint to ^1.71.0 (#6101)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:15:23 +00:00
homarr-renovate[bot] 7c12a9ce48 chore(deps): update dependency @scalar/api-reference-react to ^0.9.48 (#6103)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-28 00:13:44 +00:00
homarr-renovate[bot] 9033acc66c chore(deps): update dependency oxfmt to ^0.56.0 (#6100)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-27 22:17:27 +00:00
Thomas a42dc34d34 Revert "feat(config): enable pnpm global virtual store for worktree support" (#6106) 2026-06-27 23:37:36 +02:00
euduardo db3d58821e feat(widgets): show calendar events on hover with pin on click (#2637) (#6081)
feat(widgets): show calendar events on hover with pin on click (#2637)

docs(widgets): update calendar interactions section for hover behavior
2026-06-27 22:49:26 +02:00
Bergasha 076526293d feat(docker-widget): add per-column visibility toggle (#6093)
* Add 'Columns to show' label in Docker stats

* feat: Add column translation keys for Docker widget

* feat: Enhance table with column visibility state

Added column visibility state management for the table.

* Add 'noColumns' error message to en.json
2026-06-27 22:44:39 +02:00
Thomasandajnart 2443f1cd3f fix: dns-hole widgets showing stale disconnected state (#6096)
Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-06-27 22:37:34 +02:00
Thomas 3864727502 feat(config): enable pnpm global virtual store for worktree support (#6098) 2026-06-27 13:23:01 +02:00
homarr-releases[bot] f3608b26a0 chore: update bug report template (#6091)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-26 18:43:53 +00:00
Thomas 73bed51df8 fix(widgets): make downloads widget table background transparent (#6025)
The MantineReactTable defaults table cell backgrounds to
var(--mantine-color-body) which is opaque. This makes the
downloads widget table block appear solid while the footer
and other widgets correctly show the board's background.

Added a styles.css targeting table elements to force
background-color: transparent, following the same pattern
used by the beszel-system-table widget. Also added
bg=transparent to mantinePaperProps for the Paper wrapper.
2026-06-26 20:27:25 +02:00
Releases Homarr ab9aca8b60 chore(release): 1.68.0 [skip ci] 2026-06-26 18:23:57 +00:00
homarr-releases[bot] dcde2819eb chore(release): automatic release v1.68.0 2026-06-26 18:22:47 +00:00
Thomas abd16354bc feat: re-add Docker quick-access button in user menu (#6054)
Adds a Docker button to the user avatar dropdown menu (admin-only)
that opens a 1400px modal containing the same Docker container table
as /manage/tools/docker.

Closes #2165
2026-06-26 20:16:34 +02:00
homarr-update-contributors[bot] 6ac352c520 chore: update contributors (#6088)
* chore: update contributors

* style: auto-fix lint and format

---------

Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-26 18:14:48 +00:00
Thomas 19f9769d9e fix(manage/about): bypass self-fetch + add manage error boundary (LLM exploration) (#6009)
* fix(manage/about): bypass self-fetch to prevent ECONNREFUSED crash

The /manage/about page fetched its own API routes over HTTP
(http://$HOSTNAME:3000/api/about/...). When the server could not
reach itself (misconfigured HOSTNAME, restricted container networking,
reverse-proxy setups), fetch threw ECONNREFUSED and the whole page
crashed with a full-page Next.js error (issue #6008).

All four routes were force-static serving static JSON / a direct fs
read, so the HTTP round-trip was unnecessary. Replace the fetches
with direct JSON imports and a direct getDependenciesAsync() call,
eliminating the self-network dependency entirely.

Also add a manage-level error.tsx boundary (Next.js 16 convention) so
that any future runtime error in a manage page renders a friendly,
localised fallback inside the existing layout (sidebar stays usable)
instead of the ugly default full-page error.

Exploration draft by an LLM.

Refs #6008

* style: auto-fix lint and format

* refactor: use @static-data path alias, simplify error ternary

- Add @static-data/* tsconfig path alias to replace 7-level relative
  imports (../../../../../../../static-data/...) with clean alias
  imports across the about page and all 3 API route handlers.
- Simplify error.tsx: replace ternary with && short-circuit.
- Preserve CI lint bot's .sort() -> .toSorted() fix.

Refs #6008
2026-06-26 20:12:39 +02:00
homarr-renovate[bot] f0b2486c8a chore(deps): update dependency timezones-ical-library to ^2.2.1 (#6077)
* chore(deps): update dependency timezones-ical-library to ^2.2.1

* style: auto-fix lint and format

---------

Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-26 16:18:40 +00:00
Matt Van Horn 2443b89b46 fix(plex): use series title for media releases instead of season label (#6083) 2026-06-26 14:42:36 +02:00
Thomas 915c8a3501 fix: show app first in add item modal (#6087) 2026-06-26 14:42:13 +02:00
a2bb53a8b8 feat: add VPN status widget (#5812)
Co-authored-by: Lokesh-Kumar-Dudi <lkumardudi@gmail.com>
Co-authored-by: Thomas Camlong <thomas@ajnart.fr>
2026-06-26 14:29:23 +02:00
homarr-renovate[bot] f56a05bd8e chore(deps): update dependency video.js to ^8.23.9 (#6084)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-26 11:04:52 +00:00
homarr-renovate[bot] 6051b17401 chore(deps): update dependency nanoid@>=4.0.0 <5.0.9 to >=5.1.15 (#6079)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-25 16:37:40 +00:00
homarr-renovate[bot] 29eed08408 chore(deps): update dependency isomorphic-dompurify to ^3.18.0 (#6074)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 17:20:55 +00:00
homarr-renovate[bot] bd74fe4a61 chore(deps): update dependency posthog-js to ^1.391.0 (#6073)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 17:18:43 +00:00
homarr-renovate[bot] b88e8861e1 chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.62.1 (#6076)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 17:17:34 +00:00
homarr-renovate[bot] 39af3ec7e6 chore(deps): update dependency mantine-datatable to ^9.3.1 (#6072)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 17:17:15 +00:00
homarr-renovate[bot] 7f50be3dbb chore(deps): update dependency fast-xml-parser to ^5.9.3 (#6069)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 14:48:29 +00:00
homarr-renovate[bot] b6b7bea1dd chore(deps): update dependency mantine-contextmenu to ^9.3.1 (#6071)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 11:56:45 +00:00
homarr-renovate[bot] 9bde1dfbd6 chore(deps): update dependency pg to ^8.22.0 (#6068)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-24 04:26:21 +00:00
homarr-renovate[bot] c2712ed7f1 chore(deps): update tiptap monorepo to v3.27.1 (#6067)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 22:24:52 +00:00
homarr-renovate[bot] 16be7ece33 chore(deps): update dependency nanoid@>=4.0.0 <5.0.9 to >=5.1.14 (#6066)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 20:19:16 +00:00
homarr-renovate[bot] b0a46186d1 chore(deps): update node.js to v24.17.0 (#6043)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 18:07:27 +00:00
homarr-renovate[bot] d0385a4480 chore(deps): update dependency @ctrl/qbittorrent to ^9.13.0 (#5978)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 18:06:38 +00:00
homarr-renovate[bot] 3d6a3594a3 chore(deps): update pnpm to v11.8.0 (#6064)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 16:02:26 +00:00
homarr-renovate[bot] 641ee18278 chore(deps): update dependency hono@<4.6.5 to >=4.12.26 (#6062)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 12:44:43 +00:00
homarr-renovate[bot] 2e538bf40d chore(deps): update dependency lucide-react to ^1.21.0 (#6063)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 09:52:42 +00:00
homarr-renovate[bot] 1c0a94a6dc chore(deps): update playwright monorepo (#6045)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 00:26:23 +00:00
homarr-renovate[bot] 3a3dc449de chore(deps): update scalar monorepo (#6056)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 00:22:43 +00:00
homarr-renovate[bot] d6b156b8ee chore(deps): update dependency posthog-js to ^1.388.1 (#6041)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 00:21:38 +00:00
homarr-renovate[bot] fc4e31fb42 chore(deps): update dependency @gfazioli/mantine-onboarding-tour to ^4.0.5 (#6033)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-23 00:19:54 +00:00
homarr-renovate[bot] b82b22431b chore(deps): update vitest monorepo to ^4.1.9 (#6037)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 22:15:25 +00:00
homarr-renovate[bot] 510cba295a chore(deps): update dependency oxfmt to ^0.55.0 (#6040)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 19:49:24 +00:00
homarr-renovate[bot] 56d838dafc chore(deps): update dependency fast-xml-parser to ^5.9.2 (#6039)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 19:45:28 +00:00
homarr-renovate[bot] eae9f0438c chore(deps): update oxlint monorepo (#6044)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 16:17:14 +00:00
homarr-renovate[bot] b81f729bfd chore(deps): update testcontainers-node monorepo to ^12.0.3 (#6057)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 16:07:28 +00:00
homarr-renovate[bot] 86b83c5425 chore(deps): update mantine monorepo to ^9.3.2 (#6036)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 16:05:35 +00:00
Thomas bbc4589c9e fix(beszel): leverage TanStack Query for stale-while-revalidate caching (#6055) 2026-06-22 17:17:09 +02:00
Thomas ea9dd716f0 Merge pull request #6053 from homarr-labs/beszel-1w-30d-data-view
fix(beszel): use 480m interval for 1w/30d stats views
2026-06-22 15:44:43 +02:00
Thomas Camlong 4383da38ef fix(beszel): use 480m interval for 1w/30d stats views
Beszel only stores stats at 1m, 10m, 20m, 120m, 480m intervals.
The 1440m type doesn't exist, so queries for 1w/30d returned empty
results. Use 480m (8h) with appropriate perPage (21 for 1w, 90 for 30d).
2026-06-22 15:44:14 +02:00
homarr-crowdin[bot] 58cb65cb3a chore(lang): updated translations from crowdin (#6029) 2026-06-22 15:23:27 +02:00
Matt Van Horn 89380f81dd feat: ship source maps for readable production stack traces (#6031) 2026-06-22 15:22:54 +02:00
Thomas e01571b221 fix(icon-picker): keep URL visible in edit mode, add SVG-first ordering (#6051) 2026-06-22 15:22:25 +02:00
Thomasandajnart 49c266374d fix(beszel): harden cache layer and prevent blank widgets (#6052)
* fix(beszel): harden cache layer and prevent blank widgets

- Add debug logging across Beszel integration, request handler, and tRPC
  router (auth, fetch, SSE, per-procedure entry/exit) gated behind
  LOG_LEVEL=debug
- cached-request-handler: retry with backoff, isValid predicate to skip
  caching invalid data, in-flight dedup (single-flight per channel) to
  avoid thundering herd after cache invalidation
- redis channel: set-then-publish so storage is always consistent even
  when pubsub publish fails
- Beszel handlers: opt into fallbackToStaleOnError and retry so brief
  upstream hiccups don't throw away good cached data
- beszel-system-stats: add refetchInterval matching each time period's
  cache TTL so historical charts refresh in the background
- tRPC router: switch Promise.all -> Promise.allSettled for getSystems
  and getAlerts; getSystemStats returns an error object instead of
  throwing. Each result carries an optional error field so one failed
  integration no longer nukes the whole widget
- Widgets: add explicit isPending loader, empty state for grid and
  stats, and a shared per-integration error indicator (tooltip with
  failed integration names). Alerts and table now only show their
  empty state after loading completes
- Translation: add empty.noSystems key to beszelSystemGrid and
  beszelSystemStats

Addresses #6030

* style: auto-fix lint and format

---------

Co-authored-by: ajnart <ajnart@users.noreply.github.com>
2026-06-22 15:20:12 +02:00
homarr-renovate[bot] 577950d20e chore(deps): update tiptap monorepo to v3.27.0 (#6047)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:10:10 +00:00
homarr-renovate[bot] 82bf71b073 chore(deps): update pnpm to v11.7.0 (#6046)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:08:43 +00:00
homarr-renovate[bot] e26883acfa chore(deps): update lucide monorepo (#6042)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 11:05:29 +00:00
homarr-renovate[bot] 8ee747cde8 chore(deps): update dependency axios@>=1.0.0 <1.8.2 to >=1.18.0 (#6038)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:57:58 +00:00
homarr-renovate[bot] faa7e55b1b chore(deps): update dependency video.js to ^8.23.8 (#6035)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:50:43 +00:00
homarr-renovate[bot] ce021a89ac chore(deps): update dependency posthog-node to ^4.18.0 (#6034)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-22 10:50:04 +00:00
Thomas 55fb227305 fix(beszel): improve stats date display, add click-to-stats modal, fix grid ordering (#6026) 2026-06-20 15:38:04 +02:00
Thomas 39e424563a fix(beszel): widget transparency, error handling, and cache invalidation (#5993) 2026-06-20 12:21:26 +02:00
Afonso Jorge Ramos e29c79a902 fix(widgets): clean up health monitoring disk and size display (#6019) 2026-06-20 11:47:38 +02:00
Afonso Jorge Ramos a19c9d88ca fix(widgets): hide system disk usage value when it overflows and show it in a tooltip (#6022) 2026-06-20 11:45:39 +02:00
Afonso Jorge Ramos 1fb97ee9c6 fix(unraid): report disk free space instead of total size (#6018) 2026-06-20 10:12:11 +02:00
Afonso Jorge Ramos f69e545d64 fix(truenas): report actual memory usage and pool free space (#6017) 2026-06-20 10:11:12 +02:00
Afonso Jorge Ramos 52df56b19d fix(widgets): hide health monitoring default tab option for non-proxmox integrations (#6021) 2026-06-20 08:14:56 +02:00
Afonso Jorge Ramos 0f66bfd6c1 fix(integrations): prevent certificate trust button from submitting the form (#6020) 2026-06-20 07:46:36 +02:00
Thomas 842c5a0b4c Merge pull request #6013 from homarr-labs/chore/update-cron 2026-06-20 07:27:19 +02:00
homarr-crowdin[bot] ce9134dee2 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-20 02:14:01 +00:00
homarr-releases[bot] 9ce9be6b7e chore: update bug report template (#6015)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-19 20:44:05 +00:00
Releases Homarr 7ec22411a6 chore(release): 1.67.0 [skip ci] 2026-06-19 20:27:41 +00:00
homarr-releases[bot] d3ae06e515 chore(release): automatic release v1.67.0 2026-06-19 20:26:34 +00:00
Manuel d09a4f8468 chore: update cron 2026-06-19 22:08:25 +02:00
Thomas e1a245ff2d fix(auth): isolate session cookies from other Auth.js apps on same host (#5996)
Prefix all Auth.js cookies (default "homarr") so they no longer collide
with other Auth.js apps on the same hostname.
2026-06-19 21:24:15 +02:00
homarr-crowdin[bot] bf4396917e chore(lang): updated translations from crowdin (#5994)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-06-19 21:22:48 +02:00
Thomas d059023b51 feat(analytics): migrate from Umami to PostHog server-side analytics (#5983)
* feat(analytics): migrate from Umami to PostHog server-side analytics

Replace @umami/node with posthog-node for all server-side telemetry.
Add singleton PostHog client, feature tracking POC, DAU heartbeat,
and tRPC analytics router. Cron job is now locked (no manual trigger,
no custom interval). Update consent copy to explain what we collect
and why.

* style: auto-fix lint and format

* refactor(analytics): simplify + add client-side posthog-js init

- Inline constants.ts and track.ts into client.ts (single source)
- Remove dead exports (shutdownPostHogAsync, getPostHogClient)
- Flatten all DB queries into one Promise.all (no waterfall)
- Derive integration/widget totals from group-by (drop redundant counts)
- Add posthog-js with dynamic import, gated on enableGeneral
- Move Analytics component to body, pass consent from server settings

* style: auto-fix lint and format

* feat(cron): add hidden flag to cron jobs, hide analytics from task panel

The analytics job runs silently in the background — users don't need
to see or interact with it. Hidden jobs are filtered out of getAllAsync
so they never appear in the management UI.

* refactor(analytics): simplify to single toggle, always collect all data

Remove granular enableWidgetData/enableIntegrationData/enableUserData
toggles. Analytics is now a single on/off switch. When enabled, all
anonymous usage data is collected in one flat Promise.all call.

Side effect: existing users with enableGeneral=true who had granular
flags off will now send all data. This is by design — the granular
controls added complexity without meaningful privacy benefit since
the data is already anonymous counts.

* style: auto-fix lint and format
2026-06-19 21:21:50 +02:00
d9cbd5427d feat: Calculate and display total CPU and memory usage (#5911)
* Calculate and display total CPU and memory usage

Added total CPU and memory usage calculations for containers.

* Refactor Docker component layout

Refactor component layout and remove redundant Group elements.

* Create test.yaml

* Add files via upload

* Delete packages/icons/icons directory

Accidental

* Add total CPU and memory strings to English translations

* Add English translations and size refactor

* Fix typo in search label in en.json

* chore(docs): update OpenAPI spec

* Remove color styling from CPU and memory usage text

* Enhance Docker component with footer and toolbar updates

Updated createColumns function to include totals and relativeTime parameters for footer display. Adjusted table configuration to enable bottom toolbar based on isTiny flag.

* Fix formatting and add missing newline at end of file

* Enable sticky header and footer in component

Enable sticky header and add footer row properties for sticky positioning.

* Refactor table component properties

* Refactor table columns and fix footers

* Revert "chore(docs): update OpenAPI spec"

This reverts commit 26f3c809d9.

---------

Co-authored-by: Bergasha <Bergasha@users.noreply.github.com>
Co-authored-by: Manuel <30572287+manuel-rw@users.noreply.github.com>
2026-06-19 20:58:57 +02:00
Matt Van HornandMatt Van Horn c56d9d6894 fix(downloads): sort stalled torrents last when sorting by finish time (#6011)
Stalled torrents have time=0 (infinite/never), which raw numeric sort
places between negative (completed) and positive (active) values.
Add a custom sortingFn on the time column that maps 0 to Infinity so
stalled entries always sort last in ascending order and first in
descending order.

Closes #5416

Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
2026-06-19 20:42:48 +02:00
homarr-update-contributors[bot] 3ea49f0391 chore: update contributors (#6010)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-06-19 15:02:14 +00:00
homarr-renovate[bot] e2f8f5f081 chore(deps): update lucide monorepo to ^1.18.0 (#5986)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 16:49:33 +00:00
homarr-renovate[bot] f57037b568 chore(deps): update dependency mantine-datatable to ^9.3.0 (#5992)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 16:49:08 +00:00
homarr-renovate[bot] 84803aeefb chore(deps): update dependency rollup@>=4.0.0 <4.22.4 to >=4.62.0 (#5998)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 16:48:53 +00:00
homarr-renovate[bot] 5f47298f3d chore(deps): update dependency isomorphic-dompurify to ^3.17.0 (#6003)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 16:48:07 +00:00
homarr-renovate[bot] de858011fc chore(deps): update dependency mantine-contextmenu to ^9.3.0 (#5991)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 13:08:17 +00:00
homarr-renovate[bot] 12f8e2b83a chore(deps): update dependency sass to ^1.101.0 (#5979)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 13:06:59 +00:00
homarr-renovate[bot] 78b6e520ac fix(deps): update tailwindcss monorepo to ^4.3.1 (#5988)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 13:04:08 +00:00
Thomas Camlong bcaa93f90b chore: trigger release build for v1.66.1 2026-06-18 13:39:22 +02:00
homarr-releases[bot] 15e6612697 chore: update bug report template (#6001)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-06-18 11:39:09 +00:00
Thomas Camlong eef06dd5a3 Revert "chore(release): 1.66.0 [skip ci]"
This reverts commit 84e044f427.
2026-06-18 13:17:30 +02:00
Releases Homarr 84e044f427 chore(release): 1.66.0 [skip ci] 2026-06-18 11:14:43 +00:00
homarr-releases[bot] 609ab9976a chore(release): automatic release v1.66.0 2026-06-18 11:13:29 +00:00
Thomas Camlong 28f570aa79 Revert "chore(release): 1.66.0 [skip ci]"
This reverts commit 5b4f03da35.
2026-06-18 13:07:44 +02:00
Releases Homarr 5b4f03da35 chore(release): 1.66.0 [skip ci] 2026-06-18 10:55:09 +00:00
Matt Van Horn 6960ac15e9 fix(integrations): make Emby Studios field optional (#5997) 2026-06-18 12:45:59 +02:00
Thomas f1ee76c99c fix(db): crash on MySQL — replace onConflictDoNothing with dialect-neutral pre-filter (#5995) 2026-06-18 12:45:02 +02:00
homarr-renovate[bot] 9a9ee5a9d7 chore(deps): update tiptap monorepo to v3.26.1 (#5976)
Co-authored-by: homarr-renovate[bot] <158783068+homarr-renovate[bot]@users.noreply.github.com>
2026-06-18 09:24:34 +00:00
179 changed files with 10685 additions and 5528 deletions

No files matched your search

+3
View File
@@ -33,6 +33,9 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.68.0
- 1.67.0
- 1.66.1
- 1.66.0
- 1.65.0
- 1.64.0
+62
View File
@@ -163,3 +163,65 @@ jobs:
run: cp .env.example .env
- name: Build
run: pnpm turbo build --affected
memory-regression:
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Build Docker image
uses: docker/build-push-action@v7
with:
platforms: linux/amd64
context: .
push: false
load: true
tags: homarr-memory-test
network: host
cache-from: type=gha
cache-to: type=gha,mode=max
env:
SKIP_ENV_VALIDATION: true
- name: Start container and measure memory
run: |
docker run -d --name homarr-mem-test -p 17575:7575 homarr-memory-test
# Wait for container to be healthy (up to 60s)
healthy=false
for i in $(seq 1 30); do
if curl -sf http://localhost:17575/api/health 2>/dev/null; then
echo "Container healthy after $((i * 2))s"
healthy=true
break
fi
sleep 2
done
if [ "$healthy" != "true" ]; then
echo "ERROR: Container never became healthy"
docker logs homarr-mem-test --tail 50
exit 1
fi
# Warm up: hit the dashboard page a few times
for i in $(seq 1 5); do
curl -sf http://localhost:17575/ > /dev/null 2>&1 || true
sleep 2
done
# Measure RSS after warmup
MEM_KB=$(docker stats --no-stream --format '{{.MemUsage}}' homarr-mem-test | sed 's| /.*||')
echo "Raw memory: $MEM_KB"
# Convert to numeric MB (handles GiB and MiB suffixes)
MEM_NUM=$(echo "$MEM_KB" | awk '{
if ($0 ~ /GiB/) { gsub(/GiB/,""); print $1 * 1024 }
else if ($0 ~ /MiB/) { gsub(/MiB/,""); print $1 }
else if ($0 ~ /KiB/) { gsub(/KiB/,""); print $1 / 1024 }
else { print $1 / 1048576 }
}')
echo "Memory usage: ${MEM_NUM} MB"
MAX_MB=700
if awk "BEGIN {exit !($MEM_NUM > $MAX_MB)}"; then
echo "ERROR: Memory usage ${MEM_NUM} MB exceeds ${MAX_MB} MB threshold"
docker logs homarr-mem-test --tail 50
exit 1
fi
echo "Memory check passed: ${MEM_NUM} MB <= ${MAX_MB} MB"
@@ -66,7 +66,7 @@ jobs:
- uses: actions/setup-node@v6
if: env.SKIP_RELEASE == 'false'
with:
node-version: 24.16.0
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
@@ -3,6 +3,7 @@ name: "[Deployment] Automatic Weekly Release"
on:
schedule:
- cron: "0 19 * * 5" # https://crontab.guru/#0_19_*_*_5
timezone: Europe/Zurich
workflow_dispatch:
inputs:
send-notifications:
+1 -1
View File
@@ -1,2 +1,2 @@
[tools]
node = "24.16.0"
node = "24.18.0"
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.18.0
+46
View File
@@ -1,3 +1,49 @@
## [1.68.1](https://github.com/homarr-labs/homarr/compare/v1.68.0...v1.68.1) (2026-06-30)
### Bug Fixes
* make source maps opt-in to restore v1.67.0 memory baseline ([57ef0af](https://github.com/homarr-labs/homarr/commit/57ef0af4a252ea39727caeba7e13587dabc6254e)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
* revert production source maps to restore v1.67.0 memory baseline ([60a69e3](https://github.com/homarr-labs/homarr/commit/60a69e31efc7168b8f4309daf75954d7730d883b)), closes [#6123](https://github.com/homarr-labs/homarr/issues/6123)
## [1.68.0](https://github.com/homarr-labs/homarr/compare/v1.67.0...v1.68.0) (2026-06-26)
### Features
* add VPN status widget ([#5812](https://github.com/homarr-labs/homarr/issues/5812)) ([a2bb53a](https://github.com/homarr-labs/homarr/commit/a2bb53a8b8fd17da3d2e23851b75d4e179f66225))
* re-add Docker quick-access button in user menu ([#6054](https://github.com/homarr-labs/homarr/issues/6054)) ([abd1635](https://github.com/homarr-labs/homarr/commit/abd16354bc9ef3dddbb497fd16b03476535046ac)), closes [#2165](https://github.com/homarr-labs/homarr/issues/2165)
* ship source maps for readable production stack traces ([#6031](https://github.com/homarr-labs/homarr/issues/6031)) ([89380f8](https://github.com/homarr-labs/homarr/commit/89380f81ddd132f5b1041a8f2c3b6c6b138923dd))
### Bug Fixes
* **beszel:** harden cache layer and prevent blank widgets ([#6052](https://github.com/homarr-labs/homarr/issues/6052)) ([49c2663](https://github.com/homarr-labs/homarr/commit/49c266374df12fea2404710232ced42e2de11dbf)), closes [#6030](https://github.com/homarr-labs/homarr/issues/6030)
* **beszel:** improve stats date display, add click-to-stats modal, fix grid ordering ([#6026](https://github.com/homarr-labs/homarr/issues/6026)) ([55fb227](https://github.com/homarr-labs/homarr/commit/55fb227305fe51963e486ffb25c4ce6811a2f0e4))
* **beszel:** leverage TanStack Query for stale-while-revalidate caching ([#6055](https://github.com/homarr-labs/homarr/issues/6055)) ([bbc4589](https://github.com/homarr-labs/homarr/commit/bbc4589c9e1608721fb58a6f9b03cca8d65c1c4a))
* **beszel:** use 480m interval for 1w/30d stats views ([4383da3](https://github.com/homarr-labs/homarr/commit/4383da38ef634327c288626c58480cc1e5a2f805))
* **beszel:** widget transparency, error handling, and cache invalidation ([#5993](https://github.com/homarr-labs/homarr/issues/5993)) ([39e4245](https://github.com/homarr-labs/homarr/commit/39e424563a3187dcf0dba5f654e2225be76aef51))
* **icon-picker:** keep URL visible in edit mode, add SVG-first ordering ([#6051](https://github.com/homarr-labs/homarr/issues/6051)) ([e01571b](https://github.com/homarr-labs/homarr/commit/e01571b221812cd19a6d67ebe3cf44432104ec27))
* **integrations:** prevent certificate trust button from submitting the form ([#6020](https://github.com/homarr-labs/homarr/issues/6020)) ([0f66bfd](https://github.com/homarr-labs/homarr/commit/0f66bfd6c128253ba644d80d7c908de5314cf997))
* **manage/about:** bypass self-fetch + add manage error boundary (LLM exploration) ([#6009](https://github.com/homarr-labs/homarr/issues/6009)) ([19f9769](https://github.com/homarr-labs/homarr/commit/19f9769d9e669d56d793e9f6a0d265316113d743)), closes [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008) [#6008](https://github.com/homarr-labs/homarr/issues/6008)
* **plex:** use series title for media releases instead of season label ([#6083](https://github.com/homarr-labs/homarr/issues/6083)) ([2443b89](https://github.com/homarr-labs/homarr/commit/2443b89b46f88fddbf41f0382a9f3bc5469c6fa8))
* show app first in add item modal ([#6087](https://github.com/homarr-labs/homarr/issues/6087)) ([915c8a3](https://github.com/homarr-labs/homarr/commit/915c8a3501551fdccf786d0c34dc01107d873ce0))
* **truenas:** report actual memory usage and pool free space ([#6017](https://github.com/homarr-labs/homarr/issues/6017)) ([f69e545](https://github.com/homarr-labs/homarr/commit/f69e545d6429aece0e55975c3b2426f20e91e545))
* **unraid:** report disk free space instead of total size ([#6018](https://github.com/homarr-labs/homarr/issues/6018)) ([1fb97ee](https://github.com/homarr-labs/homarr/commit/1fb97ee9c6fbe656a7dd3a9d1e436125982533f4))
* **widgets:** clean up health monitoring disk and size display ([#6019](https://github.com/homarr-labs/homarr/issues/6019)) ([e29c79a](https://github.com/homarr-labs/homarr/commit/e29c79a9028d9311dcb3d8323e0950a444589c7a))
* **widgets:** hide health monitoring default tab option for non-proxmox integrations ([#6021](https://github.com/homarr-labs/homarr/issues/6021)) ([52df56b](https://github.com/homarr-labs/homarr/commit/52df56b19da8e0bcb6c2e5df47b7936ec63f3149))
* **widgets:** hide system disk usage value when it overflows and show it in a tooltip ([#6022](https://github.com/homarr-labs/homarr/issues/6022)) ([a19c9d8](https://github.com/homarr-labs/homarr/commit/a19c9d88caacc86b42854c732818f0b5e5cc8dd5))
## [1.67.0](https://github.com/homarr-labs/homarr/compare/v1.66.1...v1.67.0) (2026-06-19)
### Features
* **analytics:** migrate from Umami to PostHog server-side analytics ([#5983](https://github.com/homarr-labs/homarr/issues/5983)) ([d059023](https://github.com/homarr-labs/homarr/commit/d059023b51a323df027c1cf033454440bd0d7e57))
* Calculate and display total CPU and memory usage ([#5911](https://github.com/homarr-labs/homarr/issues/5911)) ([d9cbd54](https://github.com/homarr-labs/homarr/commit/d9cbd5427d0e5be3e5dbecca8d6069dc8074d810))
### Bug Fixes
* **auth:** isolate session cookies from other Auth.js apps on same host ([#5996](https://github.com/homarr-labs/homarr/issues/5996)) ([e1a245f](https://github.com/homarr-labs/homarr/commit/e1a245ff2d8520d08957c4a7dbe01d039607a973))
* **deps:** update tailwindcss monorepo to ^4.3.1 ([#5988](https://github.com/homarr-labs/homarr/issues/5988)) ([78b6e52](https://github.com/homarr-labs/homarr/commit/78b6e520ac095129398d3da8f2441df408e93e9f))
* **downloads:** sort stalled torrents last when sorting by finish time ([#6011](https://github.com/homarr-labs/homarr/issues/6011)) ([c56d9d6](https://github.com/homarr-labs/homarr/commit/c56d9d6894c717b5c70ab75d6a100d4d1071c2d3)), closes [#5416](https://github.com/homarr-labs/homarr/issues/5416)
## [1.65.0](https://github.com/homarr-labs/homarr/compare/v1.64.0...v1.65.0) (2026-06-12)
### Features
+187 -1
View File
@@ -1,7 +1,193 @@
# ── CODEOWNERS ───────────────────────────────────────
* @homarr-labs/maintainers
# Exempt Renovate‑managed files (no owners)
# Specific features / integrations
# These owners come from the PR that introduced each integration implementation.
# Specific rules override the global fallback because CODEOWNERS uses the last
# matching pattern; do not include @homarr-labs/maintainers here unless they
# should also be requested for that integration.
# AdGuard Home - introduced in https://github.com/homarr-labs/homarr/pull/929 by @hillaliy
apps/docs/docs/integrations/adguard-home @hillaliy
packages/integrations/src/adguard-home @hillaliy
# Anchor - introduced in https://github.com/homarr-labs/homarr/pull/5040 by @ohmzi
apps/docs/docs/integrations/anchor @ohmzi
packages/integrations/src/anchor @ohmzi
# ArchiveTeam Warrior - introduced in https://github.com/homarr-labs/homarr/pull/5784 by @darbyjack
apps/docs/docs/integrations/archive-team-warrior @darbyjack
packages/integrations/src/archive-team-warrior @darbyjack
# Audiobookshelf - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/audiobookshelf @ajnart
packages/integrations/src/audiobookshelf @ajnart
# Beszel - introduced in https://github.com/homarr-labs/homarr/pull/5900 by @ajnart
apps/docs/docs/integrations/beszel @ajnart
packages/integrations/src/beszel @ajnart
# Codeberg - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/codeberg @Aandree5
packages/integrations/src/codeberg @Aandree5
# Coolify - introduced in https://github.com/homarr-labs/homarr/pull/4789 by @Rhiz3K
apps/docs/docs/integrations/coolify @Rhiz3K
packages/integrations/src/coolify @Rhiz3K
# Dash. - introduced in https://github.com/homarr-labs/homarr/pull/1541 by @manuel-rw
apps/docs/docs/integrations/dash-dot @manuel-rw
packages/integrations/src/dashdot @manuel-rw
# Docker Hub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/docker-hub @Aandree5
packages/integrations/src/docker-hub @Aandree5
# Emby - introduced in https://github.com/homarr-labs/homarr/pull/2466 by @Meierschlumpf
apps/docs/docs/integrations/emby @Meierschlumpf
packages/integrations/src/emby @Meierschlumpf
# GitHub - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/github @Aandree5
packages/integrations/src/github @Aandree5
# GitHub Container Registry - introduced in https://github.com/homarr-labs/homarr/pull/3727 by @Aandree5
apps/docs/docs/integrations/github-containerregistry @Aandree5
packages/integrations/src/github-container-registry @Aandree5
# GitLab - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/gitlab @Aandree5
packages/integrations/src/gitlab @Aandree5
# Glances - introduced in https://github.com/homarr-labs/homarr/pull/5102 by @manuel-rw
apps/docs/docs/integrations/glances @manuel-rw
packages/integrations/src/glances @manuel-rw
# Gluetun - introduced in https://github.com/homarr-labs/homarr/pull/5812 by @Lok3sh-kumar
apps/docs/docs/integrations/gluetun @Lok3sh-kumar
packages/integrations/src/gluetun @Lok3sh-kumar
# Gotify - introduced in https://github.com/homarr-labs/homarr/pull/5847 by @Nerdy-Technician
apps/docs/docs/integrations/gotify @Nerdy-Technician
packages/integrations/src/gotify @Nerdy-Technician
# Home Assistant - introduced in https://github.com/homarr-labs/homarr/pull/578 by @manuel-rw
apps/docs/docs/integrations/home-assistant @manuel-rw
packages/integrations/src/homeassistant @manuel-rw
# ICal - introduced in https://github.com/homarr-labs/homarr/pull/3980 by @NicolasNewman
apps/docs/docs/integrations/ical @NicolasNewman
packages/integrations/src/ical @NicolasNewman
# Immich - introduced in https://github.com/homarr-labs/homarr/pull/5165 by @manuel-rw
apps/docs/docs/integrations/immich @manuel-rw
packages/integrations/src/immich @manuel-rw
# Jellyfin - introduced in https://github.com/homarr-labs/homarr/pull/672 by @manuel-rw
apps/docs/docs/integrations/jellyfin @manuel-rw
packages/integrations/src/jellyfin @manuel-rw
# Jellyseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/jellyseerr @manuel-rw
packages/integrations/src/jellyseerr @manuel-rw
# LinuxServer.io - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/linux-server-io @Aandree5
packages/integrations/src/linuxserverio @Aandree5
# Navidrome - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/navidrome @ajnart
packages/integrations/src/navidrome @ajnart
# Nextcloud - introduced in https://github.com/homarr-labs/homarr/pull/2501 by @manuel-rw
apps/docs/docs/integrations/nextcloud @manuel-rw
packages/integrations/src/nextcloud @manuel-rw
# npm - introduced in https://github.com/homarr-labs/homarr/pull/3253 by @Aandree5
apps/docs/docs/integrations/npm @Aandree5
packages/integrations/src/npm @Aandree5
# ntfy - introduced in https://github.com/homarr-labs/homarr/pull/2900 by @itzTheMeow
apps/docs/docs/integrations/ntfy @ajnart @manuel-rw
packages/integrations/src/ntfy @ajnart @manuel-rw
# OpenMediaVault - introduced in https://github.com/homarr-labs/homarr/pull/1142 by @hillaliy
apps/docs/docs/integrations/open-media-vault @hillaliy
packages/integrations/src/openmediavault @hillaliy
# OPNsense - introduced in https://github.com/homarr-labs/homarr/pull/3424 by @Oupsman
apps/docs/docs/integrations/opnsense @Oupsman
packages/integrations/src/opnsense @Oupsman
# Overseerr - introduced in https://github.com/homarr-labs/homarr/pull/774 by @manuel-rw
apps/docs/docs/integrations/overseerr @manuel-rw
packages/integrations/src/overseerr @manuel-rw
# Paperless-ngx - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/paperless-ngx @ajnart
packages/integrations/src/paperless-ngx @ajnart
# PeaNUT - introduced in https://github.com/homarr-labs/homarr/pull/5862 by @afonsojramos
apps/docs/docs/integrations/peanut @afonsojramos
packages/integrations/src/peanut @afonsojramos
# Pi-hole - introduced in https://github.com/homarr-labs/homarr/pull/521 by @Meierschlumpf
apps/docs/docs/integrations/pi-hole @Meierschlumpf
packages/integrations/src/pi-hole @Meierschlumpf
# Plex - introduced in https://github.com/homarr-labs/homarr/pull/1342 by @hillaliy
apps/docs/docs/integrations/plex @hillaliy
packages/integrations/src/plex @hillaliy
# Prowlarr - introduced in https://github.com/homarr-labs/homarr/pull/965 by @hillaliy
apps/docs/docs/integrations/prowlarr @hillaliy
packages/integrations/src/prowlarr @hillaliy
# Proxmox - introduced in https://github.com/homarr-labs/homarr/pull/1969 by @Meierschlumpf
apps/docs/docs/integrations/proxmox @Meierschlumpf
packages/integrations/src/proxmox @Meierschlumpf
# Quay - introduced in https://github.com/homarr-labs/homarr/pull/3607 by @Aandree5
apps/docs/docs/integrations/quay @Aandree5
packages/integrations/src/quay @Aandree5
# search.ch - introduced in https://github.com/homarr-labs/homarr/pull/5180 by @Meierschlumpf
apps/docs/docs/integrations/search-ch @Meierschlumpf
packages/integrations/src/search-ch @Meierschlumpf
# Speedtest Tracker - introduced in https://github.com/homarr-labs/homarr/pull/5363 by @kylegrieder
apps/docs/docs/integrations/speedtest-tracker @kylegrieder
packages/integrations/src/speedtest-tracker @kylegrieder
# Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch
apps/docs/docs/integrations/technitium-dns @lopsch
packages/integrations/src/technitium @lopsch
# Tracearr - introduced in https://github.com/homarr-labs/homarr/pull/5060 by @StrandedTurtle
apps/docs/docs/integrations/tracearr @StrandedTurtle
packages/integrations/src/tracearr @StrandedTurtle
# TrueNAS - introduced in https://github.com/homarr-labs/homarr/pull/3745 by @manuel-rw
apps/docs/docs/integrations/truenas @manuel-rw @afonsojramos
packages/integrations/src/truenas @manuel-rw @afonsojramos
# Umami - introduced in https://github.com/homarr-labs/homarr/pull/5362 by @febrile42
apps/docs/docs/integrations/umami @febrile42
packages/integrations/src/umami @febrile42
# UniFi Controller - introduced in https://github.com/homarr-labs/homarr/pull/2236 by @pitschi
apps/docs/docs/integrations/unifi-controller @pitschi
packages/integrations/src/unifi-controller @pitschi
# Unraid - introduced in https://github.com/homarr-labs/homarr/pull/4439 by @manuel-rw
apps/docs/docs/integrations/unraid @manuel-rw
packages/integrations/src/unraid @manuel-rw
# Uptime Kuma - introduced in https://github.com/homarr-labs/homarr/pull/5910 by @ajnart
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# Exempt Renovate-managed files (no owners)
package.json
**/package.json
package-lock.json
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.16.0-alpine AS base
FROM node:24.18.0-alpine AS base
FROM base AS builder
RUN apk add --no-cache libc6-compat
@@ -23,6 +23,7 @@ Homarr supports multiple authentication options, from internal userbase (credent
| AUTH_PROVIDERS | Select Which provider to use between *credentials*, *ldap* and *oidc*. <br /> Multiple providers can be enabled with by separating them with `,`, (ex. `AUTH_PROVIDERS=credentials,oidc`, it is highly recommended to just enable one provider). | credentials |
| AUTH_LOGOUT_REDIRECT_URL | URL to redirect to after clicking logging out. | --- |
| AUTH_SESSION_EXPIRY_TIME | Time for the session to time out. Can be set as pure number, which will automatically be used in seconds, or followed by s, m, h or d for seconds, minutes, hours or days. (ex: "30m") | "30d" |
| AUTH_COOKIE_PREFIX | Prefix used for all authentication cookies (session, csrf, callback, pkce, state, nonce). Change this if you run another Auth.js/NextAuth app on the same hostname as Homarr (browsers scope cookies by host, not by port) to avoid cookie name collisions. Only letters, numbers, hyphens and underscores are allowed. | `homarr` |
<Tabs>
<TabItem value="credentials" label="Credentials Provider" default>
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.19.0](https://img.shields.io/badge/Version-8.19.0-informational?style=flat)
![Version: 8.20.0](https://img.shields.io/badge/Version-8.20.0-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.60.0](https://img.shields.io/badge/AppVersion-v1.60.0-informational?style=flat)
![AppVersion: v1.68.0](https://img.shields.io/badge/AppVersion-v1.68.0-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -383,6 +383,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| containerPorts | object | `{"http":{"port":7575,"protocol":"TCP"}}` | containerPorts defines the ports to open on the container. It is a map where each entry specifies: - `port` (int) (required): The port number to expose inside the container. - `protocol` (string) (required): The network protocol (TCP or UDP) used for the port. - `disabled` (bool) : Optional flag to disable this port (defaults to false). Can be overridden via Helm values. By default, this configuration exposes TCP port 7575 with the name `http`. |
| database.migrationEnabled | bool | `true` | Database migration configuration. DB_MIGRATIONS_DISABLED Set to `true` to disable database migrations. Migrations are enabled by default (`false`). |
| database.type | string | `"sqlite"` | Database type: sqlite, mysql or postgresql |
| env.AUTH_COOKIE_PREFIX | string | `"homarr"` | Prefix used for all authentication cookies. Change if you run another Auth.js/NextAuth app on the same hostname to avoid cookie name collisions. Only letters, numbers, hyphens and underscores. |
| env.AUTH_LDAP_BASE | string | `nil` | Base dn of your LDAP server |
| env.AUTH_LDAP_BIND_DN | string | `nil` | User used for finding users and groups |
| env.AUTH_LDAP_GROUP_CLASS | string | `"groupOfUniqueNames"` | Class used for querying groups |
@@ -0,0 +1,35 @@
---
title: "ArchiveTeam Warrior"
description: "ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { archiveTeamWarriorIntegration } from ".";
<IntegrationHeader integration={archiveTeamWarriorIntegration} categories={["archiving"]} />
The ArchiveTeam Warrior integration lets Homarr connect to your ArchiveTeam Warrior instance so you can manage and monitor your archiving tasks directly from your dashboard.
### Adding the integration
<AddingIntegration />
### Secrets
ArchiveTeam Warrior does not require authentication by default. If your instance is protected with basic authentication, you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const archiveTeamWarriorIntegration: IntegrationDefinition = {
name: "ArchiveTeam Warrior",
description:
"ArchiveTeam Warrior is an easy-to-run virtual machine that helps preserve websites by using some of your bandwidth and disk space to download and upload content to ArchiveTeam’s archive.",
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
path: "../../integrations/archiveteam-warrior",
};
@@ -0,0 +1,36 @@
---
title: 'FileFlows'
description: "FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import {IntegrationSecrets} from '@site/src/components/integrations/secrets';
import { IconTransform } from "@tabler/icons-react";
import { fileFlowsIntegration } from '.';
import { mediaTranscodingWidget } from '@site/docs/widgets/media-transcoding';
<IntegrationHeader
integration={fileFlowsIntegration}
categories={['Media Transcoding']}
/>
### Widgets & Capabilities
<IntegrationCapabilites
items={[{ widget: mediaTranscodingWidget }]}
/>
### Adding the integration
<AddingIntegration />
### Secrets
<IntegrationSecrets secrets={[{
credentials: ['apiKey'],
steps: []
}, {
credentials: [],
steps: []
}]} />
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const fileFlowsIntegration: IntegrationDefinition = {
name: "FileFlows",
description:
"FileFlows is a distributed media processing system that allows you to encode, compress and process your media files across multiple nodes.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/png/fileflows.png",
path: "../../integrations/fileflows",
};
@@ -0,0 +1,52 @@
---
title: 'Gluetun'
description: 'Connect Gluetun to Homarr as a VPN integration.'
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { gluetunIntegration } from '.';
import { vpnWidget } from '@site/docs/widgets/vpn';
<IntegrationHeader
integration={gluetunIntegration}
categories={['VPN', 'Network']}
/>
[Gluetun](https://github.com/qdm12/gluetun) exposes a control server that Homarr can query to report the VPN tunnel
status, DNS status, public IP address, and active provider details in the VPN widget.
### Widgets & Capabilities
<IntegrationCapabilites
items={[{
widget: vpnWidget,
}]}
/>
### Setting up the Gluetun control server
Refer to the [Gluetun control server documentation](https://github.com/qdm12/gluetun-wiki/blob/main/setup/advanced/control-server.md#authentication) to enable and configure authentication.
### Adding the integration
<AddingIntegration />
### Secrets
Gluetun's control server can be left unprotected or secured with either an API key or HTTP Basic authentication.
Choose the option that matches how your Gluetun instance integration is set up.
<IntegrationSecrets secrets={[{
credentials: [],
steps: [],
},{
credentials: ['username', 'password'],
steps: [],
},{
credentials: ['apiKey'],
steps: [],
},,]} />
@@ -0,0 +1,8 @@
import { IntegrationDefinition } from "@site/src/types";
export const gluetunIntegration: IntegrationDefinition = {
name: "Gluetun",
description: "VPN control server integration for Homarr's VPN widget.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/gluetun.svg",
path: "../../integrations/gluetun",
};
+3 -1
View File
@@ -63,7 +63,9 @@ import withoutIntegrations from './img/without-integrations.png';
### Interactions
- View calendar events by clicking on the marked dates.
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
### Adding the widget
Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

+40
View File
@@ -0,0 +1,40 @@
---
title: 'VPN'
description: 'Monitor the status of your VPN integrations.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { vpnWidget } from '.';
import { gluetunIntegration } from '@site/docs/integrations/gluetun';
<WidgetHeader
widget={vpnWidget}
categories={['Network', 'Security']}
/>
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
When multiple VPN integrations are attached, the widget renders one card per integration so you can compare their
current status at a glance.
### Screenshots
import singleIntegrationImage from './img/single.png';
<div>
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: gluetunIntegration,
}]} />
### Adding the widget
<AddingWidget />
+9
View File
@@ -0,0 +1,9 @@
import { WidgetDefinition } from "@site/src/types";
import { IconShieldLock } from "@tabler/icons-react";
export const vpnWidget: WidgetDefinition = {
icon: IconShieldLock,
name: "VPN",
description: "Monitor the connection status, public IP, and provider details of your VPN integrations.",
path: "../../widgets/vpn",
};
+2 -2
View File
@@ -32,7 +32,7 @@
"@scalar/api-reference-react": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.0",
"@tailwindcss/postcss": "^4.3.1",
"clsx": "catalog:",
"dayjs": "catalog:",
"docusaurus-plugin-image-zoom": "^3.0.1",
@@ -44,7 +44,7 @@
"react": "catalog:",
"react-dom": "catalog:",
"swiper": "^12.2.0",
"tailwindcss": "^4.3.0"
"tailwindcss": "^4.3.1"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.10.1",
+1
View File
@@ -89,6 +89,7 @@
"mcp-handler": "catalog:",
"next": "catalog:",
"postcss-preset-mantine": "catalog:",
"posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
+1 -1
View File
@@ -139,11 +139,11 @@ export default async function Layout(props: {
suppressHydrationWarning
>
<head>
<Analytics />
<SearchEngineOptimization />
<CrowdinLiveTranslation locale={locale} />
</head>
<body className={["font-sans", fontSans.variable].join(" ")}>
<Analytics enabled={serverSettings.analytics.enableGeneral} />
<StackedProvider>
<Notifications pauseResetOnHover="notification" />
<ServiceWorkerRegistration />
@@ -1,4 +1,3 @@
import { headers } from "next/headers";
import Image from "next/image";
import {
Accordion,
@@ -41,11 +40,10 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import type { PackageJsonDependencies } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import type githubContributorsJson from "../../../../../../../static-data/contributors.json";
import type openCollectiveContributorsJson from "../../../../../../../static-data/opencollective-contributors.json";
import type crowdinContributorsJson from "../../../../../../../static-data/translators.json";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
export async function generateMetadata() {
@@ -56,32 +54,10 @@ export async function generateMetadata() {
};
}
const getHostAsync = async () => {
if (process.env.HOSTNAME) {
return `${process.env.HOSTNAME}:3000`;
}
return (await headers()).get("host");
};
export default async function AboutPage() {
const baseServerUrl = `http://${await getHostAsync()}`;
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = (await fetch(`${baseServerUrl}/api/about/dependencies`).then((res) =>
res.json(),
)) as PackageJsonDependencies;
const githubContributors = (await fetch(`${baseServerUrl}/api/about/contributors/github`).then((res) =>
res.json(),
)) as typeof githubContributorsJson;
const crowdinContributors = (await fetch(`${baseServerUrl}/api/about/contributors/crowdin`).then((res) =>
res.json(),
)) as typeof crowdinContributorsJson;
const openCollectiveContributors = (await fetch(`${baseServerUrl}/api/about/contributors/opencollective`).then(
(res) => res.json(),
)) as typeof openCollectiveContributorsJson;
const dependencies = await getDependenciesAsync();
return (
<div>
@@ -185,7 +161,7 @@ export default async function AboutPage() {
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.sort(([key1], [key2]) => key1.localeCompare(key2))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import { Button, Center, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export default function ManageErrorPage({
error,
unstable_retry,
}: {
error: Error & { digest?: string };
unstable_retry: () => void;
}) {
const tError = useScopedI18n("management.error");
const t = useI18n();
useEffect(() => {
console.error(error);
}, [error]);
return (
<Center h="100%">
<Stack align="center" gap="xs">
<IconAlertTriangle size={48} stroke={1.5} />
<Title order={3}>{tError("title")}</Title>
<Text size="sm" c="dimmed" maw={480} ta="center">
{tError("text")}
</Text>
{error.digest && (
<Text size="xs" c="dimmed">
{tError("digest", { digest: error.digest })}
</Text>
)}
<Button variant="light" onClick={unstable_retry}>
{t("common.action.tryAgain")}
</Button>
</Stack>
</Center>
);
}
@@ -131,6 +131,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
{(error.data.reason === "untrusted" && rootCertificate.isSelfSigned) ||
error.data.reason === "hostnameMismatch" ? (
<Button
type="button"
variant="default"
fullWidth
onClick={error.data.reason === "hostnameMismatch" ? handleTrustHostname : handleTrustSelfSigned}
@@ -140,6 +141,7 @@ export const CertificateErrorDetails = ({ error, url }: CertificateErrorDetailsP
) : null}
{error.data.reason === "untrusted" && !rootCertificate.isSelfSigned ? (
<Button
type="button"
variant="default"
fullWidth
onClick={() =>
@@ -17,9 +17,10 @@ interface AnalyticsSettingsProps {
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
if (!form.isValid()) {
return;
}
@@ -149,16 +149,20 @@ const createColumns = (t: TranslationFunction): MRT_ColumnDef<DockerContainer>[]
},
];
export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["getContainers"]) {
interface DockerTableProps {
initialData?: RouterOutputs["docker"]["getContainers"];
}
export function DockerTable({ initialData }: DockerTableProps) {
const t = useI18n();
const tDocker = useScopedI18n("docker");
const { data } = clientApi.docker.getContainers.useQuery(undefined, {
initialData: { containers, timestamp },
const { data, isFetching } = clientApi.docker.getContainers.useQuery(undefined, {
initialData,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
});
const relativeTime = useTimeAgo(data.timestamp);
const relativeTime = useTimeAgo(data?.timestamp ?? new Date());
const utils = clientApi.useUtils();
const { mutate, isPending } = clientApi.docker.invalidate.useMutation({
async onSuccess() {
@@ -176,8 +180,11 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
},
});
const containers = data?.containers ?? [];
const table = useTranslatedMantineReactTable({
data: data.containers,
data: containers,
state: { isLoading: isFetching },
enableDensityToggle: false,
enableColumnActions: false,
enableColumnFilters: false,
@@ -190,7 +197,7 @@ export function DockerTable({ containers, timestamp }: RouterOutputs["docker"]["
enableBottomToolbar: false,
positionGlobalFilter: "right",
mantineSearchTextInputProps: {
placeholder: tDocker("table.search", { count: String(data.containers.length) }),
placeholder: tDocker("table.search", { count: String(containers.length) }),
style: { minWidth: 300 },
autoFocus: true,
},
@@ -23,7 +23,7 @@ export default async function DockerPage() {
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tDocker("title")}</Title>
<DockerTable containers={containers} timestamp={timestamp} />
<DockerTable initialData={{ containers, timestamp }} />
</Stack>
</>
);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import crowdinContributors from "../../../../../../../../static-data/translators.json";
import crowdinContributors from "@static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import githubContributors from "../../../../../../../../static-data/contributors.json";
import githubContributors from "@static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "../../../../../../../../static-data/opencollective-contributors.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
@@ -211,7 +211,17 @@ describe("SQLite backup", () => {
const decryptedAfter = decryptWithKey(updatedRow.value as `${string}.${string}`, newKey);
expect(decryptedAfter).toBe(plaintext);
expect(() => decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey)).toThrow();
// The old key must no longer recover the plaintext. With AES-256-CBC this
// usually fails PKCS7 padding validation (throws), but ~1/256 of the time the
// wrong key yields valid padding and returns garbage instead of throwing, so
// assert on the actual property (plaintext is unrecoverable) rather than throwing.
let decryptedWithOldKey: string | null = null;
try {
decryptedWithOldKey = decryptWithKey(updatedRow.value as `${string}.${string}`, oldKey);
} catch {
// Expected for most wrong keys: padding validation fails.
}
expect(decryptedWithOldKey).not.toBe(plaintext);
sqlite.close();
});
@@ -142,7 +142,8 @@ export const useBackupAnalysis = () => {
phase: "error",
});
throw new Error(
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`, { cause: migrationErr },
`Migration ${migration.tag} failed: ${migrationErr instanceof Error ? migrationErr.message : "Unknown error"}`,
{ cause: migrationErr },
);
}
@@ -1,3 +1,5 @@
import type { MutableRefObject } from "react";
import { useRef } from "react";
import { Badge, Card } from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { QueryErrorResetBoundary } from "@tanstack/react-query";
@@ -16,6 +18,7 @@ import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
import { BoardItemMenu } from "./item-menu";
import { WidgetContextMenu } from "./widget-context-menu";
interface BoardItemContentProps {
item: SectionItem;
@@ -30,30 +33,33 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null);
return (
<>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} />
</Card>
<WidgetContextMenu item={item} widgetStateRef={widgetStateRef}>
<Card
ref={ref}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"grid-stack-item-content",
item.advancedOptions.customCssClasses.join(" "),
)}
radius={board.itemRadius}
styles={{
root: {
"--opacity": board.opacity / 100,
containerType: "size",
overflow: getOverflowFromKind(item.kind),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
p={0}
>
<InnerContent item={item} width={width} height={height} widgetStateRef={widgetStateRef} />
</Card>
</WidgetContextMenu>
{item.advancedOptions.title?.trim() && (
<Badge
pos="absolute"
@@ -83,6 +89,7 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
@@ -41,7 +41,12 @@ export const ItemSelectModal = createModal<void>(({ actions }) => {
name: t(`widget.${kind}.name`),
description: t(`widget.${kind}.description`),
}))
.sort((itemA, itemB) => itemA.name.localeCompare(itemB.name)),
.sort((itemA, itemB) => {
if (itemA.kind === "app") return -1;
if (itemB.kind === "app") return 1;
return itemA.name.localeCompare(itemB.name);
}),
[t],
);
@@ -0,0 +1,250 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { Group, Menu, Switch } from "@mantine/core";
import { IconCopy, IconLayoutKanban, IconSettings, IconTrash } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { WidgetContextMenuAction } from "@homarr/widgets";
import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
children: ReactNode;
}
export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
const settings = useSettings();
const { openModal } = useModalAction(WidgetEditModal);
const { openModal: openMoveModal } = useModalAction(ItemMoveModal);
const { openConfirmModal } = useConfirmModal();
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record<string, unknown>,
[item.kind, settings, item.options],
);
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
}, [currentDefinition, settings]);
const widgetContextActions = useMemo(() => {
const def = currentDefinition as Record<string, unknown>;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options,
setOptions: (partial: Record<string, unknown>) => {
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
}, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
const persistBoard = useCallback(
(updatedItems: typeof board.items) => {
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => {
openModal(
{
kind: item.kind,
value: {
advancedOptions: item.advancedOptions,
options: item.options,
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) {
persistBoard(
board.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
...boardItem,
options: editResult.options,
advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds,
},
),
);
}
},
integrationData: (integrationData ?? []).filter(
(integration) =>
"supportedIntegrations" in currentDefinition &&
(currentDefinition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
),
integrationSupport: "supportedIntegrations" in currentDefinition,
settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
);
}, [
openModal,
item,
updateItemOptions,
updateItemAdvancedOptions,
updateItemIntegrations,
integrationData,
currentDefinition,
settings,
isEditMode,
persistBoard,
board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
const newOptions = { ...options, [key]: checked };
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
);
if (!session) return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
<Menu shadow="md" width={300} closeOnItemClick={false} position="right-start" offset={4}>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{toggleOptions.length > 0 && (
<>
<Menu.Label>{tMenu("options")}</Menu.Label>
{toggleOptions.map(([key]) => (
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}>
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
<Group wrap="nowrap">
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" />
</Group>
</Menu.Item>
))}
</>
)}
{visibleWidgetActions.length > 0 && (
<>
{toggleOptions.length > 0 && <Menu.Divider />}
<Menu.Label>{tMenu("actions")}</Menu.Label>
{visibleWidgetActions.map((action) => {
const Icon = action.icon;
return (
<Menu.Item
key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{String(translateIfNecessary(t, action.label))}
</Menu.Item>
);
})}
</>
)}
{isEditMode && (
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconLayoutKanban size={16} />}
onClick={() => {
if (!gridstack.current) return;
openMoveModal({ item, columnCount: gridstack.current.getColumn(), gridStack: gridstack.current });
}}
>
{tItem("action.moveResize")}
</Menu.Item>
<Menu.Item
closeMenuOnClick
leftSection={<IconCopy size={16} />}
onClick={() => duplicateItem({ itemId: item.id })}
>
{tItem("action.duplicate")}
</Menu.Item>
</>
)}
<>
{!isEditMode && (toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item
closeMenuOnClick
leftSection={<IconSettings size={16} />}
onClick={openEditModal}
disabled={isPending}
>
{tMenu("settings")}
</Menu.Item>
</>
{isEditMode && (
<>
<Menu.Divider />
<Menu.Item
closeMenuOnClick
color="red"
leftSection={<IconTrash size={16} />}
onClick={() => {
openConfirmModal({
title: tItem("remove.title"),
children: tItem("remove.message"),
onConfirm: () => removeItem({ itemId: item.id }),
});
}}
>
{tItem("action.remove")}
</Menu.Item>
</>
)}
</Menu.Dropdown>
</Menu>
);
};
@@ -57,6 +57,7 @@ export const categoryTranslationKeys: Record<string, string> = {
mediaMonitoring: "integration.category.mediaMonitoring",
speedtest: "integration.category.speedtest",
analytics: "integration.category.analytics",
vpn: "integration.category.vpn",
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
+19 -11
View File
@@ -1,16 +1,24 @@
import Script from "next/script";
"use client";
import { UMAMI_WEBSITE_ID } from "@homarr/analytics";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { useEffect } from "react";
export const Analytics = async () => {
// For static pages it will not find any analytics data so we do not include the script on them
const analytics = await getServerSettingByKeyAsync(db, "analytics").catch(() => null);
const POSTHOG_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
const POSTHOG_HOST = "https://hog.homarr.dev";
if (analytics?.enableGeneral) {
return <Script src="https://umami.homarr.dev/script.js" data-website-id={UMAMI_WEBSITE_ID} defer />;
}
export const Analytics = ({ enabled }: { enabled: boolean }) => {
useEffect(() => {
if (!enabled) return;
return <></>;
void import("posthog-js").then(({ default: posthog }) => {
posthog.init(POSTHOG_KEY, {
api_host: POSTHOG_HOST,
capture_pageview: false,
capture_pageleave: false,
autocapture: false,
persistence: "memory",
});
});
}, [enabled]);
return null;
};
@@ -0,0 +1,10 @@
"use client";
import { createModal } from "@homarr/modals";
import { DockerTable } from "~/app/[locale]/manage/tools/docker/docker-table";
export const DockerQuickAccessModal = createModal<void>(() => <DockerTable />).withOptions({
defaultTitle: (t) => t("docker.title"),
size: 1400,
});
@@ -3,6 +3,7 @@ import { UnstyledButton } from "@mantine/core";
import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { env } from "@homarr/docker/env";
import { CurrentUserAvatar } from "~/components/user-avatar";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
@@ -11,9 +12,10 @@ import { UpdateIndicator } from "./update";
export const UserButton = async () => {
const session = await auth();
const isAdmin = session?.user.permissions.includes("admin");
const isDockerEnabled = isAdmin && env.ENABLE_DOCKER;
const availableUpdatesPromise = isAdmin ? api.updateChecker.getAvailableUpdates() : undefined;
return (
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise}>
<UserAvatarMenu availableUpdatesPromise={availableUpdatesPromise} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<Suspense fallback={<CurrentUserAvatar size="md" />}>
<UpdateIndicator availableUpdatesPromise={availableUpdatesPromise} disabled={!isAdmin}>
@@ -5,7 +5,15 @@ import { useCallback, useEffect } from "react";
import { useRouter } from "next/navigation";
import { Center, Menu, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { useHotkeys, useTimeout } from "@mantine/hooks";
import { IconCheck, IconHome, IconLogin, IconLogout, IconSettings, IconTool } from "@tabler/icons-react";
import {
IconBrandDocker,
IconCheck,
IconHome,
IconLogin,
IconLogout,
IconSettings,
IconTool,
} from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { signOut, useSession } from "@homarr/auth/client";
@@ -17,14 +25,16 @@ import { Link } from "@homarr/ui";
import { useAuthContext } from "~/app/[locale]/_client-providers/session";
import { CurrentColorSchemeCombobox } from "./color-scheme/current-color-scheme-combobox";
import { CurrentLanguageCombobox } from "./language/current-language-combobox";
import { DockerQuickAccessModal } from "./layout/header/docker-quick-access-modal";
import { AvailableUpdatesMenuItem } from "./layout/header/update";
interface UserAvatarMenuProps {
children: ReactNode;
availableUpdatesPromise?: Promise<RouterOutputs["updateChecker"]["getAvailableUpdates"]>;
isDockerEnabled?: boolean;
}
export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdatesPromise, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -34,6 +44,7 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
const { logoutUrl } = useAuthContext();
const { openModal } = useModalAction(LogoutModal);
const { openModal: openDockerModal } = useModalAction(DockerQuickAccessModal);
const handleSignout = useCallback(async () => {
await signOut({
@@ -80,6 +91,11 @@ export const UserAvatarMenu = ({ children, availableUpdatesPromise }: UserAvatar
<Menu.Item component={Link} href="/manage" leftSection={<IconTool size="1rem" />}>
{t("management")}
</Menu.Item>
{isDockerEnabled && (
<Menu.Item leftSection={<IconBrandDocker size="1rem" />} onClick={() => openDockerModal()}>
{t("docker")}
</Menu.Item>
)}
</>
)}
<Menu.Divider />
+1
View File
@@ -3,6 +3,7 @@
"compilerOptions": {
"paths": {
"~/*": ["./src/*"],
"@static-data/*": ["../../static-data/*"],
"@homarr/node-unifi": ["../../node_modules/@types/node-unifi"]
},
"plugins": [
+1 -1
View File
@@ -1,4 +1,4 @@
FROM node:24.16.0-trixie AS base
FROM node:24.18.0-trixie AS base
WORKDIR /app
COPY package.json .
COPY pnpm-lock.yaml .
+3 -3
View File
@@ -5,8 +5,8 @@
"better-sqlite3": "^12.10.0"
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.6.0"
"packageManager": "pnpm@11.9.0"
}
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.65.0",
"version": "1.68.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -61,10 +61,10 @@
"vitest": "catalog:"
},
"engines": {
"node": ">=24.16.0",
"pnpm": ">=11.6.0"
"node": ">=24.18.0",
"pnpm": ">=11.9.0"
},
"packageManager": "pnpm@11.6.0",
"packageManager": "pnpm@11.9.0",
"dependencies": {
"jsonpath-plus": "catalog:"
}
+1 -3
View File
@@ -26,9 +26,7 @@
"@homarr/core": "workspace:^0.1.0",
"@homarr/db": "workspace:^0.1.0",
"@homarr/docker": "workspace:^0.1.0",
"@homarr/server-settings": "workspace:^0.1.0",
"@umami/node": "catalog:",
"superjson": "catalog:"
"posthog-node": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
+23
View File
@@ -0,0 +1,23 @@
import { PostHog } from "posthog-node";
export const POSTHOG_API_KEY = "phc_vYBmGWNbRshvfeC7EHfeSmUm2pD2Neg5nGqzJuGvS8Hs";
export const POSTHOG_HOST = "https://hog.homarr.dev";
let instance: PostHog | undefined;
export const getPostHogClient = (): PostHog => {
instance ??= new PostHog(POSTHOG_API_KEY, {
host: POSTHOG_HOST,
flushAt: 10,
flushInterval: 30_000,
});
return instance;
};
export const trackEvent = (instanceId: string, event: string, properties?: Record<string, unknown>) => {
getPostHogClient().capture({
distinctId: instanceId,
event,
properties,
});
};
-2
View File
@@ -1,2 +0,0 @@
export const UMAMI_HOST_URL = "https://umami.homarr.dev";
export const UMAMI_WEBSITE_ID = "ff7dc470-a84f-4779-b1ab-66a5bb16a94b";
+2 -2
View File
@@ -1,2 +1,2 @@
export * from "./constants";
export * from "./send-server-analytics";
export { trackEvent } from "./client";
export { sendServerAnalyticsAsync } from "./send-server-analytics";
+136 -63
View File
@@ -1,17 +1,16 @@
import type { UmamiEventData } from "@umami/node";
import { Umami } from "@umami/node";
import { isProviderEnabled } from "@homarr/auth/server";
import { Stopwatch } from "@homarr/common";
import { createId, Stopwatch } from "@homarr/common";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { count, db } from "@homarr/db";
import { isMysql, isPostgresql } from "@homarr/db/collection";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { getServerSettingByKeyAsync, updateServerSettingByKeyAsync } from "@homarr/db/queries";
import {
accounts,
apiKeys,
apps,
boards,
cronJobConfigurations,
customWidgetDefinitions,
groups,
iconRepositories,
icons,
@@ -24,86 +23,160 @@ import {
searchEngines,
sectionLayouts,
sections,
sessions,
trustedCertificateHostnames,
users,
} from "@homarr/db/schema";
import { env as dockerEnv } from "@homarr/docker/env";
import packageJson from "../../../package.json";
import { UMAMI_HOST_URL, UMAMI_WEBSITE_ID } from "./constants";
import { getPostHogClient } from "./client";
const logger = createLogger({ module: "analytics" });
const getDatabaseType = (): "mysql" | "postgresql" | "sqlite" => {
if (isMysql()) return "mysql";
if (isPostgresql()) return "postgresql";
return "sqlite";
type AnalyticsResult = "sent" | "disabled" | "failed";
// ponytail: no DB-level lock; concurrent calls may both generate an ID, but the last write wins
// and the ID stabilizes after the first successful run. Upgrade path: use DB upsert with WHERE instanceId IS NULL.
const getOrCreateInstanceId = async (
analyticsSettings: Awaited<ReturnType<typeof getServerSettingByKeyAsync<"analytics">>>,
): Promise<string> => {
if (analyticsSettings.instanceId) return analyticsSettings.instanceId;
const instanceId = createId();
await updateServerSettingByKeyAsync(db, "analytics", { ...analyticsSettings, instanceId });
const verified = await getServerSettingByKeyAsync(db, "analytics");
return verified.instanceId ?? instanceId;
};
const getEnabledAuthProviders = (): string[] => {
const providers: string[] = [];
if (isProviderEnabled("credentials")) providers.push("credentials");
if (isProviderEnabled("oidc")) providers.push("oidc");
if (isProviderEnabled("ldap")) providers.push("ldap");
return providers;
};
const sumGroupedCounts = (rows: { count: number }[]): number => rows.reduce((sum, row) => sum + row.count, 0);
export const sendServerAnalyticsAsync = async () => {
export const sendServerAnalyticsAsync = async (): Promise<AnalyticsResult> => {
const stopWatch = new Stopwatch();
const analyticsSettings = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticsSettings.enableGeneral) {
logger.info("Analytics are disabled. No data will be sent. Enable analytics in the settings");
return;
return "disabled";
}
const umamiInstance = new Umami();
umamiInstance.init({
hostUrl: UMAMI_HOST_URL,
websiteId: UMAMI_WEBSITE_ID,
});
const client = getPostHogClient();
const enabledAuthProviders = getEnabledAuthProviders();
try {
const instanceId = await getOrCreateInstanceId(analyticsSettings);
const analyticsData: UmamiEventData = {
homarrVersion: packageJson.version,
databaseType: getDatabaseType(),
dockerEnabled: dockerEnv.ENABLE_DOCKER ? 1 : 0,
kubernetesEnabled: dockerEnv.ENABLE_KUBERNETES ? 1 : 0,
authCredentials: enabledAuthProviders.includes("credentials") ? 1 : 0,
authOidc: enabledAuthProviders.includes("oidc") ? 1 : 0,
authLdap: enabledAuthProviders.includes("ldap") ? 1 : 0,
countUsers: await db.$count(users),
countBoards: await db.$count(boards),
countGroups: await db.$count(groups),
countApps: await db.$count(apps),
countWidgets: await db.$count(items),
countSections: await db.$count(sections),
countIntegrations: await db.$count(integrations),
countSearchEngines: await db.$count(searchEngines),
countIconRepositories: await db.$count(iconRepositories),
countIcons: await db.$count(icons),
countApiKeys: await db.$count(apiKeys),
countInvites: await db.$count(invites),
countMedias: await db.$count(medias),
countLayouts: await db.$count(layouts),
countItemLayouts: await db.$count(itemLayouts),
countSectionLayouts: await db.$count(sectionLayouts),
countCronJobConfigs: await db.$count(cronJobConfigurations),
};
const [
cultureSettings,
countBoards,
countGroups,
countApps,
countSections,
countSearchEngines,
countIconRepositories,
countIcons,
countLayouts,
countItemLayouts,
countSectionLayouts,
countCronJobConfigs,
countCustomWidgets,
countTrustedCertificates,
countUsers,
countApiKeys,
countInvites,
countMedias,
countSessions,
countAccounts,
integrationKinds,
widgetKinds,
] = await Promise.all([
getServerSettingByKeyAsync(db, "culture"),
db.$count(boards),
db.$count(groups),
db.$count(apps),
db.$count(sections),
db.$count(searchEngines),
db.$count(iconRepositories),
db.$count(icons),
db.$count(layouts),
db.$count(itemLayouts),
db.$count(sectionLayouts),
db.$count(cronJobConfigurations),
db.$count(customWidgetDefinitions),
db.$count(trustedCertificateHostnames),
db.$count(users),
db.$count(apiKeys),
db.$count(invites),
db.$count(medias),
db.$count(sessions),
db.$count(accounts),
db
.select({ kind: integrations.kind, count: count(integrations.id) })
.from(integrations)
.groupBy(integrations.kind),
db
.select({ kind: items.kind, count: count(items.id) })
.from(items)
.groupBy(items.kind),
]);
const integrationKinds = await db
.select({ kind: integrations.kind, count: count(integrations.id) })
.from(integrations)
.groupBy(integrations.kind);
const enabledAuthProviders = (["credentials", "oidc", "ldap"] as const).filter(isProviderEnabled);
integrationKinds.forEach((integrationKind) => {
analyticsData[`integration_${integrationKind.kind}`] = integrationKind.count;
});
const properties: Record<string, unknown> = {
homarrVersion: packageJson.version,
databaseType: isMysql() ? "mysql" : isPostgresql() ? "postgresql" : "sqlite",
dockerEnabled: Boolean(dockerEnv.ENABLE_DOCKER),
kubernetesEnabled: Boolean(dockerEnv.ENABLE_KUBERNETES),
authProviders: enabledAuthProviders,
const response = await umamiInstance.track("server-analytics-v2", analyticsData);
if (!response.ok) {
logger.warn("Unable to send analytics to Umami instance");
osPlatform: process.platform,
osArch: process.arch,
uptimeSeconds: Math.floor(process.uptime()),
defaultLocale: cultureSettings.defaultLocale,
countBoards,
countGroups,
countApps,
countSections,
countSearchEngines,
countIconRepositories,
countIcons,
countLayouts,
countItemLayouts,
countSectionLayouts,
countCronJobConfigs,
countCustomWidgets,
countTrustedCertificates,
countUsers,
countApiKeys,
countInvites,
countMedias,
countSessions,
countAccounts,
countIntegrations: sumGroupedCounts(integrationKinds),
countWidgets: sumGroupedCounts(widgetKinds),
};
for (const row of integrationKinds) {
properties[`integration_${row.kind}`] = row.count;
}
for (const row of widgetKinds) {
properties[`widget_${row.kind}`] = row.count;
}
client.capture({
distinctId: instanceId,
event: "server-analytics",
properties,
});
await client.flush();
logger.info(`Sent analytics to PostHog in ${stopWatch.getElapsedInHumanWords()}`);
return "sent";
} catch (error) {
logger.warn("Failed to send analytics to PostHog", { error });
return "failed";
}
logger.info(`Sent analytics in ${stopWatch.getElapsedInHumanWords()}`);
};
+1
View File
@@ -24,6 +24,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@homarr/analytics": "workspace:^0.1.0",
"@homarr/auth": "workspace:^0.1.0",
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
+1
View File
@@ -28,6 +28,7 @@ export const appRouter = createTRPCRouter({
updateChecker: lazy(() => import("./router/update-checker").then((mod) => mod.updateCheckerRouter)),
certificates: lazy(() => import("./router/certificates/certificate-router").then((mod) => mod.certificateRouter)),
bangs: lazy(() => import("./router/bangs/bangs-router").then((mod) => mod.bangsRouter)),
analytics: lazy(() => import("./router/analytics").then((mod) => mod.analyticsRouter)),
info: lazy(() => import("./router/info").then((mod) => mod.infoRouter)),
customWidget: lazy(() => import("./router/custom-widget/custom-widget-router").then((mod) => mod.customWidgetRouter)),
});
+51
View File
@@ -0,0 +1,51 @@
import { TRPCError } from "@trpc/server";
import z from "zod/v4";
import { sendServerAnalyticsAsync, trackEvent } from "@homarr/analytics";
import { env } from "@homarr/common/env";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure } from "../trpc";
const getInstanceId = async (): Promise<string | null> => {
if (env.NO_EXTERNAL_CONNECTION) return null;
const settings = await getServerSettingByKeyAsync(db, "analytics");
if (!settings.enableGeneral) return null;
return settings.instanceId;
};
export const analyticsRouter = createTRPCRouter({
sendAnalytics: permissionRequiredProcedure.requiresPermission("admin").mutation(async () => {
if (env.NO_EXTERNAL_CONNECTION) {
throw new TRPCError({ code: "PRECONDITION_FAILED", message: "External connections are disabled" });
}
return { status: await sendServerAnalyticsAsync() };
}),
trackFeature: protectedProcedure
.input(
z.object({
feature: z.string().max(128),
properties: z.record(z.string(), z.unknown()).optional(),
}),
)
.mutation(async ({ input, ctx }) => {
const instanceId = await getInstanceId();
if (!instanceId) return;
trackEvent(instanceId, `feature:${input.feature}`, {
...input.properties,
userId: ctx.session.user.id,
});
}),
heartbeat: protectedProcedure.mutation(async ({ ctx }) => {
const instanceId = await getInstanceId();
if (!instanceId) return;
trackEvent(instanceId, "user-active", {
userId: ctx.session.user.id,
});
}),
});
+25 -9
View File
@@ -15,20 +15,36 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: {
id: true,
name: true,
url: true,
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
// ponytail: SVG first, then alphabetical; with a search term,
// prepend a relevance tier (exact > prefix > substring).
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
},
where:
(input.searchText?.length ?? 0) > 0
? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
and(...input.searchText!.split(" ").map((keyword) => like(icons.name, `%${keyword}%`)))
: undefined,
limit: input.limitPerGroup,
},
},
@@ -27,6 +27,7 @@ import {
integrationSecretKindObject,
} from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import { invalidateIntegrationCacheAsync } from "@homarr/redis";
import { byIdSchema } from "@homarr/validation/common";
import {
integrationCreateSchema,
@@ -469,6 +470,10 @@ export const integrationRouter = createTRPCRouter({
kind: integration.kind,
url: input.url,
});
// Invalidate all cached data for this integration so that widgets pick up the
// new configuration immediately instead of serving stale (or errored) data.
await invalidateIntegrationCacheAsync(input.id);
}),
delete: protectedProcedure
.meta({
@@ -490,6 +495,9 @@ export const integrationRouter = createTRPCRouter({
}
await ctx.db.delete(integrations).where(eq(integrations.id, input.id));
// Clean up any cached data left behind by the deleted integration.
await invalidateIntegrationCacheAsync(input.id);
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
+7 -6
View File
@@ -24,17 +24,18 @@ export const serverSettingsRouter = createTRPCRouter({
}),
)
.mutation(async ({ ctx, input }) => {
await updateServerSettingByKeyAsync(
ctx.db,
input.settingsKey,
input.value as ServerSettings[keyof ServerSettings],
);
const current = await getServerSettingByKeyAsync(ctx.db, input.settingsKey);
await updateServerSettingByKeyAsync(ctx.db, input.settingsKey, {
...current,
...input.value,
} as ServerSettings[keyof ServerSettings]);
}),
initSettings: onboardingProcedure
.requiresStep("settings")
.input(settingsInitSchema)
.mutation(async ({ ctx, input }) => {
await updateServerSettingByKeyAsync(ctx.db, "analytics", input.analytics);
const currentAnalytics = await getServerSettingByKeyAsync(ctx.db, "analytics");
await updateServerSettingByKeyAsync(ctx.db, "analytics", { ...currentAnalytics, ...input.analytics });
await updateServerSettingByKeyAsync(ctx.db, "crawlingAndIndexing", input.crawlingAndIndexing);
await nextOnboardingStepAsync(ctx.db, undefined);
}),
@@ -75,9 +75,6 @@ describe("saveSettings", () => {
settingsKey: "analytics",
value: {
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
},
});
@@ -87,9 +84,7 @@ describe("saveSettings", () => {
settingKey: "analytics",
value: SuperJSON.stringify({
enableGeneral: true,
enableWidgetData: true,
enableIntegrationData: true,
enableUserData: true,
instanceId: null,
}),
},
]);
+109 -5
View File
@@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import { observable } from "@trpc/server/observable";
import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import {
@@ -14,6 +15,8 @@ import type { BeszelAlertsData, BeszelSystemRow } from "@homarr/request-handler/
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "beszelRouter" });
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -25,7 +28,9 @@ export const beszelRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "beszel", "mock"))
.query(async ({ ctx }) => {
const results = await Promise.all(
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getSystems called", { userId: ctx.session?.user?.id, integrationIds });
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const innerHandler = beszelSystemsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({
@@ -40,6 +45,29 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getSystems integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
integrationUrl: integration?.url ?? "",
systems: [],
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getSystems completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -83,7 +111,14 @@ export const beszelRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const results = await Promise.all(
const integrationIds = ctx.integrations.map((i) => i.id);
logger.debug("getAlerts called", {
userId: ctx.session?.user?.id,
integrationIds,
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
});
const settled = await Promise.allSettled(
ctx.integrations.map(async (integration) => {
const alertsHandler = beszelAlertsRequestHandler.handler(integration, {
includeHistory: input.includeHistory,
@@ -100,6 +135,7 @@ export const beszelRouter = createTRPCRouter({
}
return {
integrationId: integration.id,
integrationName: integration.name,
alerts: alertsResult.data.alerts,
history: alertsResult.data.history,
systemNameMap,
@@ -107,6 +143,30 @@ export const beszelRouter = createTRPCRouter({
};
}),
);
const results = settled.map((result, index) => {
if (result.status === "fulfilled") return result.value;
const integration = ctx.integrations[index];
logger.warn("getAlerts integration failed", {
userId: ctx.session?.user?.id,
integrationId: integration?.id,
error: result.reason,
});
return {
integrationId: integration?.id ?? "unknown",
integrationName: integration?.name ?? "unknown",
alerts: [],
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: result.reason instanceof Error ? result.reason.message : String(result.reason),
};
});
logger.debug("getAlerts completed", {
userId: ctx.session?.user?.id,
integrationIds,
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
return results;
}),
@@ -164,13 +224,41 @@ export const beszelRouter = createTRPCRouter({
if (!integration) {
throw new TRPCError({ code: "BAD_REQUEST", message: "At least one Beszel integrationId is required" });
}
const innerHandler = beszelStatsRequestHandler.handler(integration, {
logger.debug("getSystemStats called", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return { integrationId: integration.id, ...data, updatedAt: timestamp };
try {
const innerHandler = beszelStatsRequestHandler.handler(integration, {
systemId: input.systemId,
timePeriod: input.timePeriod,
includeDocker: input.includeDocker,
});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
logger.debug("getSystemStats completed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
return { integrationId: integration.id, ...data, updatedAt: timestamp };
} catch (error) {
logger.warn("getSystemStats failed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
return {
integrationId: integration.id,
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: error instanceof Error ? error.message : String(error),
};
}
}),
subscribeSystemStats: publicProcedure
@@ -190,6 +278,11 @@ export const beszelRouter = createTRPCRouter({
emit.error(new Error("No Beszel integration configured"));
return () => {};
}
logger.debug("subscribeSystemStats opened", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
const abortController = new AbortController();
@@ -199,6 +292,12 @@ export const beszelRouter = createTRPCRouter({
await instance.subscribeRealtimeMetrics(input.systemId, (data) => emit.next(data), abortController.signal);
} catch (error) {
if (!abortController.signal.aborted) {
logger.warn("subscribeSystemStats error", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
error,
});
if (error instanceof Error) {
emit.error(error);
} else {
@@ -210,6 +309,11 @@ export const beszelRouter = createTRPCRouter({
return () => {
abortController.abort();
logger.debug("subscribeSystemStats closed", {
userId: ctx.session?.user?.id,
integrationId: integration.id,
systemId: input.systemId,
});
};
});
}),
+1
View File
@@ -36,6 +36,7 @@ export const widgetRouter = createTRPCRouter({
uptimeKuma: lazy(() => import("./uptime-kuma").then((mod) => mod.uptimeKumaRouter)),
audioStats: lazy(() => import("./audio-stats").then((mod) => mod.audioStatsRouter)),
umami: lazy(() => import("./umami").then((mod) => mod.umamiRouter)),
vpn: lazy(() => import("./vpn").then((mod) => mod.vpnRouter)),
ups: lazy(() => import("./ups").then((mod) => mod.upsRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
});
+59
View File
@@ -0,0 +1,59 @@
import { observable } from "@trpc/server/observable";
import type { Modify } from "@homarr/common/types";
import type { Integration } from "@homarr/db/schema";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createVpnIntegrationMiddleware = () =>
createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"));
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).query(async ({ ctx }) => {
return await Promise.all(
ctx.integrations.map(async (integration) => {
const innerHandler = vpnSummaryHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getCachedOrUpdatedDataAsync({ forceUpdate: false });
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
}),
);
}),
subscribeSummaries: publicProcedure.unstable_concat(createVpnIntegrationMiddleware()).subscription(({ ctx }) => {
return observable<{
integration: Modify<Integration, { kind: IntegrationKindByCategory<"vpn"> }>;
summary: VpnSummary | null;
}>((emit) => {
const unsubscribes: (() => void)[] = [];
for (const integrationWithSecrets of ctx.integrations) {
const { decryptedSecrets: _, ...integration } = integrationWithSecrets;
const innerHandler = vpnSummaryHandler.handler(integrationWithSecrets, {});
const unsubscribe = innerHandler.subscribe((summary) => {
emit.next({
integration,
summary,
});
});
unsubscribes.push(unsubscribe);
}
return () => {
unsubscribes.forEach((unsubscribe) => {
unsubscribe();
});
};
});
}),
});
+19 -5
View File
@@ -20,6 +20,24 @@ import { expireDateAfter, generateSessionToken, sessionTokenCookieName } from ".
const logger = createLogger({ module: "authConfiguration" });
// All Auth.js cookies are prefixed with AUTH_COOKIE_PREFIX (default "homarr") so
// Homarr's cookie jar is isolated from any other Auth.js/NextAuth app on the
// same hostname (browsers scope cookies by host, not by port).
// See https://github.com/homarr-labs/homarr/issues/5773
const createCookies = (useSecureCookies: boolean) => {
const prefix = env.AUTH_COOKIE_PREFIX;
const securePrefix = useSecureCookies ? "__Secure-" : "";
const hostPrefix = useSecureCookies ? "__Host-" : "";
return {
sessionToken: { name: sessionTokenCookieName },
csrfToken: { name: `${hostPrefix}${prefix}.csrf-token` },
callbackUrl: { name: `${securePrefix}${prefix}.callback-url` },
pkceCodeVerifier: { name: `${securePrefix}${prefix}.pkce.cooldown` },
state: { name: `${securePrefix}${prefix}.state` },
nonce: { name: `${securePrefix}${prefix}.nonce` },
};
};
// See why it's unknown in the [...nextauth]/route.ts file
export const createConfiguration = (
provider: SupportedAuthProvider | "unknown",
@@ -41,11 +59,7 @@ export const createConfiguration = (
},
},
trustHost: true,
cookies: {
sessionToken: {
name: sessionTokenCookieName,
},
},
cookies: createCookies(useSecureCookies),
adapter,
providers: filterProviders([
Credentials(createCredentialsConfiguration(db)),
+5
View File
@@ -28,6 +28,11 @@ export const env = createEnv({
AUTH_LOGOUT_REDIRECT_URL: z.string().url().optional(),
AUTH_SESSION_EXPIRY_TIME: createDurationSchema("30d"),
AUTH_PROVIDERS: authProvidersSchema,
AUTH_COOKIE_PREFIX: z
.string()
.min(1)
.regex(/^[a-zA-Z0-9-_]+$/, "AUTH_COOKIE_PREFIX must only contain letters, numbers, hyphens and underscores")
.default("homarr"),
...(authProviders.includes("oidc")
? {
AUTH_OIDC_ISSUER: z.string().url(),
+4 -2
View File
@@ -4,9 +4,11 @@ import { generateSecureRandomToken } from "@homarr/common/server";
import type { Database } from "@homarr/db";
import { getCurrentUserPermissionsAsync } from "./callbacks";
import { env } from "./env";
// Default of authjs
export const sessionTokenCookieName = "authjs.session-token";
// Prefixed (AUTH_COOKIE_PREFIX, default "homarr") to avoid cookie collisions
// with other Auth.js apps on the same host. See https://github.com/homarr-labs/homarr/issues/5773
export const sessionTokenCookieName = `${env.AUTH_COOKIE_PREFIX}.session-token`;
export const expireDateAfter = (seconds: number) => {
return new Date(Date.now() + seconds * 1000);
+2
View File
@@ -20,6 +20,7 @@ interface CreateCronJobOptions {
runOnStart?: boolean;
preventManualExecution?: boolean;
preventCustomInterval?: boolean;
hidden?: boolean;
expectedMaximumDurationInMillis?: number;
beforeStart?: () => MaybePromise<void>;
}
@@ -121,6 +122,7 @@ const createCallback = <TAllowedNames extends string, TName extends TAllowedName
},
preventManualExecution: options.preventManualExecution ?? false,
preventCustomInterval: options.preventCustomInterval ?? false,
hidden: options.hidden ?? false,
timezone: creatorOptions.timezone,
};
};
+2
View File
@@ -21,6 +21,7 @@ import { refreshNotificationsJob } from "./jobs/integrations/notifications";
import { timetableJob } from "./jobs/integrations/timetable";
import { tracearrJob } from "./jobs/integrations/tracearr";
import { umamiActiveVisitorsJob } from "./jobs/integrations/umami";
import { vpnJob } from "./jobs/integrations/vpn";
import { minecraftServerStatusJob } from "./jobs/minecraft-server-status";
import { pingJob } from "./jobs/ping";
import { rssFeedsJob } from "./jobs/rss-feeds";
@@ -57,6 +58,7 @@ const getJobGroup = () => {
timetable: timetableJob,
tracearr: tracearrJob,
umamiActiveVisitors: umamiActiveVisitorsJob,
vpn: vpnJob,
});
};
+12 -10
View File
@@ -122,15 +122,17 @@ export class JobManager {
> {
const configurations = await this.db.query.cronJobConfigurations.findMany();
return [...this.jobGroup.getJobRegistry().entries()].map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
preventCustomInterval: job.preventCustomInterval,
isEnabled: config?.isEnabled ?? true,
};
});
return [...this.jobGroup.getJobRegistry().entries()]
.filter(([_, job]) => !job.hidden)
.map(([name, job]) => {
const config = configurations.find((config) => config.name === name);
return {
name,
cron: config?.cronExpression ?? job.cronExpression,
preventManualExecution: job.preventManualExecution,
preventCustomInterval: job.preventCustomInterval,
isEnabled: config?.isEnabled ?? true,
};
});
}
}
+1 -8
View File
@@ -1,8 +1,6 @@
import { sendServerAnalyticsAsync } from "@homarr/analytics";
import { env } from "@homarr/common/env";
import { EVERY_WEEK } from "@homarr/cron-jobs-core/expressions";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import { createCronJob } from "../lib";
@@ -10,13 +8,8 @@ export const analyticsJob = createCronJob("analytics", EVERY_WEEK, {
runOnStart: true,
preventManualExecution: true,
preventCustomInterval: true,
hidden: true,
}).withCallback(async () => {
if (env.NO_EXTERNAL_CONNECTION) return;
const analyticSetting = await getServerSettingByKeyAsync(db, "analytics");
if (!analyticSetting.enableGeneral) {
return;
}
await sendServerAnalyticsAsync();
});
@@ -0,0 +1,14 @@
import { EVERY_30_SECONDS } from "@homarr/cron-jobs-core/expressions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createRequestIntegrationJobHandler } from "@homarr/request-handler/lib/cached-request-integration-job-handler";
import { createCronJob } from "../../lib";
export const vpnJob = createCronJob("vpn", EVERY_30_SECONDS).withCallback(
createRequestIntegrationJobHandler(vpnSummaryHandler.handler, {
widgetKinds: ["vpn"],
getInput: {
vpn: () => ({}),
},
}),
);
+10 -14
View File
@@ -647,9 +647,13 @@ const seedDemoUserAsync = async (db: Database) => {
const seedDefaultCustomWidgetsAsync = async (db: Database) => {
const seedIds = CUSTOM_WIDGET_SEEDS.map((s) => s.id);
const beforeCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const existing = await db.query.customWidgetDefinitions.findMany({
columns: { id: true },
where: inArray(customWidgetDefinitions.id, seedIds),
});
const existingIds = new Set(existing.map((row) => row.id));
const seedValues = CUSTOM_WIDGET_SEEDS.map((seed) => {
const seedValues = CUSTOM_WIDGET_SEEDS.filter((seed) => !existingIds.has(seed.id)).map((seed) => {
const parsed = customWidgetImportSchema.parse(seed.data);
return {
id: seed.id,
@@ -668,22 +672,14 @@ const seedDefaultCustomWidgetsAsync = async (db: Database) => {
};
});
if (seedValues.length === 0) return;
await db
.insert(customWidgetDefinitions)
.values(seedValues)
.onConflictDoNothing({ target: customWidgetDefinitions.id });
const afterCount = await db.$count(customWidgetDefinitions, inArray(customWidgetDefinitions.id, seedIds));
const createdCount = afterCount - beforeCount;
if (createdCount === 0) {
if (seedValues.length === 0) {
console.log("Skipping seeding of default custom widgets as they already exist");
return;
}
console.log(`Created ${createdCount} default custom widgets through seeding process`);
await db.insert(customWidgetDefinitions).values(seedValues);
console.log(`Created ${seedValues.length} default custom widgets through seeding process`);
};
const seedBoardWidgetsAsync = async (db: Database) => {
@@ -36,6 +36,7 @@ const integrationAliases: Partial<Record<IntegrationKind, readonly string[]>> =
linuxServerIO: ["linuxserver"],
coolify: ["coolify"],
truenas: ["truenas"],
fileFlows: ["fileflows"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
@@ -165,6 +165,7 @@ export type HomarrDocumentationPath =
| "/docs/getting-started/installation/unraid"
| "/docs/integrations/adguard-home"
| "/docs/integrations/anchor"
| "/docs/integrations/archiveteam-warrior"
| "/docs/integrations/aria2"
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/beszel"
@@ -179,6 +180,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/github"
| "/docs/integrations/gitlab"
| "/docs/integrations/glances"
| "/docs/integrations/gluetun"
| "/docs/integrations/gotify"
| "/docs/integrations/home-assistant"
| "/docs/integrations/ical"
@@ -280,6 +282,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/ups"
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/video"
| "/docs/widgets/vpn"
| "/docs/widgets/weather"
| ""
| "/sitemap.xml"
@@ -309,6 +312,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/dash-dot"
| "/docs/integrations/glances"
| "/docs/integrations/tdarr"
| "/docs/integrations/fileflows"
| "/docs/integrations/proxmox"
| "/docs/integrations/nextcloud"
| "/docs/integrations/unifi-controller"
@@ -337,6 +341,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/audiobookshelf"
| "/docs/integrations/navidrome"
| "/docs/integrations/umami"
| "/docs/integrations/gluetun"
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/widgets/clock"
@@ -380,6 +385,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/uptime-kuma"
| "/docs/widgets/audio-stats"
| "/docs/widgets/umami"
| "/docs/widgets/vpn"
| "/docs/widgets/ups"
| "/docs/widgets/beszel-system-table"
| "/docs/widgets/beszel-system-grid"
@@ -27,6 +27,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
dashDot: "dash-dot",
glances: "glances",
tdarr: "tdarr",
fileFlows: "fileflows",
proxmox: "proxmox",
nextcloud: "nextcloud",
unifiController: "unifi-controller",
@@ -55,6 +56,7 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
audiobookshelf: "audiobookshelf",
navidrome: "navidrome",
umami: "umami",
gluetun: "gluetun",
peaNut: "peanut",
beszel: "beszel",
archiveTeamWarrior: null,
@@ -43,6 +43,7 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
uptimeKuma: "uptime-kuma",
audioStats: "audio-stats",
umami: "umami",
vpn: "vpn",
ups: "ups",
archiveTeamWarrior: null,
beszelSystemTable: "beszel-system-table",
+17 -1
View File
@@ -251,6 +251,14 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/tdarr"),
defaultPort: 8265,
},
fileFlows: {
name: "FileFlows",
secretKinds: [[], ["apiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/fileflows.png",
category: ["mediaTranscoding"],
documentationUrl: createDocumentationLink("/docs/integrations/fileflows"),
defaultPort: 5000,
},
proxmox: {
name: "Proxmox",
secretKinds: [["username", "tokenId", "apiKey", "realm"]],
@@ -489,13 +497,20 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/beszel"),
defaultPort: 8090,
},
gluetun: {
name: "Gluetun",
secretKinds: [["username", "password"], ["apiKey"], []],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/gluetun.svg",
category: ["vpn"],
documentationUrl: createDocumentationLink("/docs/integrations/gluetun"),
},
archiveTeamWarrior: {
name: "ArchiveTeam Warrior",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/selfhst/icons/png/archiveteam-warrior.png",
category: ["archiving"],
defaultUrl: "http://localhost:8001",
documentationUrl: null,
documentationUrl: createDocumentationLink("/docs/integrations/archiveteam-warrior"),
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
@@ -604,6 +619,7 @@ export const integrationCategories = [
"mediaMonitoring",
"speedtest",
"analytics",
"vpn",
"archiving",
"ups",
"documents",
@@ -38,6 +38,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
speedtestTracker: ["speedtestTracker"],
uptimeKuma: ["uptimeKuma"],
audioStats: ["navidrome", "audiobookshelf"],
vpn: getIntegrationKindsByCategory("vpn"),
anchorNote: ["anchor"],
};
+1
View File
@@ -41,6 +41,7 @@ export const widgetKinds = [
"uptimeKuma",
"audioStats",
"umami",
"vpn",
"archiveTeamWarrior",
"ups",
"beszelSystemTable",
@@ -1,8 +1,11 @@
"use client";
import type { FocusEventHandler } from "react";
import { startTransition } from "react";
import { startTransition, useEffect, useState } from "react";
import {
ActionIcon,
Box,
Button,
Card,
Combobox,
Flex,
@@ -18,7 +21,7 @@ import {
useCombobox,
} from "@mantine/core";
import { useDebouncedValue, useUncontrolled } from "@mantine/hooks";
import { IconUpload } from "@tabler/icons-react";
import { IconMoodEmpty, IconSearchOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
@@ -34,7 +37,6 @@ interface IconPickerProps {
error?: string | null;
onFocus?: FocusEventHandler;
onBlur?: FocusEventHandler;
label?: string;
placeholder?: string;
withAsterisk?: boolean;
@@ -50,29 +52,30 @@ export const IconPicker = ({
label,
placeholder,
}: IconPickerProps) => {
const [value, setValue] = useUncontrolled({
value: propsValue,
onChange,
});
const [search, setSearch] = useUncontrolled({
value,
onChange: (value) => {
setValue(value);
},
});
const [previewUrl, setPreviewUrl] = useUncontrolled({
value: propsValue ?? null,
});
const [value, setValue] = useUncontrolled({ value: propsValue, onChange });
const [query, setQuery] = useState("");
// ponytail: reset the typed query when the committed value changes
// externally (form reset, parent re-render) so the input reverts to the URL.
useEffect(() => {
setQuery("");
}, [value]);
const { data: session } = useSession();
const tCommon = useScopedI18n("common");
const [debouncedQuery] = useDebouncedValue(query, 100);
const [debouncedSearch] = useDebouncedValue(search, 100);
// ponytail: only search when the typed query differs from the committed
// value. On mount the input shows the existing URL (edit mode), which
// would never match the bare filenames in the icon table.
const searchText = (debouncedQuery !== (value ?? "") && debouncedQuery) || "";
// We use not useSuspenseQuery as it would cause an above Suspense boundary to trigger and so searching for something is bad UX.
const { data } = clientApi.icon.findIcons.useQuery({
searchText: debouncedSearch,
});
// ponytail: not useSuspenseQuery — an above Suspense boundary would
// trigger on every keystroke and the dropdown flash is bad UX.
const { data, isLoading, isError, refetch } = clientApi.icon.findIcons.useQuery(
{ searchText },
{ placeholderData: (prev) => prev },
);
const combobox = useCombobox({
onDropdownClose: () => combobox.resetSelectedOption(),
@@ -90,25 +93,15 @@ export const IconPicker = ({
>
<UnstyledButton
onClick={() => {
const value = item.url;
startTransition(() => {
setPreviewUrl(value);
setSearch(value);
setValue(value);
setValue(item.url);
setQuery("");
combobox.closeDropdown();
});
}}
>
<Indicator label="SVG" disabled={!item.url.endsWith(".svg")} size={16}>
<Card
p="sm"
pos="relative"
style={{
overflow: "visible",
cursor: "pointer",
}}
className={classes.iconCard}
>
<Card p="sm" pos="relative" style={{ overflow: "visible", cursor: "pointer" }} className={classes.iconCard}>
<Box w={25} h={25}>
<Image src={item.url} w={25} h={25} />
</Box>
@@ -130,6 +123,48 @@ export const IconPicker = ({
);
});
// ponytail: show the typed query if non-empty, else fall back to the URL.
const inputValue = query || value || "";
// ponytail: show the existing icon as a left-section preview.
const leftSection = shouldShowPreview(value) && <img src={value} alt="" style={{ width: 20, height: 20 }} />;
// ponytail: prop override -> real count -> loading text.
const placeholderText =
placeholder ||
(data && tCommon("iconPicker.header", { countIcons: String(data.countIcons) })) ||
tCommon("iconPicker.headerLoading");
const renderDropdown = () => {
if (isError) {
return (
<Stack align="center" gap="xs" p="md">
<IconSearchOff size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.error")}</Text>
<Button size="xs" variant="default" onClick={() => refetch()}>
{tCommon("action.tryAgain")}
</Button>
</Stack>
);
}
if (isLoading) {
return Array(15)
.fill(0)
.map((_, index) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
));
}
if (totalOptions > 0) {
return <Stack gap={4}>{groups}</Stack>;
}
return (
<Stack align="center" gap="xs" p="md">
<IconMoodEmpty size={32} stroke={1.5} />
<Text size="sm">{tCommon("iconPicker.noResults", { search: debouncedQuery })}</Text>
</Stack>
);
};
return (
<Combobox store={combobox} withinPortal>
<Combobox.Target>
@@ -137,16 +172,11 @@ export const IconPicker = ({
<InputBase
flex={1}
rightSection={<Combobox.Chevron />}
leftSection={
shouldShowPreview(previewUrl) ? <img src={previewUrl} alt="" style={{ width: 20, height: 20 }} /> : null
}
value={search}
leftSection={leftSection}
value={inputValue}
onChange={(event) => {
combobox.openDropdown();
combobox.updateSelectedOptionIndex();
setSearch(event.currentTarget.value);
setValue(event.currentTarget.value);
setPreviewUrl(null);
setQuery(event.currentTarget.value);
}}
onClick={() => combobox.openDropdown()}
onFocus={(event) => {
@@ -156,25 +186,21 @@ export const IconPicker = ({
onBlur={(event) => {
onBlur?.(event);
combobox.closeDropdown();
setPreviewUrl(value);
setSearch(value || "");
setQuery("");
}}
rightSectionPointerEvents="none"
withAsterisk={withAsterisk}
error={error}
label={label ?? tCommon("iconPicker.label")}
placeholder={placeholder ?? tCommon("iconPicker.header", { countIcons: String(data?.countIcons ?? 0) })}
placeholder={placeholderText}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
setValue(first.url);
setPreviewUrl(first.url);
setSearch(first.url);
});
}}
>
@@ -190,17 +216,7 @@ export const IconPicker = ({
<Combobox.Dropdown>
<Combobox.Options mah={350} style={{ overflowY: "auto" }}>
{totalOptions > 0 ? (
<Stack gap={4}>{groups}</Stack>
) : (
Array(15)
.fill(0)
.map((_, index: number) => (
<Combobox.Option value={`skeleton-${index}`} key={index} disabled>
<Skeleton height={25} visible />
</Combobox.Option>
))
)}
{renderDropdown()}
</Combobox.Options>
</Combobox.Dropdown>
</Combobox>
@@ -209,8 +225,8 @@ export const IconPicker = ({
// This regex is used to prevent loading a preview like en or /en which would trigger a language change
// See https://github.com/homarr-labs/homarr/issues/3070
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i" /* ignore casing */);
const shouldShowPreview = (value: string | null): value is string => {
const localizationPathRegex = new RegExp(`^/?(${supportedLanguages.join("|")})(/.*)?$`, "i");
const shouldShowPreview = (value: string | null | undefined): value is string => {
if (!value) return false;
return !localizationPathRegex.test(value);
};
@@ -21,6 +21,7 @@ import { GitHubContainerRegistryIntegration } from "../github-container-registry
import { GithubIntegration } from "../github/github-integration";
import { GitlabIntegration } from "../gitlab/gitlab-integration";
import { GlancesIntegration } from "../glances/glances-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
@@ -32,6 +33,7 @@ import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration"
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { FileFlowsIntegration } from "../media-transcoding/fileflows-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
@@ -108,6 +110,7 @@ export const integrationCreators = {
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
fileFlows: FileFlowsIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
@@ -137,6 +140,7 @@ export const integrationCreators = {
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
@@ -46,10 +46,13 @@ export class BeszelIntegration extends Integration {
private async authenticateAsync(): Promise<BeszelSession> {
const existingSession = await this.sessionStore.getAsync();
if (existingSession) {
logger.debug("Using stored Beszel session", { integrationId: this.integration.id });
return existingSession;
}
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/collections/users/auth-with-password"), {
const authUrl = this.url("/api/collections/users/auth-with-password");
logger.debug("Authenticating with Beszel", { integrationId: this.integration.id, url: authUrl.pathname });
const response = await fetchWithTrustedCertificatesAsync(authUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -57,23 +60,28 @@ export class BeszelIntegration extends Integration {
password: this.getSecretValue("password"),
}),
});
logger.debug("Beszel auth response received", { integrationId: this.integration.id, status: response.status });
if (!response.ok) {
logger.warn("Beszel auth failed", { integrationId: this.integration.id, status: response.status });
throw new ResponseError(response);
}
const data = (await response.json()) as BeszelAuthResponse;
const session: BeszelSession = { token: data.token, userId: data.record.id };
await this.sessionStore.setAsync(session);
logger.debug("Saved Beszel session", { integrationId: this.integration.id, userId: session.userId });
return session;
}
private async fetchWithAuthAsync(url: URL, options: { method?: string; body?: string } = {}) {
const method = options.method ?? "GET";
const start = performance.now();
let session = await this.authenticateAsync();
const doFetch = (token: string) =>
fetchWithTrustedCertificatesAsync(url, {
method: options.method ?? "GET",
method,
headers: {
Authorization: token,
...(options.body ? { "Content-Type": "application/json" } : {}),
@@ -82,14 +90,42 @@ export class BeszelIntegration extends Integration {
});
let response = await doFetch(session.token);
logger.debug("Beszel response", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 1,
});
if (response.status === 401) {
logger.warn("Beszel 401, clearing session and retrying", {
integrationId: this.integration.id,
method,
path: url.pathname,
});
await this.sessionStore.clearAsync();
session = await this.authenticateAsync();
response = await doFetch(session.token);
logger.debug("Beszel response (after re-auth)", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
attempt: 2,
});
}
if (!response.ok) {
logger.warn("Beszel request failed", {
integrationId: this.integration.id,
method,
path: url.pathname,
status: response.status,
durationMs: Math.round(performance.now() - start),
});
throw new ResponseError(response);
}
@@ -257,6 +293,7 @@ export class BeszelIntegration extends Integration {
): Promise<void> {
const session = await this.authenticateAsync();
const sseUrl = this.url("/api/realtime");
logger.debug("Opening Beszel SSE connection", { integrationId: this.integration.id, systemId });
const sseResponse = await fetchWithTrustedCertificatesAsync(sseUrl, {
headers: { Authorization: session.token },
@@ -264,8 +301,14 @@ export class BeszelIntegration extends Integration {
});
if (!sseResponse.ok || !sseResponse.body) {
logger.warn("Beszel SSE connection failed", {
integrationId: this.integration.id,
systemId,
status: sseResponse.status,
});
throw new ResponseError(sseResponse);
}
logger.debug("Beszel SSE connection opened", { integrationId: this.integration.id, systemId });
const reader = sseResponse.body.getReader();
const decoder = new TextDecoder();
@@ -322,6 +365,10 @@ export class BeszelIntegration extends Integration {
}
}
if (!signal.aborted) {
logger.warn("Beszel SSE stream ended unexpectedly", {
integrationId: this.integration.id,
systemId,
});
throw new Error("Beszel SSE stream ended unexpectedly");
}
} finally {
@@ -38,7 +38,7 @@ const itemSchema = z.object({
ServerId: z.string(),
Name: z.string(),
Taglines: z.array(z.string()),
Studios: z.array(z.object({ Name: z.string() })),
Studios: z.array(z.object({ Name: z.string() })).optional(),
Overview: z.string().optional(),
PremiereDate: z
.string()
@@ -172,7 +172,7 @@ export class EmbyIntegration extends Integration implements IMediaServerIntegrat
poster: super.externalUrl(`/Items/${item.Id}/Images/Primary?maxHeight=492&maxWidth=328&quality=90`).toString(),
backdrop: super.externalUrl(`/Items/${item.Id}/Images/Backdrop/0?maxWidth=960&quality=70`).toString(),
},
producer: item.Studios.at(0)?.Name,
producer: item.Studios?.at(0)?.Name,
rating: item.CommunityRating?.toFixed(1),
tags: item.Genres,
href: super.externalUrl(`/web/index.html#!/item?id=${item.Id}&serverId=${item.ServerId}`).toString(),
@@ -0,0 +1,115 @@
import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { VpnSummaryIntegration } from "../interfaces/vpn-summary/vpn-summary-integration";
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
import {
gluetunDnsStatusSchema,
gluetunPublicIpSchema,
gluetunVpnSettingsSchema,
gluetunVpnStatusSchema,
} from "./gluetun-types";
import type { GluetunDnsStatus, GluetunPublicIp, GluetunVpnSettings, GluetunVpnStatus } from "./gluetun-types";
export class GluetunIntegration extends Integration implements VpnSummaryIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = this.getAuthHeaders();
const url = this.url("/v1/vpn/status");
const response = await input.fetchAsync(url, {
headers: authHeaders,
});
if (!response.ok) {
return TestConnectionError.StatusResult(response);
}
return { success: true };
}
public async getSummaryAsync(): Promise<VpnSummary> {
const authHeaders = this.getAuthHeaders();
const [vpnStatus, dnsStatus, publicIp, vpnSettings] = await Promise.all([
this.getVpnStatusAsync(authHeaders),
this.getDnsStatusAsync(authHeaders),
this.getPublicIpAsync(authHeaders),
this.getVpnSettingAsync(authHeaders),
]);
return {
vpnStatus: vpnStatus.status,
dnsStatus: dnsStatus.status,
publicIp: publicIp.public_ip,
country: publicIp.country,
city: publicIp.city,
vpnProvider: {
protocol: vpnSettings.type,
provider: vpnSettings.provider.name,
},
};
}
private async getVpnStatusAsync(authHeaders: Record<string, string>): Promise<GluetunVpnStatus> {
const url = this.url("/v1/vpn/status");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunVpnStatusSchema.parse(await response.json());
}
private async getDnsStatusAsync(authHeaders: Record<string, string>): Promise<GluetunDnsStatus> {
const url = this.url("/v1/dns/status");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunDnsStatusSchema.parse(await response.json());
}
private async getPublicIpAsync(authHeaders: Record<string, string>): Promise<GluetunPublicIp> {
const url = this.url("/v1/publicip/ip");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunPublicIpSchema.parse(await response.json());
}
private async getVpnSettingAsync(authHeaders: Record<string, string>): Promise<GluetunVpnSettings> {
const url = this.url("/v1/vpn/settings");
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (!response.ok) throw new ResponseError(response);
return gluetunVpnSettingsSchema.parse(await response.json());
}
private getAuthHeaders(): Record<string, string> {
if (this.hasSecretValue("apiKey")) {
return { "X-API-Key": this.getSecretValue("apiKey") };
}
if (this.hasSecretValue("username") && this.hasSecretValue("password")) {
const username = this.getSecretValue("username");
const password = this.getSecretValue("password");
const encodedCredentials = btoa(`${username}:${password}`);
return { Authorization: `Basic ${encodedCredentials}` };
}
return {};
}
}
@@ -0,0 +1,116 @@
import { z } from "zod/v4";
export const gluetunVpnStatusSchema = z.object({
status: z.string(),
});
export type GluetunVpnStatus = z.infer<typeof gluetunVpnStatusSchema>;
export const gluetunDnsStatusSchema = gluetunVpnStatusSchema;
export type GluetunDnsStatus = z.infer<typeof gluetunDnsStatusSchema>;
export const gluetunPublicIpSchema = z.object({
public_ip: z.string(),
region: z.string(),
country: z.string(),
city: z.string(),
location: z.string(),
organization: z.string(),
postal_code: z.string(),
timezone: z.string(),
});
export type GluetunPublicIp = z.infer<typeof gluetunPublicIpSchema>;
const gluetunServerSelectionOpenvpnSchema = z.object({
config_file_path: z.string(),
protocol: z.string(),
endpoint_ip: z.string(),
custom_port: z.number(),
pia_encryption_preset: z.string(),
});
const gluetunServerSelectionWireguardSchema = z.object({
endpoint_ip: z.string(),
endpoint_port: z.number(),
public_key: z.string(),
});
const gluetunServerSelectionSchema = z.object({
vpn: z.string(),
countries: z.array(z.string()).nullable(),
categories: z.array(z.string()).nullable(),
regions: z.array(z.string()).nullable(),
cities: z.array(z.string()).nullable(),
isps: z.array(z.string()).nullable(),
names: z.array(z.string()).nullable(),
numbers: z.array(z.number()).nullable(),
hostnames: z.array(z.string()),
owned_only: z.boolean(),
free_only: z.boolean(),
premium_only: z.boolean(),
stream_only: z.boolean(),
multi_hop_only: z.boolean(),
port_forward_only: z.boolean(),
secure_core_only: z.boolean(),
tor_only: z.boolean(),
openvpn: gluetunServerSelectionOpenvpnSchema,
wireguard: gluetunServerSelectionWireguardSchema,
});
const gluetunPortForwardingSchema = z.object({
enabled: z.boolean(),
provider: z.string(),
status_file_path: z.string(),
up_command: z.string(),
down_command: z.string(),
listening_port: z.number(),
username: z.string(),
password: z.string(),
});
const gluetunProviderSchema = z.object({
name: z.string(),
server_selection: gluetunServerSelectionSchema,
port_forwarding: gluetunPortForwardingSchema,
});
const gluetunOpenvpnSettingsSchema = z.object({
version: z.string(),
user: z.string(),
password: z.string(),
config_file_path: z.string(),
ciphers: z.array(z.string()).nullable(),
auth: z.string(),
cert: z.string(),
key: z.string(),
encrypted_key: z.string(),
key_passphrase: z.string(),
pia_encryption_preset: z.string(),
mssfix: z.number(),
interface: z.string(),
process_user: z.string(),
verbosity: z.number(),
flags: z.array(z.string()).nullable(),
});
const gluetunWireguardSettingsSchema = z.object({
private_key: z.string(),
pre_shared_key: z.string(),
addresses: z.array(z.string()),
allowed_ips: z.array(z.string()),
interface: z.string(),
persistent_keep_alive_interval: z.number(),
mtu: z.number(),
implementation: z.string(),
});
export const gluetunVpnSettingsSchema = z.object({
type: z.string(),
provider: gluetunProviderSchema,
openvpn: gluetunOpenvpnSettingsSchema,
wireguard: gluetunWireguardSettingsSchema,
});
export type GluetunVpnSettings = z.infer<typeof gluetunVpnSettingsSchema>;
@@ -0,0 +1,371 @@
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../../base/integration";
import { GluetunIntegration } from "../gluetun-integration";
import {
gluetunDnsStatusSchema,
gluetunPublicIpSchema,
gluetunVpnSettingsSchema,
gluetunVpnStatusSchema,
} from "../gluetun-types";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/db")>();
return {
...actual,
db: createDb(),
};
});
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const TEST_URL = "https://gluetun.example.com";
const TEST_API_KEY = "test-api-key";
const TEST_USERNAME = "user";
const TEST_PASSWORD = "p@ss";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
type FetchResponse = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
const VPN_STATUS_PAYLOAD = { status: "running" };
const DNS_STATUS_PAYLOAD = { status: "running" };
const PUBLIC_IP_PAYLOAD = {
public_ip: "203.0.113.42",
region: "Europe",
country: "Germany",
city: "Berlin",
location: "52.5200,13.4050",
organization: "Example Org",
postal_code: "10115",
timezone: "Europe/Berlin",
};
const VPN_SETTINGS_PAYLOAD = {
type: "wireguard",
provider: {
name: "mullvad",
server_selection: {
vpn: "wireguard",
countries: ["Germany"],
categories: null,
regions: null,
cities: ["Berlin"],
isps: null,
names: null,
numbers: null,
hostnames: ["de-ber-wg-001"],
owned_only: false,
free_only: false,
premium_only: false,
stream_only: false,
multi_hop_only: false,
port_forward_only: false,
secure_core_only: false,
tor_only: false,
openvpn: {
config_file_path: "",
protocol: "udp",
endpoint_ip: "",
custom_port: 0,
pia_encryption_preset: "",
},
wireguard: {
endpoint_ip: "203.0.113.1",
endpoint_port: 51820,
public_key: "pubkey",
},
},
port_forwarding: {
enabled: false,
provider: "mullvad",
status_file_path: "",
up_command: "",
down_command: "",
listening_port: 0,
username: "",
password: "",
},
},
openvpn: {
version: "",
user: "",
password: "",
config_file_path: "",
ciphers: null,
auth: "",
cert: "",
key: "",
encrypted_key: "",
key_passphrase: "",
pia_encryption_preset: "",
mssfix: 0,
interface: "tun0",
process_user: "",
verbosity: 0,
flags: null,
},
wireguard: {
private_key: "",
pre_shared_key: "",
addresses: ["10.0.0.2/32"],
allowed_ips: ["0.0.0.0/0"],
interface: "wg0",
persistent_keep_alive_interval: 25,
mtu: 1420,
implementation: "auto",
},
};
const jsonResponse = (body: unknown, init: ResponseInit = { status: 200 }): FetchResponse =>
new Response(JSON.stringify(body), {
...init,
headers: { "content-type": "application/json" },
}) as unknown as FetchResponse;
const setupHappyPath = () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
switch (pathname) {
case "/v1/vpn/status":
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
case "/v1/dns/status":
return Promise.resolve(jsonResponse(DNS_STATUS_PAYLOAD));
case "/v1/publicip/ip":
return Promise.resolve(jsonResponse(PUBLIC_IP_PAYLOAD));
case "/v1/vpn/settings":
return Promise.resolve(jsonResponse(VPN_SETTINGS_PAYLOAD));
default:
return Promise.resolve(jsonResponse({ error: "not found" }, { status: 404 }));
}
});
};
const createIntegrationWithApiKey = () =>
new GluetunIntegration({
id: "gluetun-1",
name: "Gluetun",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: TEST_API_KEY }],
});
const createIntegrationWithBasicAuth = () =>
new GluetunIntegration({
id: "gluetun-2",
name: "Gluetun",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: TEST_USERNAME },
{ kind: "password", value: TEST_PASSWORD },
],
});
describe("GluetunIntegration.getSummaryAsync", () => {
beforeEach(() => {
vi.clearAllMocks();
});
test("maps the four upstream responses into a flat VPN summary", async () => {
setupHappyPath();
const result = await createIntegrationWithApiKey().getSummaryAsync();
expect(result).toEqual({
vpnStatus: "running",
dnsStatus: "running",
publicIp: "203.0.113.42",
country: "Germany",
city: "Berlin",
vpnProvider: {
protocol: "wireguard",
provider: "mullvad",
},
});
});
test("hits each of the four control endpoints exactly once", async () => {
setupHappyPath();
await createIntegrationWithApiKey().getSummaryAsync();
const calledPaths = mockFetch.mock.calls.map(([url]) => {
const urlString = typeof url === "string" ? url : url.toString();
return new URL(urlString).pathname;
});
expect(calledPaths).toHaveLength(4);
expect(new Set(calledPaths)).toEqual(
new Set(["/v1/vpn/status", "/v1/dns/status", "/v1/publicip/ip", "/v1/vpn/settings"]),
);
});
test("sends X-API-Key header when an apiKey secret is configured", async () => {
setupHappyPath();
await createIntegrationWithApiKey().getSummaryAsync();
for (const [, init] of mockFetch.mock.calls) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
expect(headers.Authorization).toBeUndefined();
}
});
test("sends Basic auth header when username/password are configured", async () => {
setupHappyPath();
await createIntegrationWithBasicAuth().getSummaryAsync();
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
for (const [, init] of mockFetch.mock.calls) {
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers.Authorization).toBe(expected);
expect(headers["X-API-Key"]).toBeUndefined();
}
});
test("throws when any upstream endpoint returns a non-OK status", async () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
if (pathname === "/v1/publicip/ip") {
return Promise.resolve(jsonResponse({ error: "unauthorized" }, { status: 401 }));
}
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
});
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
});
test("throws when an upstream payload fails schema validation", async () => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url.toString();
const { pathname } = new URL(urlString);
if (pathname === "/v1/publicip/ip") {
return Promise.resolve(jsonResponse({ public_ip: 123 }));
}
return Promise.resolve(jsonResponse(VPN_STATUS_PAYLOAD));
});
await expect(createIntegrationWithApiKey().getSummaryAsync()).rejects.toThrow();
});
});
describe("GluetunIntegration testing endpoint", () => {
type TestingFn = (input: IntegrationTestingInput) => Promise<{ success: boolean }>;
const invokeTesting = (integration: GluetunIntegration, input: IntegrationTestingInput) => {
const testing = (integration as unknown as { testingAsync: TestingFn }).testingAsync.bind(integration);
return testing(input);
};
const makeInput = (response: Response): IntegrationTestingInput =>
({
fetchAsync: vi.fn().mockResolvedValue(response),
}) as unknown as IntegrationTestingInput;
test("returns success when /v1/vpn/status responds 200", async () => {
const input = makeInput(new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 }));
const result = await invokeTesting(createIntegrationWithApiKey(), input);
expect(result.success).toBe(true);
});
test("returns a status-code failure when /v1/vpn/status responds non-OK", async () => {
const input = makeInput(new Response("unauthorized", { status: 401 }));
const result = await invokeTesting(createIntegrationWithApiKey(), input);
expect(result.success).toBe(false);
});
test("passes the api-key header through to fetchAsync", async () => {
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithApiKey(), input);
const fetchAsyncMock = vi.mocked(input.fetchAsync);
const init = fetchAsyncMock.mock.calls[0]?.[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
expect(headers["X-API-Key"]).toBe(TEST_API_KEY);
});
test("passes the Basic auth header through to fetchAsync when username/password are configured", async () => {
const response = new Response(JSON.stringify(VPN_STATUS_PAYLOAD), { status: 200 });
const input = makeInput(response);
await invokeTesting(createIntegrationWithBasicAuth(), input);
const fetchAsyncMock = vi.mocked(input.fetchAsync);
const init = fetchAsyncMock.mock.calls[0]?.[1];
const headers = (init?.headers ?? {}) as Record<string, string>;
const expected = `Basic ${btoa(`${TEST_USERNAME}:${TEST_PASSWORD}`)}`;
expect(headers.Authorization).toBe(expected);
expect(headers["X-API-Key"]).toBeUndefined();
});
});
describe("gluetun schemas", () => {
test("vpn status schema accepts a string status", () => {
expect(gluetunVpnStatusSchema.parse({ status: "running" })).toEqual({ status: "running" });
});
test("vpn status schema rejects a missing status", () => {
expect(() => gluetunVpnStatusSchema.parse({})).toThrow();
});
test("dns status schema mirrors vpn status schema", () => {
expect(gluetunDnsStatusSchema.parse({ status: "stopped" })).toEqual({ status: "stopped" });
expect(() => gluetunDnsStatusSchema.parse({ status: 42 })).toThrow();
});
test("public ip schema parses the full payload", () => {
expect(gluetunPublicIpSchema.parse(PUBLIC_IP_PAYLOAD)).toEqual(PUBLIC_IP_PAYLOAD);
});
test("public ip schema rejects a missing required field", () => {
const { city: _city, ...partial } = PUBLIC_IP_PAYLOAD;
expect(() => gluetunPublicIpSchema.parse(partial)).toThrow();
});
test("vpn settings schema parses a wireguard payload", () => {
const parsed = gluetunVpnSettingsSchema.parse(VPN_SETTINGS_PAYLOAD);
expect(parsed.type).toBe("wireguard");
expect(parsed.provider.name).toBe("mullvad");
});
test("vpn settings schema accepts nullable server-selection arrays", () => {
const payloadWithNulls = {
...VPN_SETTINGS_PAYLOAD,
provider: {
...VPN_SETTINGS_PAYLOAD.provider,
server_selection: {
...VPN_SETTINGS_PAYLOAD.provider.server_selection,
countries: null,
cities: null,
},
},
};
expect(() => gluetunVpnSettingsSchema.parse(payloadWithNulls)).not.toThrow();
});
test("vpn settings schema rejects a non-string type", () => {
expect(() => gluetunVpnSettingsSchema.parse({ ...VPN_SETTINGS_PAYLOAD, type: 1 })).toThrow();
});
});
@@ -15,9 +15,9 @@ export class GotifyIntegration extends Integration implements INotificationsInte
}
private getHeaders() {
const credentials = Buffer.from(
`${super.getSecretValue("username")}:${super.getSecretValue("password")}`,
).toString("base64");
const credentials = Buffer.from(`${super.getSecretValue("username")}:${super.getSecretValue("password")}`).toString(
"base64",
);
return { Authorization: `Basic ${credentials}` };
}
@@ -31,11 +31,13 @@ export class GotifyIntegration extends Integration implements INotificationsInte
if (!result.success) throw new Error(`Failed to parse Gotify response: ${result.error.message}`);
const parsed = result.data;
return parsed.messages.map((message): Notification => ({
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}));
return parsed.messages.map(
(message): Notification => ({
id: String(message.id),
time: new Date(message.date),
title: message.title,
body: message.message,
}),
);
}
}
@@ -0,0 +1,5 @@
import type { VpnSummary } from "./vpn-summary-types";
export interface VpnSummaryIntegration {
getSummaryAsync(): Promise<VpnSummary>;
}
@@ -0,0 +1,13 @@
export interface VpnProviderDetails {
protocol: string;
provider: string;
}
export interface VpnSummary {
vpnStatus: string;
dnsStatus: string;
publicIp: string;
country: string;
city: string;
vpnProvider: VpnProviderDetails;
}
@@ -0,0 +1,200 @@
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import { TestConnectionError } from "../base/test-connection/test-connection-error";
import type { TestingResult } from "../base/test-connection/test-connection-service";
import type { IMediaTranscodingIntegration } from "../interfaces/media-transcoding/media-transcoding-integration";
import type { TdarrQueue, TdarrStatistics, TdarrWorker } from "../interfaces/media-transcoding/media-transcoding-types";
import {
libraryStatusSchema,
recentlyFinishedSchema,
shrinkageGroupsSchema,
statusResponseSchema,
upcomingFilesSchema,
workersResponseSchema,
} from "./fileflows-validation-schemas";
export class FileFlowsIntegration extends Integration implements IMediaTranscodingIntegration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const response = await input.fetchAsync(this.url("/api/system/version"), {
method: "GET",
headers: {
accept: "application/json",
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
},
});
if (!response.ok) return TestConnectionError.StatusResult(response);
return { success: true };
}
public async getStatisticsAsync(): Promise<TdarrStatistics> {
const headerParams = {
accept: "application/json",
"Content-Type": "application/json",
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const [statusData, libraryStatusData, shrinkageData] = await Promise.all([
this.fetchStatusAsync(headerParams),
this.fetchLibraryStatusAsync(headerParams),
this.fetchShrinkageAsync(headerParams),
]);
const failedCount = libraryStatusData
.filter((item) => item.Status === 4)
.reduce((sum, item) => sum + item.Count, 0);
const processedCount = libraryStatusData
.filter((item) => item.Status === 3)
.reduce((sum, item) => sum + item.Count, 0);
const queuedCount = libraryStatusData
.filter((item) => item.Status === 1 || item.Status === 5)
.reduce((sum, item) => sum + item.Count, 0);
const totalSavedSpace = Object.values(shrinkageData).reduce(
(sum, group) => sum + Math.max(0, group.OriginalSize - group.FinalSize),
0,
);
const transcodeStatus = [
{ name: "Processed", value: statusData.processed },
{ name: "Queued", value: statusData.queue },
{ name: "Processing", value: statusData.processing },
{ name: "Failed", value: failedCount },
];
return {
libraryName: "All",
totalFileCount: processedCount + queuedCount + statusData.processing + failedCount,
totalTranscodeCount: statusData.processed,
totalHealthCheckCount: 0,
failedTranscodeCount: failedCount,
failedHealthCheckCount: 0,
stagedTranscodeCount: statusData.queue,
stagedHealthCheckCount: 0,
totalSavedSpace,
transcodeStatus,
healthCheckStatus: [],
videoCodecs: [],
videoContainers: [],
videoResolutions: [],
audioCodecs: [],
audioContainers: [],
};
}
public async getWorkersAsync(): Promise<TdarrWorker[]> {
const headerParams = {
"Content-Type": "application/json",
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const url = this.url("/api/worker");
const response = await fetchWithTrustedCertificatesAsync(url, {
method: "GET",
headers: headerParams,
});
const workersData = await workersResponseSchema.parseAsync(await response.json());
return workersData.map((worker) => ({
id: worker.Uid,
filePath: worker.RelativeFile ?? "",
fps: 0,
percentage: worker.CurrentPartPercent,
ETA: worker.ProcessingTime?.TotalSeconds ? `${Math.round(worker.ProcessingTime.TotalSeconds)}s` : "",
jobType: worker.CurrentPartName ?? "Processing",
status: "Processing",
step: worker.CurrentPartName ?? "",
originalSize: worker.LibraryFile?.OriginalSize ?? 0,
estimatedSize: null,
outputSize: null,
}));
}
public async getQueueAsync(firstItemIndex: number, pageSize: number): Promise<TdarrQueue> {
const headerParams = {
"Content-Type": "application/json",
...(super.hasSecretValue("apiKey") ? { "X-Api-Key": super.getSecretValue("apiKey") } : {}),
};
const [upcomingItems, recentlyFinishedItems] = await Promise.all([
this.fetchUpcomingAsync(headerParams),
this.fetchRecentlyFinishedAsync(headerParams),
]);
const queueArray = [
...upcomingItems.map((item) => ({
id: item.Uid,
healthCheck: "",
transcode: item.Flow ?? "Default",
filePath: item.RelativePath ?? item.Name ?? "",
fileSize: item.OriginalSize ?? 0,
container: "",
codec: "",
resolution: "",
type: "transcode" as const,
})),
...recentlyFinishedItems.map((item) => ({
id: item.Uid,
healthCheck: "",
transcode: item.Flow ?? "Default",
filePath: item.RelativePath ?? item.Name ?? "",
fileSize: item.FinalSize ?? item.OriginalSize ?? 0,
container: "",
codec: "",
resolution: "",
type: "health-check" as const,
})),
].slice(0, pageSize);
return {
array: queueArray,
totalCount: upcomingItems.length + recentlyFinishedItems.length,
startIndex: firstItemIndex,
endIndex: firstItemIndex + queueArray.length - 1,
};
}
private async fetchStatusAsync(headerParams: Record<string, string>) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/status"), {
method: "GET",
headers: headerParams,
});
return statusResponseSchema.parseAsync(await response.json());
}
private async fetchLibraryStatusAsync(headerParams: Record<string, string>) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/status"), {
method: "GET",
headers: headerParams,
});
return libraryStatusSchema.parseAsync(await response.json());
}
private async fetchShrinkageAsync(headerParams: Record<string, string>) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/shrinkage-groups"), {
method: "GET",
headers: headerParams,
});
return shrinkageGroupsSchema.parseAsync(await response.json());
}
private async fetchUpcomingAsync(headerParams: Record<string, string>) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/upcoming"), {
method: "GET",
headers: headerParams,
});
return upcomingFilesSchema.parseAsync(await response.json());
}
private async fetchRecentlyFinishedAsync(headerParams: Record<string, string>) {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/library-file/recently-finished"), {
method: "GET",
headers: headerParams,
});
return recentlyFinishedSchema.parseAsync(await response.json());
}
}
@@ -0,0 +1,98 @@
import { z } from "zod/v4";
export const statusResponseSchema = z.object({
queue: z.number(),
processing: z.number(),
processed: z.number(),
processingFiles: z
.array(
z.object({
name: z.string().nullable(),
relativePath: z.string().nullable(),
library: z.string().nullable(),
step: z.string().nullable(),
stepPercent: z.number(),
}),
)
.optional()
.transform((arr) => arr ?? []),
});
export const libraryStatusSchema = z.array(
z.object({
Name: z.string().nullable(),
Status: z.number(),
Count: z.number(),
}),
);
export const shrinkageGroupsSchema = z.record(
z.string(),
z.object({
Library: z.string().nullable(),
OriginalSize: z.number(),
FinalSize: z.number(),
Items: z.number(),
Difference: z.number().optional(),
}),
);
export const workersResponseSchema = z.array(
z.object({
Uid: z.string(),
NodeName: z.string().nullable(),
RelativeFile: z.string().nullable(),
CurrentPart: z.number(),
CurrentPartName: z.string().nullable(),
CurrentPartPercent: z.number(),
StartedAt: z.string(),
LastUpdate: z.string(),
ProcessingTime: z
.object({
TotalSeconds: z.number().optional(),
})
.optional(),
LibraryFile: z
.object({
OriginalSize: z.number(),
})
.optional(),
Library: z
.object({
Name: z.string().nullable(),
})
.optional(),
}),
);
export const upcomingFilesSchema = z.array(
z.object({
Uid: z.string(),
Name: z.string().nullable(),
RelativePath: z.string().nullable(),
OriginalSize: z.number().nullable(),
FinalSize: z.number().nullable(),
Status: z.number(),
Library: z.string().nullable(),
Flow: z.string().nullable(),
Node: z.string().nullable(),
ProcessingTime: z
.object({
TotalSeconds: z.number().optional(),
})
.optional(),
}),
);
export const recentlyFinishedSchema = z.array(
z.object({
Uid: z.string(),
Name: z.string().nullable(),
RelativePath: z.string().nullable(),
OriginalSize: z.number().nullable(),
FinalSize: z.number().nullable(),
Status: z.number(),
Library: z.string().nullable(),
Flow: z.string().nullable(),
}),
);
@@ -127,11 +127,18 @@ export class PlexIntegration extends Integration implements IMediaServerIntegrat
const media =
data.MediaContainer.Metadata?.filter((item) => item.Image).map((item) => {
const title =
item.type === "episode"
? (item.grandparentTitle ?? item.title)
: item.type === "season"
? (item.parentTitle ?? item.title)
: item.title;
return {
id: item.Media?.at(0)?.id.toString() ?? item.key,
type: mapType(item.type),
title: item.title,
subtitle: item.tagline,
title,
subtitle: title === item.title ? item.tagline : item.title,
description: item.summary,
releaseDate: item.originallyAvailableAt
? new Date(item.originallyAvailableAt)
@@ -222,6 +229,8 @@ const recentlyAddedSchema = z.object({
studio: z.string().optional(),
type: z.string(), // For example "movie", "album"
title: z.string(),
parentTitle: z.string().optional(),
grandparentTitle: z.string().optional(),
summary: z.string().optional(),
duration: z.number().optional(),
addedAt: z.number(),
@@ -0,0 +1,140 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../base/types";
import { PlexIntegration } from "../plex-integration";
const imageProxyMocks = vi.hoisted(() => ({
createImageAsync: vi.fn((url: string) => Promise.resolve(`proxied:${url}`)),
}));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
vi.mock("@homarr/image-proxy", () => ({
ImageProxy: class {
createImageAsync = imageProxyMocks.createImageAsync;
},
}));
const TEST_URL = "https://plex.example.com";
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "plex-token" }];
const createIntegration = () =>
new PlexIntegration({
id: "test-plex",
name: "Test Plex",
url: TEST_URL,
externalUrl: null,
decryptedSecrets: secrets,
});
const createMetadataItem = (key: string, overrides: Record<string, unknown>) => ({
key: `/library/metadata/${key}`,
type: "movie",
title: "Movie",
addedAt: 1_735_689_600,
Image: [
{
type: "coverPoster",
url: `/library/metadata/${key}/thumb`,
},
],
...overrides,
});
beforeEach(() => {
mockFetch.mockReset();
imageProxyMocks.createImageAsync.mockClear();
});
describe("PlexIntegration.getMediaReleasesAsync", () => {
test("uses the series title for TV seasons and episodes", async () => {
mockFetch.mockImplementation((url) => {
const urlString = String(url);
if (urlString.endsWith("/identity")) {
return Promise.resolve(
new Response(JSON.stringify({ MediaContainer: { machineIdentifier: "server-id" } }), { status: 200 }),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
if (urlString.endsWith("/library/recentlyAdded")) {
return Promise.resolve(
new Response(
JSON.stringify({
MediaContainer: {
Metadata: [
createMetadataItem("season", {
type: "season",
title: "Season 1",
parentTitle: "Breaking Bad",
}),
createMetadataItem("episode", {
type: "episode",
title: "Review",
parentTitle: "Season 1",
grandparentTitle: "The Bear",
}),
createMetadataItem("movie", {
type: "movie",
title: "Inception",
tagline: "Dreams feel real",
}),
createMetadataItem("show", {
type: "show",
title: "The Last of Us",
tagline: "A series tagline",
}),
createMetadataItem("missing-parent", {
type: "season",
title: "Season 3",
}),
],
},
}),
{ status: 200 },
),
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
}
throw new Error(`Unexpected Plex request: ${urlString}`);
});
const releases = await createIntegration().getMediaReleasesAsync();
expect(releases).toMatchObject([
{
title: "Breaking Bad",
subtitle: "Season 1",
type: "tv",
},
{
title: "The Bear",
subtitle: "Review",
type: "tv",
},
{
title: "Inception",
subtitle: "Dreams feel real",
type: "movie",
},
{
title: "The Last of Us",
subtitle: "A series tagline",
type: "tv",
},
{
title: "Season 3",
subtitle: undefined,
type: "tv",
},
]);
});
});
@@ -132,8 +132,9 @@ const happyResponder = (method: string) => {
aggregations: emptyAggregations(),
start: 0,
end: 1,
legend: [],
data: [[1000, 8_000_000_000]],
// The "memory" graph reports available (free) memory: legend is ["time", "available"].
legend: ["time", "available"],
data: [[1000, 6_000_000_000]],
},
{
name: "cputemp",
@@ -194,15 +195,17 @@ describe("TrueNasIntegration", () => {
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
cpuTemp: 45,
memAvailableInBytes: 16_000_000_000,
memUsedInBytes: 8_000_000_000,
// physmem (16 GB) minus the reported available 6 GB leaves 10 GB used.
memAvailableInBytes: 6_000_000_000,
memUsedInBytes: 10_000_000_000,
uptime: 3600,
rebootRequired: false,
availablePkgUpdates: 0,
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
fileSystem: [{ deviceName: "tank", available: "1000", used: "500", percentage: 50 }],
// `available` is the free space left on the pool (free = 500), not its total size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
@@ -37,14 +37,19 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
// The "memory" reporting graph reports available (free) memory, its legend is ["time", "available"].
// Used memory is therefore the physical total minus what is still available (clamped against timing skew).
const memAvailableInBytes = memoryData[1] ?? 0; // Index 0 is the UNIX timestamp, Index 1 is available bytes
const memUsedInBytes = Math.max(systemInformation.physmem - memAvailableInBytes, 0);
return {
cpuUtilization: cpuData.reduce((acc, item) => acc + (item > 100 ? 0 : item), 0) / cpuData.length,
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes: systemInformation.physmem,
memUsedInBytes: memoryData[1] ?? 0, // Index 0 is UNIX timestamp, Index 1 is free space in bytes
memAvailableInBytes,
memUsedInBytes,
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.size}`, // TODO: can we use number instead of string here?
available: `${dataset.free}`, // free space left on the pool
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
+2
View File
@@ -4,6 +4,7 @@ export * from "./interfaces/network-controller-summary/network-controller-summar
export * from "./interfaces/firewall-summary/firewall-summary-types";
export * from "./interfaces/health-monitoring/health-monitoring-types";
export * from "./interfaces/ups-summary/ups-summary-types";
export * from "./interfaces/vpn-summary/vpn-summary-types";
export * from "./interfaces/indexer-manager/indexer-manager-types";
export * from "./interfaces/media-requests/media-request-types";
export * from "./base/searchable-integration";
@@ -22,4 +23,5 @@ export * from "./audiobookshelf/audiobookshelf-types";
export * from "./navidrome/navidrome-types";
export * from "./paperless-ngx/paperless-ngx-types";
export * from "./umami/umami-types";
export * from "./gluetun/gluetun-types";
export * from "./beszel/beszel-types";
@@ -59,8 +59,8 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
cpuTemp: undefined, // Not implemented, see https://github.com/unraid/api/issues/1597
fileSystem: systemInfo.array.disks.map((disk) => ({
deviceName: disk.name,
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), covert to bytes
available: `${disk.size * 1024}`, // API is in KiB (kibibytes), covert to bytes
used: humanFileSize(disk.fsUsed * 1024), // API is in KiB (kibibytes), convert to bytes
available: `${(disk.size - disk.fsUsed) * 1024}`, // free space left on the disk, API is in KiB (kibibytes)
percentage: (disk.fsUsed / disk.size) * 100, // The units are the same, therefore the actual unit is irrelevant
})),
smart: systemInfo.array.disks.map((disk) => ({
+158 -211
View File
@@ -2,15 +2,7 @@
import type { StartedTestContainer } from "testcontainers";
import { GenericContainer, Wait } from "testcontainers";
import {
beforeAll,
beforeEach,
afterAll,
describe,
expect,
test,
vi,
} from "vitest";
import { beforeAll, beforeEach, afterAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
@@ -31,15 +23,10 @@ vi.mock("@homarr/db", async (importActual) => {
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual =
await importActual<
typeof import("@homarr/core/infrastructure/certificates")
>();
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
return {
...actual,
getTrustedCertificateHostnamesAsync: vi
.fn()
.mockImplementation(() => Promise.resolve([])),
getTrustedCertificateHostnamesAsync: vi.fn().mockImplementation(() => Promise.resolve([])),
};
});
@@ -90,9 +77,7 @@ const INTEGRATION_ID = "test-technitium";
const makeInput = (
baseUrl: string,
auth:
| { kind: "credentials"; password?: string }
| { kind: "apiKey"; value: string },
auth: { kind: "credentials"; password?: string } | { kind: "apiKey"; value: string },
): IntegrationInput => ({
id: INTEGRATION_ID,
name: "Technitium DNS",
@@ -109,10 +94,7 @@ const makeInput = (
// Fetch an API token appropriate for the server version.
// v15 has a dedicated createToken endpoint; legacy reuses the session token from login.
async function fetchApiToken(
baseUrl: string,
apiVersion: TechnitiumVersion,
): Promise<string> {
async function fetchApiToken(baseUrl: string, apiVersion: TechnitiumVersion): Promise<string> {
if (apiVersion === "v15") {
// v15 exposes a dedicated endpoint for non-expiring API tokens.
const res = await fetch(
@@ -122,9 +104,7 @@ async function fetchApiToken(
}
// Legacy: /api/user/login exists on all tested versions (v11–v14) and returns a session
// token that can be used as a long-lived API key via the ?token= query parameter.
const res = await fetch(
`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`,
);
const res = await fetch(`${baseUrl}/api/user/login?user=${DEFAULT_USERNAME}&pass=${DEFAULT_PASSWORD}`);
return ((await res.json()) as { token: string }).token;
}
@@ -144,16 +124,12 @@ const pool: Record<string, ContainerEntry> = {};
beforeAll(async () => {
await Promise.all(
VERSIONS.map(async ({ tag, apiVersion }) => {
const container = await new GenericContainer(
`technitium/dns-server:${tag}`,
)
const container = await new GenericContainer(`technitium/dns-server:${tag}`)
.withExposedPorts(5380)
// Explicitly set the admin password so all versions use the same credentials.
// Without this, older versions may generate a random password on first run.
.withEnvironment({ DNS_SERVER_ADMIN_PASSWORD: DEFAULT_PASSWORD })
.withWaitStrategy(
Wait.forLogMessage("Technitium DNS Server was started successfully."),
)
.withWaitStrategy(Wait.forLogMessage("Technitium DNS Server was started successfully."))
.start();
const baseUrl = `http://${container.getHost()}:${container.getMappedPort(5380)}`;
const apiToken = await fetchApiToken(baseUrl, apiVersion);
@@ -163,238 +139,209 @@ beforeAll(async () => {
}, 120_000);
afterAll(async () => {
await Promise.all(
Object.values(pool).map(({ container }) => container.stop()),
);
await Promise.all(Object.values(pool).map(({ container }) => container.stop()));
});
// ─── version-parameterised suites ────────────────────────────────────────────
describe.each(VERSIONS)(
"Technitium DNS $tag ($apiVersion)",
({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
describe.each(VERSIONS)("Technitium DNS $tag ($apiVersion)", ({ tag, apiVersion }) => {
let baseUrl: string;
let apiToken: string;
beforeAll(() => {
const entry = pool[tag];
if (!entry)
throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeAll(() => {
const entry = pool[tag];
if (!entry) throw new Error(`Container pool entry for ${tag} was not initialized`);
({ baseUrl, apiToken } = entry);
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
beforeEach(() => {
sessionState[INTEGRATION_ID] = null;
});
// ── factory ────────────────────────────────────────────────────────────────
// ── factory ────────────────────────────────────────────────────────────────
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("createTechnitiumDnsIntegrationAsync (factory)", () => {
test("factory creates a working integration (auto-detects version)", async () => {
const integration = await createTechnitiumDnsIntegrationAsync(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── getSummaryAsync ────────────────────────────────────────────────────────
// ── getSummaryAsync ────────────────────────────────────────────────────────
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("getSummaryAsync", () => {
test("returns valid summary with credentials", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedToday).toBeGreaterThanOrEqual(0);
expect(result.adsBlockedTodayPercentage).toBeGreaterThanOrEqual(0);
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
expect(result.status).toBe("enabled");
}, 20_000);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
test("returns valid summary with API key", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
expect(result.status).toBe("enabled");
expect(result.dnsQueriesToday).toBeGreaterThanOrEqual(0);
}, 20_000);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("domainsBeingBlocked is a non-negative number (blockedZones + blockListZones)", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
expect(typeof result.domainsBeingBlocked).toBe("number");
expect(result.domainsBeingBlocked).toBeGreaterThanOrEqual(0);
}, 20_000);
});
// ── session caching ────────────────────────────────────────────────────────
// ── session caching ────────────────────────────────────────────────────────
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("session caching", () => {
test("login is performed once and token is cached for subsequent calls", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterFirst = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterFirst).toBeDefined();
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
await integration.getSummaryAsync();
const tokenAfterSecond = sessionState[INTEGRATION_ID]?.token;
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
expect(tokenAfterSecond).toBe(tokenAfterFirst);
}, 20_000);
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
test("recovers from an expired/invalid stored token by re-logging in", async () => {
// Plant a bad token with the correct version so it hits the right API path before failing
sessionState[INTEGRATION_ID] = {
token: "deliberately-invalid-token",
version: apiVersion,
};
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
apiVersion,
);
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }), apiVersion);
const result = await integration.getSummaryAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe(
"deliberately-invalid-token",
);
}, 20_000);
});
expect(result.status).toBe("enabled");
expect(sessionState[INTEGRATION_ID]).not.toBeNull();
expect(sessionState[INTEGRATION_ID]?.token).not.toBe("deliberately-invalid-token");
}, 20_000);
});
// ── blocking state changes ─────────────────────────────────────────────────
// ── blocking state changes ─────────────────────────────────────────────────
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
describe("blocking state changes", () => {
test("disableAsync permanently disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync re-enables blocking after permanent disable", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync();
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
expect(result.status).toBe("enabled");
}, 20_000);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with duration temporarily disables blocking", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
await integration.disableAsync(120); // 120s → 2 min
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("disableAsync with small duration (1s) rounds up to 1 minute and disables", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
await integration.disableAsync(1);
const result = await integration.getSummaryAsync();
expect(result.status).toBe("disabled");
expect(result.status).toBe("disabled");
await integration.enableAsync(); // restore
}, 20_000);
await integration.enableAsync(); // restore
}, 20_000);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
);
test("enableAsync cancels an active timed disable immediately", async () => {
const integration = new TechnitiumDnsIntegration(makeInput(baseUrl, { kind: "credentials" }));
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
await integration.disableAsync(600); // 10 minutes
await integration.enableAsync();
const result = await integration.getSummaryAsync();
expect(result.status).toBe("enabled");
}, 20_000);
});
expect(result.status).toBe("enabled");
}, 20_000);
});
// ── testConnectionAsync ────────────────────────────────────────────────────
// ── testConnectionAsync ────────────────────────────────────────────────────
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
describe("testConnectionAsync", () => {
test("succeeds with valid credentials", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "credentials" }),
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("succeeds with valid API key", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: apiToken }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(true);
}, 20_000);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
test("fails with wrong password — returns authorization error", async () => {
const integration = new TechnitiumDnsIntegration(
makeInput(baseUrl, {
kind: "credentials",
password: "wrong-password",
}),
);
const result = await integration.testConnectionAsync();
const result = await integration.testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
test("fails with invalid API key — returns authorization error", async () => {
const result = await new TechnitiumDnsIntegration(
makeInput(baseUrl, { kind: "apiKey", value: "invalid-api-key-xyz" }),
apiVersion,
).testConnectionAsync();
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
},
);
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error).toBeInstanceOf(TestConnectionError);
expect(result.error.type).toBe("authorization");
}, 20_000);
});
});
+1
View File
@@ -15,6 +15,7 @@ export {
createGetSetChannel,
createQueryCacheChannel,
getAllQueryCacheAsync,
invalidateIntegrationCacheAsync,
} from "./lib/channel";
export { createIntegrationHistoryChannel } from "./lib/channels/history-channel";
+42 -2
View File
@@ -261,6 +261,36 @@ export const createIntegrationOptionsChannel = <TData>(
return createChannelWithLatestAndEvents<TData>(channelName);
};
/**
* Invalidates all cached data for a given integration by deleting every Redis key
* that starts with `integration:${integrationId}:` as well as the integration's
* session store entry. This ensures that stale data from a misconfigured or
* updated integration is never served from cache.
*/
export const invalidateIntegrationCacheAsync = async (integrationId: string): Promise<void> => {
const client = getSetClient as typeof getSetClient | null;
if (!client) {
return;
}
const patterns = [`integration:${integrationId}:*`, `session-store:${integrationId}`];
let deletedCount = 0;
for (const pattern of patterns) {
let cursor = "0";
do {
const [nextCursor, keys] = await client.scan(cursor, "MATCH", pattern, "COUNT", 200);
cursor = nextCursor;
if (keys.length > 0) {
await client.del(...keys);
deletedCount += keys.length;
}
} while (cursor !== "0");
}
if (deletedCount > 0) {
logger.info("Invalidated integration cache", { integrationId, deletedKeys: deletedCount });
}
};
export const createWidgetOptionsChannel = <TData>(
widgetKind: WidgetKind,
queryKey: string,
@@ -385,8 +415,18 @@ export const createChannelWithLatestAndEvents = <TData>(channelName: string) =>
});
},
publishAndUpdateLastStateAsync: async (data: TData) => {
await publisher.publish(channelName, superjson.stringify(data));
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
// Set first, then publish. If publish fails the storage is still consistent
// and subscribers will catch up on the next event.
const payload = superjson.stringify({ data, timestamp: new Date() });
await getSetClient.set(channelName, payload);
try {
await publisher.publish(channelName, superjson.stringify(data));
} catch (error) {
logger.warn("Failed to publish cache update (storage is correct, subscribers will catch up)", {
channel: channelName,
error: error instanceof Error ? error.message : String(error),
});
}
},
setAsync: async (data: TData) => {
await getSetClient.set(channelName, superjson.stringify({ data, timestamp: new Date() }));
+42 -16
View File
@@ -1,5 +1,6 @@
import dayjs from "dayjs";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { createIntegrationAsync } from "@homarr/integrations";
import type {
BeszelAlert,
@@ -13,6 +14,8 @@ import type {
import { createCachedIntegrationRequestHandler } from "./lib/cached-integration-request-handler";
const logger = createLogger({ module: "beszelRequestHandler" });
export type { BeszelSystemRow } from "@homarr/integrations/types";
/**
@@ -56,6 +59,7 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
Record<string, never>
>({
async requestAsync(integration) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
const systems = await instance.getSystemsAsync();
const enriched = await Promise.all(
@@ -64,9 +68,16 @@ export const beszelSystemsRequestHandler = createCachedIntegrationRequestHandler
return mapToSystemRow(system, details);
}),
);
logger.debug("beszelSystems fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
count: enriched.length,
});
return enriched;
},
cacheDuration: dayjs.duration(5, "seconds"),
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelSystems",
});
@@ -81,22 +92,31 @@ export const beszelAlertsRequestHandler = createCachedIntegrationRequestHandler<
{ includeHistory: boolean; maxHistoryItems: number }
>({
async requestAsync(integration, input) {
const start = performance.now();
const instance = await createIntegrationAsync(integration);
const alerts = await instance.getAlertsAsync();
const history = input.includeHistory ? await instance.getAlertHistoryAsync(undefined, input.maxHistoryItems) : [];
logger.debug("beszelAlerts fetch completed", {
integrationId: integration.id,
durationMs: Math.round(performance.now() - start),
alertsCount: alerts.length,
historyCount: history.length,
});
return { alerts, history };
},
cacheDuration: dayjs.duration(15, "seconds"),
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelAlerts",
});
const timePeriodConfig: Record<string, { type: string; perPage: number; cacheSeconds: number }> = {
"1m": { type: "1m", perPage: 60, cacheSeconds: 5 },
"1h": { type: "1m", perPage: 60, cacheSeconds: 60 },
"12h": { type: "10m", perPage: 72, cacheSeconds: 300 },
"24h": { type: "20m", perPage: 72, cacheSeconds: 600 },
"1w": { type: "120m", perPage: 84, cacheSeconds: 1800 },
"30d": { type: "480m", perPage: 90, cacheSeconds: 3600 },
const timePeriodConfig: Record<string, { type: string; perPage: number }> = {
"1m": { type: "1m", perPage: 60 },
"1h": { type: "1m", perPage: 60 },
"12h": { type: "10m", perPage: 72 },
"24h": { type: "20m", perPage: 72 },
"1w": { type: "480m", perPage: 21 },
"30d": { type: "480m", perPage: 90 },
};
export interface BeszelStatsData {
@@ -110,19 +130,25 @@ export const beszelStatsRequestHandler = createCachedIntegrationRequestHandler<
{ systemId: string; timePeriod: string; includeDocker: boolean }
>({
async requestAsync(integration, input) {
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60, cacheSeconds: 60 };
const start = performance.now();
const config = timePeriodConfig[input.timePeriod] ?? { type: "1m", perPage: 60 };
const instance = await createIntegrationAsync(integration);
const systemStats = await instance.getSystemStatsAsync(input.systemId, config.type, config.perPage);
const containerStats = input.includeDocker
? await instance.getContainerStatsAsync(input.systemId, config.type, config.perPage).catch(() => [])
: [];
logger.debug("beszelStats fetch completed", {
integrationId: integration.id,
systemId: input.systemId,
timePeriod: input.timePeriod,
durationMs: Math.round(performance.now() - start),
systemStatsCount: systemStats.length,
containerStatsCount: containerStats.length,
});
return { systemStats, containerStats };
},
cacheDuration: dayjs.duration(60, "seconds"),
cacheDuration: dayjs.duration(1, "second"),
fallbackToStaleOnError: true,
retry: { attempts: 2, delayMs: 500 },
queryKey: "beszelStats",
cacheDurationForInput(input) {
const config = timePeriodConfig[input.timePeriod];
if (!config) return undefined;
return dayjs.duration(config.cacheSeconds, "seconds");
},
});
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more