Compare commits

...
Author SHA1 Message Date
ajnart 411e75e799 fix(cli): make demo mode writable 2026-08-27 22:41:23 +02:00
Thomas 0553d60a58 fix: prevent widget move registry crash (#6717) 2026-08-27 14:40:00 +02:00
Thomas 7da2e10064 fix: address release v2 review findings (#6708) 2026-08-27 13:59:42 +02:00
Thomas a002b91add feat: reduce disruptive UI confirmations (#6688)
* feat: reduce disruptive UI confirmations

* fix: keep destructive confirmations red
2026-08-27 12:01:36 +02:00
Thomas 9c441571bc fix: improve mobile header and board menus (#6715)
* fix: improve mobile header and board menus

* fix: keep mobile header logo condensed

* refactor: share mobile breakpoint handling
2026-08-27 11:08:27 +02:00
Thomas 643ecf3df7 fix(widgets): restore compact v1 surfaces (#6710) 2026-08-27 11:08:17 +02:00
Bergasha 1b2f1bd720 Merge pull request #6714 from homarr-labs/fix/v2-container-scale-fix
fix(V2 container): widgets clipping and label overlap
2026-08-27 14:39:16 +10:00
Bergasha c82965067c Floor containerContentScale above zero
logicalWidth/viewportHeight can go non-positive for a narrow container
on a heavily zoomed-out board, where outerCardInset (which grows as
canvasScale shrinks) can exceed the container's own un-inset size.
That non-positive scale then feeds into a division (Infinity/NaN into
--board-canvas-ui-scale) and is used as zoom directly (invalid or
collapsed content) - flooring it well above zero avoids both.
2026-08-27 14:17:17 +10:00
Bergasha 8abc9cae6c Fix unused logicalHeight variable left over from CodeRabbit fix
Removing it in favor of viewportHeight for the vertical fit ratio left
the declaration itself dead code, which the CI lint step flags as an
error rather than a warning.
2026-08-27 13:56:45 +10:00
Bergasha becc6a71a9 Address CodeRabbit review on container scale fix PR
- Guard canvasScale before dividing by it in outerCardInset, matching
  calculateBoardUiScale's own fallback.
- Fix containerContentScale's vertical fit ratio for scrollable
  containers: it was derived from the full (all-rows) content height,
  which drifts toward 1 as more rows are added regardless of the fixed
  inset, under-scaling relative to what the visible viewport actually
  needs and clipping the bottom of the visible rows. Use
  viewportHeight/viewportRowCount instead, which describe the real
  visible card size in both the scrollable and non-scrollable case.
2026-08-27 13:46:34 +10:00
Bergasha 78a4f0e513 Fix container widgets clipping and label overlap
A container's own visible card is inset from its allocated board cell
by the board's standard per-item gap, but items inside it are
positioned in a coordinate space sized to the un-inset column/row
count - so without correcting for that, items (not just the grid's
own box) spilled past the container's right/bottom edge by that
inset amount, worse the busier/more zoomed-out the board is.

Scales the container's inner grid content down to fit the actual card
size, using the same zoom mechanism the board's own top-level canvas
uses for its whole-board zoom, just one level deeper. Corrects
--board-canvas-ui-scale to compensate so icon/text/custom-CSS sizing
inside the container isn't affected - computed as a plain JS number
rather than a CSS calc() referencing the property's own inherited
value, since custom properties don't have a sequential/temporal order
within the same element and doing so is a circular reference that
silently invalidates the whole property for every descendant.

Also fixes the collapsible container's label bar poking above the
card's rounded top border via a negative offset - it now sits flush
inside it.
2026-08-27 13:34:57 +10:00
Bergasha 70e1b29913 Merge pull request #6713 from homarr-labs/fix/v2-system-resource-widget
fix(v2 system resources widget): overflow and initial chart render
2026-08-27 11:09:18 +10:00
Bergasha 73de83b48f Address CodeRabbit review on system resources widget PR
- Render the chart tooltip via a portal so it isn't clipped by the
  card's overflow:hidden (needed to stop the chart line itself
  bleeding past its rounded corners).
- Move chartColumns' width breakpoints into a lookup table and
  memoize the computed value, instead of an inline ternary.
2026-08-27 10:46:04 +10:00
Bergasha 26d12d9fad Fix system resources widget overflow and initial chart flicker
The chart card and its outer widget shell both had overflow:visible,
letting chart lines bleed past their rounded borders into neighboring
widgets. Compact-mode charts also stretched to fill the widget's full
resized height instead of staying compact like other widgets.

Additionally, the history used to build the chart needs 2+ points to
render a line - it started empty and only got its first real point on
the next poll (up to 30s later), so the chart would flash between a
single-value fallback and the actual graph. Both the initial render
and the first state update now seed with the current value duplicated,
so a flat line renders immediately with no flicker.
2026-08-27 09:07:05 +10:00
Thomas f0eddda573 fix: preserve assistant CSS and daily quota history (#6705)
* fix: route custom css through review form

* feat(workshop): retain daily assistant quotas
2026-08-26 14:48:08 +02:00
Thomas b2c72d54c2 fix: refine configurable header defaults (#6707) 2026-08-26 14:29:51 +02:00
Thomas a56ec5d06a feat: support every widget in the demo dashboard (#6696) 2026-08-26 08:44:55 +02:00
Bergasha 40e34181e7 fix(board-v2): icon and tiny text fix (#6704) 2026-08-26 08:44:36 +02:00
Thomas 6548da3772 fix: stabilize e2e login hydration (#6706) 2026-08-25 22:08:52 +02:00
Thomas 3ee9b4f0c4 feat(custom-widgets): harden AI authoring (credential redaction, JSX policy) (#6700) 2026-08-25 17:11:24 +02:00
Thomas 9303dbd284 feat: add configurable headers and instance branding (#6690) 2026-08-25 17:09:47 +02:00
Thomas 97d8390b20 feat: rebuild command menu (#6692) 2026-08-24 15:40:09 +02:00
Thomas c9a48fa23f fix: polish login backdrop and wordmark (#6687) 2026-08-24 12:26:19 +02:00
Thomas e7c8e2e01e refactor(custom-widgets): simplify authoring prompt (#6695) 2026-08-24 09:24:53 +02:00
Thomas fcca57ddf7 feat(custom-widgets): improve authoring workbench (#6689)
* feat(custom-widgets): improve authoring workbench

* fix(custom-widgets): address review and CI

* refactor(custom-widgets): satisfy architecture checks

* fix(custom-widgets): isolate workbench typing updates

* fix(custom-widgets): harden workbench state
2026-08-24 08:55:00 +02:00
Bergasha 8b6387f321 Merge pull request #6693 from homarr-labs/fix/v2-media-server
fix(mediaServer): use proportional column widths
2026-08-24 11:42:49 +10:00
Bergasha b8bc304c5c fix(mediaServer): use proportional column widths, populate Emby playback/transcoding metadata
The User and Status columns used hardcoded pixel widths with table-layout:
fixed, so they stayed narrow regardless of how wide the widget actually
rendered - text truncated even with plenty of unused space in the
"currently playing" column. Switch both to percentages so they scale with
the widget.

Emby's integration also hardcoded playback/metadata to null for every
session, so the widget's existing progress bar, resolution, and bitrate
display never had anything to show. Port the same TranscodingInfo/
MediaStreams parsing Jellyfin already does (same underlying /Sessions API
shape) so Emby sessions get real position/duration, resolution, bitrate,
and transcode-status data. Also fix ticksToMs treating a position of
exactly 0 as missing, and share the one implementation between the two
integrations instead of duplicating it.
2026-08-24 11:16:20 +10:00
Thomas ec295fe26e Merge pull request #6684 from homarr-labs/feat/integration-card-header
fix(ui): prevent integration card title wrapping
2026-08-23 14:14:57 +02:00
ajnart e56112cfd7 fix(ui): prevent integration card title wrapping 2026-08-23 09:21:48 +02:00
Thomas 449adc5b12 Merge pull request #6669 from homarr-labs/feat/instant-widget-reloads
perf: persist widget data across reloads
2026-08-23 09:06:00 +02:00
Thomas Camlong 240397e7ac refactor: drop server-seeded widget clock
The WidgetTimeProvider plumbed a server timestamp and the container's
timezone down to useWidgetNow so clock widgets would paint a real time in
the SSR HTML instead of "--:--". That traded a cosmetic first-paint win for
a wrong clock: the seeded timezone is the server's, so anyone whose timezone
differs from the container's saw an incorrect time for the whole hydration
window. Fixing that properly needs a client-set timezone cookie read during
RSC render, which is far more plumbing than the problem warrants.

Revert the clock to release/v2 behaviour ("--:--" until hydration) and drop
the provider, the @homarr/widgets/time export and their tests. The e2e clock
assertions now wait on hydration explicitly instead of the seeded paint.

Also restore the widget.beszel.getSystemStats persistence exclusion. #6289
added it deliberately to keep those large time-series payloads out of the
persisted cache, and this branch had removed it with no stated rationale.

Query-cache persistence and the background-refresh indicator are unchanged.
2026-08-23 09:00:34 +02:00
Thomas Camlong 3efb719eee refactor: simplify widget import boundary 2026-08-23 09:00:34 +02:00
Thomas Camlong 382cffc015 test: use configured DOM environment 2026-08-23 09:00:34 +02:00
Thomas Camlong 0073dfe044 refactor: trim widget reload plumbing 2026-08-23 09:00:33 +02:00
Thomas Camlong a22fb60647 fix: harden widget query persistence and refresh indicator
Review follow-ups on the session query cache:

- Cache the per-payload size verdict and reuse a single TextEncoder. The
  persist subscription re-dehydrates the whole cache on every cache event,
  so every widget payload was being superjson-serialized several times per
  refresh cycle just to measure it.
- Skip the size measurement on the restore path. It runs before any query is
  allowed to fetch and the payload was already budgeted when it was written.
- Reject stored queries whose dataUpdatedAt is not a positive finite number;
  `undefined <= 0` is false, so those were hydrating with a NaN timestamp.
- Add `stop()` and tear the persister down before `queryClient.clear()`, so
  the clear cannot overwrite the saved snapshot with an empty cache.
- Remove every other scope's cache when a persister is created: signing out
  navigates away before the scope guard can react, which left the previous
  account's dashboard data in the tab.
- Remove the entry instead of storing an empty cache.

Refresh indicator:

- Make it decorative instead of an aria-live region; every widget refreshes
  on a timer, so it made assistive technology repeat "loading" forever.

Also broadens the e2e hydration-error matcher to production React's minified
error codes, hoists the single WidgetTimeProvider in ClientBoard, memoizes the
resolved local time zone, and drops the exports this branch made dead.
2026-08-23 09:00:33 +02:00
Thomas Camlong 89e4cb843d refactor: simplify widget reload persistence 2026-08-23 09:00:33 +02:00
Thomas Camlong 65a96b6b14 fix: address widget reload review findings 2026-08-23 09:00:33 +02:00
Thomas Camlong 357355283c fix: harden widget query persistence 2026-08-23 09:00:33 +02:00
Thomas Camlong a2a408410a Persist widget data across reloads 2026-08-23 09:00:33 +02:00
Thomas 0d9eeb5752 Merge pull request #6678 from homarr-labs/feat/fix-responsive-layout-order
fix: keep responsive layout editors stable
2026-08-23 08:58:48 +02:00
Thomas bf0878c68b Merge pull request #6680 from homarr-labs/feat/fix-widget-edit-layout-shifts
fix: stabilize widgets in board edit mode
2026-08-23 08:56:07 +02:00
Thomas eb6d337dfb fix: unify widget scroll areas (#6679)
* fix: unify widget scroll areas

* fix: cover all widget scroll areas

* fix: align data table scrollbars
2026-08-23 01:58:06 +02:00
ajnart 978b3c7f33 fix: stabilize widgets in board edit mode 2026-08-23 01:56:31 +02:00
Thomas 07547320f1 Merge pull request #6667 from homarr-labs/feat/widget-settings-modal
feat: rework widget settings modal
2026-08-23 01:47:14 +02:00
ajnart 02870fc392 test: align grid footprint expectations 2026-08-23 01:42:16 +02:00
ajnart b6fc27e44d fix: preserve layout validation errors 2026-08-23 01:24:36 +02:00
ajnart d5cf1b82f2 fix: address widget editor review feedback 2026-08-23 01:20:39 +02:00
ajnart e022c3f735 fix: match legacy widget grid dimensions 2026-08-23 01:20:39 +02:00
ajnart 14cc3e8a44 fix: make widget toggle cards clickable 2026-08-23 01:19:09 +02:00
ajnart 93d160aebd chore: remove PR artifacts 2026-08-23 01:19:09 +02:00
ajnart 493e93ea42 feat: size widgets before adding 2026-08-23 01:19:08 +02:00
ajnart 85c20702cd docs: add widget settings demo evidence 2026-08-23 01:19:08 +02:00
ajnart 91b248d395 fix: harden widget editor previews 2026-08-23 01:19:08 +02:00
ajnart b7eadc7867 fix: load resolved widget settings 2026-08-23 01:19:08 +02:00
ajnart 1861d8415c fix: isolate widget editor forms 2026-08-23 01:19:08 +02:00
ajnart 7fe5f8f81c fix: tighten widget preview stage 2026-08-23 01:19:08 +02:00
ajnart b3a30e0cf6 fix: constrain widget editor previews 2026-08-23 01:19:08 +02:00
ajnart 763e1324e8 fix: preserve widget preview dimensions 2026-08-23 01:19:08 +02:00
ajnart 5188ed89d6 feat: rework widget settings modal 2026-08-23 01:19:08 +02:00
ajnart 9eafd4f7a7 fix: preserve custom layout role type 2026-08-23 01:13:00 +02:00
ajnart 71ffa5d1be fix: keep responsive layout editors stable 2026-08-23 00:59:02 +02:00
Thomas 1b843817a4 feat: polish board switcher (#6670) 2026-08-22 17:21:10 +02:00
Thomas Camlong 8ae9630b3e Revert "fix: position calendar error indicator top-left (#6668)"
This reverts commit 63f5c43b69.
2026-08-22 17:03:15 +02:00
Thomas 63f5c43b69 fix: position calendar error indicator top-left (#6668) 2026-08-22 16:57:34 +02:00
Thomas e97fe7861d feat: rework bookmarks widget (#6666) 2026-08-22 16:55:31 +02:00
Thomas 19760e5722 fix: restore translucent dashboard styling (#6664)
* fix: restore translucent dashboard styling

* fix: restore dashboard widget transparency

* fix: address dashboard review feedback

* fix: apply board opacity to containers

* fix: flatten demo board container

* fix: default board roundness to sm

* fix: restore assistant widget transparency

* Revert "fix: default board roundness to sm"

This reverts commit c235cbdf2b.

* fix: address remaining dashboard review comments

* test: align grid geometry expectations
2026-08-22 15:51:43 +02:00
Thomas 2570de25a6 fix: hide default weather details (#6665) 2026-08-22 15:48:02 +02:00
Thomas f46ccadbe4 feat: redesign demo dashboard (#6663) 2026-08-22 13:08:27 +02:00
Thomas 6297ca4b16 fix: keep media stream status at table edge (#6661) 2026-08-22 11:48:33 +02:00
Thomas 2accdc2fad feat: rework board switcher (#6660)
* feat: rework board switcher

* fix: address board switcher review
2026-08-22 11:15:40 +02:00
Thomas 8a08293016 fix: restore bookmark grid fill (#6659)
* fix: restore bookmark grid fill

* fix: use scroll area viewport class
2026-08-22 11:14:41 +02:00
Bergasha 7591f7e313 feat(v2 Containers): scrollable containers (#6629) 2026-08-22 09:24:52 +02:00
Thomas 011ce8e5a3 fix: preserve board drafts and widget scroll state (#6637) 2026-08-22 09:13:32 +02:00
Thomas b2fa46c760 fix: restore release translation checks (#6658) 2026-08-22 08:49:14 +02:00
Thomas 9e14a07e64 refactor: consolidate English translations (#6640) 2026-08-21 19:00:27 +02:00
Thomas 81aa704537 feat(cli): rework developer CLI with non-blocking tasks and GitHub CI checks (#6651) 2026-08-21 18:59:11 +02:00
Thomas f0f9539466 feat(ui): add situational tips system (#6648) 2026-08-21 18:58:52 +02:00
Thomas 3c2f95d529 perf(board): stabilize edit mode layout (#6650) 2026-08-21 16:17:42 +02:00
Thomas 64e3d04517 fix(calendar): improve event indicators (#6652) 2026-08-21 15:55:38 +02:00
Bergashaandajnart 002381d294 feat(v2 docker widget): Fixes column-resize squashing (#6646)
* fix(dataTable): resizing a column no longer squashes the others

mantine-datatable's built-in column resize keeps the table's total
width fixed by shrinking every other column to compensate, which
collapses columns toward zero in a narrow widget (open upstream
issue, no fix: icflorescu/mantine-datatable#discussions-757).

Replaced it with our own resize handle that only ever changes the
width of the column being dragged - the table grows and scrolls
horizontally instead. Shared via usePersistedTableLayout, so docker,
downloads, and beszel-system-table all get it.

* feat(docker): enable hold-Shift advanced focus preview

The widget already renders differently for displayMode="advanced",
but was missing supportsAdvancedFocus: true, so the board never
offered the hold-Shift preview for it - matching downloads and
media-server, which both set this flag.

* style(docker): tighten row density, decouple icon size from row height

Circular avatar, denser row/footer padding, and action icons rendered
larger than their button via absolute positioning so they don't
inflate row height (the button stays small and fixed - it repeats
once per row, so any growth there multiplies across every visible
container).

* fix(data-table): harden shared column resizing

* fix(data-table): account for board scaling

* fix(data-table): compact shared headers

* fix(data-table): fit columns to widget

* fix(downloads): simplify advanced details

* test(widgets): align Docker advanced focus

* fix(downloads): widen expanded details

---------

Co-authored-by: ajnart <thomas@ajnart.dev>
2026-08-21 15:12:31 +02:00
Thomas 9b406d5d83 fix(beszel): restore balanced system grid layout (#6647) 2026-08-21 13:11:40 +02:00
Thomas Camlong 4c475b8c0a ci: publish v2 images reliably 2026-08-21 12:49:03 +02:00
Thomas f3014fe271 refactor(board): modernize item and application select modals with SelectableCard (#6642)
Modernize board item, application, and integration selection flows with shared Mantine components, batch board actions, improved accessibility, and hardened persistence.
2026-08-21 03:19:18 +02:00
Afonso Jorge Ramos bd4f6a92c6 fix(onboarding): keep the account submit disabled until the step advances (#6641) 2026-08-20 18:48:33 +02:00
Thomas Camlong 7e8c139590 test(e2e): stabilize lazy widget login 2026-08-20 18:33:43 +02:00
Thomas d241bac55d feat: improve assistant conversation experience (#6636) 2026-08-20 16:26:23 +02:00
Thomas 58a24569ce feat: expand utility widgets (#6638) 2026-08-20 16:26:04 +02:00
Thomas 53b518960c feat: retain application log history (#6633)
* feat: retain application log history

* fix: harden retained log streaming

* fix: harden contextual log focus

* fix: retain multiline log output
2026-08-20 12:57:30 +02:00
Thomas ddf8ba9948 feat: rework management settings layout (#6634) 2026-08-20 12:33:18 +02:00
Bergasha a2219b72f6 Merge pull request #6632 from homarr-labs/feat/v2-stocks-widget
feat (v2 stocks widget):Add a "Show details" toggle to the stock widget's compact view
2026-08-20 12:59:52 +10:00
Bergasha 7150cdbbab Add a "Show details" toggle to the stock widget's compact view
The compact view auto-reveals name/change/range as the widget gets
bigger, which can overlap badly depending on how the widget's actual
proportions land relative to those breakpoints. Add a switch (on by
default, matching current behavior) to force the minimal symbol+price
look regardless of size. Scoped to the compact layout only - the
Shift-triggered advanced view is untouched.
2026-08-20 12:32:42 +10:00
Thomas ff14613f95 feat: recalculate permission scope & permission matrix UI (#6612) 2026-08-19 17:22:31 +02:00
ajnart 78b3dc1922 fix: restore workshop create icon 2026-08-19 16:34:21 +02:00
ajnart 2e97b337d2 fix: remove universal create search 2026-08-19 16:29:30 +02:00
Thomas 369a2660b1 Embed integration editing in widget tab (#6630) 2026-08-19 14:01:11 +02:00
Bergasha 60aec370d6 Merge pull request #6627 from homarr-labs/fix/v2-media-server-widget
fix(v2 media widget): updated media server widget
2026-08-19 16:56:30 +10:00
Bergasha 6a76b9eafe Merge branch 'release/v2' into fix/v2-media-server-widget 2026-08-19 16:55:15 +10:00
Bergasha 76ba13fbad Address Codex review feedback on PR #6627
- Classify IPv4/IPv6 link-local addresses (169.254.0.0/16, fe80::/10) as
  LAN in Jellyfin location parsing - they can't traverse a router, so a
  false WAN reading was possible for common autoconfig/link-local sessions.
- Fix portrait video resolution mislabeling - a 1080x1920 clip was
  classified off its long-axis height (1920) and showed as 1440p. Classify
  by the short axis instead so orientation doesn't flip the result.
- Raise the compact-mode status column breakpoint from 420 to 540px - at
  420 the fixed-width user (200px) and status (190px) columns left almost
  no room for the currentlyPlaying column, the widget's primary content.
- Document the new resolution indicator in the media-server widget docs.
2026-08-19 16:45:09 +10:00
Thomas 150d38be62 feat(docker): add endpoint filtering and streamline setup assistant (#6625)
* feat(docker): add endpoint filtering and setup assistant

* fix(docker): simplify environment setup

* test(docker): cover demo endpoint filtering

* refactor(docker): use accordion for setup suggestions

* fix(docker): use docker icon for setup assistant

* refactor(docker): simplify assisted service setup

* refactor(docker): reuse shared translations

* fix(docker): address review feedback
2026-08-19 08:45:07 +02:00
Bergasha a4bbed0cb8 Fixed resolution showing the raw cropped height (e.g. 804p) instead of the nominal class Plex/Jellyfin display (1080p) - letterboxed/cinemascope sources are shorter than their resolution tier, so classify off the width-derived 16:9 height instead 2026-08-19 14:54:48 +10:00
Bergasha 90fa9e8741 Added tests for the LAN/WAN parsing and fixed IPv4-mapped IPv6 addresses being misclassified as WAN - a dual-stack server can report a LAN client as ::ffff:192.168.x.x, which the private-range check was missing since it only matched literal IPv6 ranges 2026-08-19 13:52:14 +10:00
Bergasha 4b20d6ecd0 Widened the status column from 140 to 190px - the Np resolution format is shorter so line 2 had room, but Transcoding video/audio badges are noticeably longer than Direct play and needed the space on line 1 2026-08-19 13:33:21 +10:00
Bergasha 6cac34e968 Changed resolution to show the actual streaming resolution (transcode target) instead of the source file's, formatted as e.g. 1080p instead of 1920x1080 2026-08-19 13:33:14 +10:00
Bergasha c6f77cec2e Added resolution to the status column, restructured into two lines: status + LAN/WAN on top, resolution + bitrate underneath 2026-08-19 13:33:08 +10:00
Bergasha acc97da84b Added LAN/WAN detection to Jellyfin, matching what Plex already reports - Jellyfin's sessions endpoint only gives a client IP (RemoteEndPoint), so classify it against the standard private IP ranges ourselves 2026-08-19 13:32:40 +10:00
Bergasha 6b10ac2ab5 Gave User a fixed 200px width so Currently playing (the busiest column) gets the largest share of the row instead of splitting it evenly with User 2026-08-19 13:32:34 +10:00
Bergasha c7563f2c39 Fixed Jellyfin bitrate showing null on direct play - the sessions endpoint only reliably fills in MediaSources bitrate while transcoding, so sum the individual media streams' own bitrates as a fallback 2026-08-19 13:32:28 +10:00
Bergasha 96676182f4 Gave the status column a fixed 140px width - it had no explicit width so it shared an equal, unconstrained share of the row with the other columns despite only holding a small badge 2026-08-19 13:32:21 +10:00
Bergasha 3769d88ebb Dimmed the column header labels and added a bottom border under the sticky header, so it reads as its own band instead of the same weight as the row data 2026-08-19 13:32:15 +10:00
Bergasha d9c35744f6 Only show a server's badge in the footer when it actually has an active session - selecting both Plex and Jellyfin always showed both badges even if only one was streaming. Also gave the footer badges a pill style and added a separator between the stream count and bitrate 2026-08-19 13:31:58 +10:00
Bergasha 33991f4e22 Added flexShrink to the session avatar so it stops getting squished in a tight row 2026-08-19 13:31:33 +10:00
Bergasha df300919fc Gated season/episode fields on episode type - was showing S/E labels on movies and music too, using the episode number as season 2026-08-19 13:31:27 +10:00
1023 changed files with 54579 additions and 18585 deletions

No files matched your search

+25 -9
View File
@@ -320,8 +320,12 @@ jobs:
publish-preview:
name: Publish multi-platform preview image
needs: [preview-amd64, preview-arm64]
needs: [container, preview-amd64, preview-arm64]
if: |
!cancelled() &&
needs.container.result == 'success' &&
needs.preview-amd64.result == 'success' &&
needs.preview-arm64.result == 'success' &&
(
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
(github.event_name == 'push' && github.ref == 'refs/heads/release/v2') ||
@@ -374,21 +378,25 @@ jobs:
AMD64_DIGEST: ${{ needs.preview-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.preview-arm64.outputs.digest }}
run: |
docker buildx imagetools create -t "${PREVIEW_IMAGE}:${PREVIEW_TAG}" \
tags=(-t "${PREVIEW_IMAGE}:${PREVIEW_TAG}")
docker buildx imagetools create "${tags[@]}" \
"${PREVIEW_IMAGE}@${AMD64_DIGEST}" \
"${PREVIEW_IMAGE}@${ARM64_DIGEST}"
- name: Verify preview manifest platforms
if: "!env.ACT"
run: |
image="${PREVIEW_IMAGE}:${PREVIEW_TAG}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
for architecture in amd64 arm64; do
jq --exit-status --arg architecture "$architecture" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
<<<"$manifest" >/dev/null
tags=("${PREVIEW_TAG}")
for tag in "${tags[@]}"; do
image="${PREVIEW_IMAGE}:${tag}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
for architecture in amd64 arm64; do
jq --exit-status --arg architecture "$architecture" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
<<<"$manifest" >/dev/null
done
docker buildx imagetools inspect "$image"
done
docker buildx imagetools inspect "$image"
cleanup:
name: Clean up preview image
@@ -425,6 +433,14 @@ jobs:
core.info(`No package version found with tag "${tag}"`);
return;
}
const targetTags = target.metadata?.container?.tags ?? [];
const extraTags = targetTags.filter((targetTag) => targetTag !== tag);
if (extraTags.length > 0) {
core.setFailed(
`Package version ${target.id} also carries tag(s) ${extraTags.map((targetTag) => `"${targetTag}"`).join(", ")}; refusing cleanup.`,
);
return;
}
await github.rest.packages.deletePackageVersionForOrg({
package_type: "container",
package_name: packageName,
+81 -15
View File
@@ -27,37 +27,103 @@ jobs:
- run: pnpm test:workshop
- run: pnpm test:workshop-image
publish:
publish-amd64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: validate
runs-on: ubuntu-24.04
timeout-minutes: 30
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-qemu-action@v4
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@v6
id: metadata
with:
images: ghcr.io/homarr-labs/workshop
tags: |
type=sha,prefix=sha-,format=long
type=raw,value=v2,enable=${{ github.ref_name == 'v2' || github.ref_name == 'release/v2' }}
type=raw,value=latest,enable=${{ github.ref_name == 'main' }}
- uses: docker/build-push-action@v7
id: build
with:
context: .
file: apps/workshop/Dockerfile
target: production
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
platforms: linux/amd64
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-amd64
cache-to: type=gha,scope=workshop-linux-amd64,mode=max
publish-arm64:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: validate
runs-on: ubuntu-24.04-arm
timeout-minutes: 20
permissions:
contents: read
packages: write
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v7
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v7
id: build
with:
context: .
file: apps/workshop/Dockerfile
target: production
platforms: linux/arm64
outputs: type=image,"name=ghcr.io/homarr-labs/workshop",push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=workshop-linux-arm64
cache-to: type=gha,scope=workshop-linux-arm64,mode=max
publish:
if: github.event_name == 'push' && (github.ref_name == 'v2' || github.ref_name == 'release/v2' || github.ref_name == 'main')
needs: [publish-amd64, publish-arm64]
runs-on: ubuntu-24.04
timeout-minutes: 5
permissions:
contents: read
packages: write
env:
WORKSHOP_IMAGE: ghcr.io/homarr-labs/workshop
steps:
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Publish multi-platform image
env:
AMD64_DIGEST: ${{ needs.publish-amd64.outputs.digest }}
ARM64_DIGEST: ${{ needs.publish-arm64.outputs.digest }}
run: |
tags=(-t "${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}")
if [[ "${GITHUB_REF_NAME}" == "v2" || "${GITHUB_REF_NAME}" == "release/v2" ]]; then
tags+=(-t "${WORKSHOP_IMAGE}:v2")
fi
if [[ "${GITHUB_REF_NAME}" == "main" ]]; then
tags+=(-t "${WORKSHOP_IMAGE}:latest")
fi
docker buildx imagetools create "${tags[@]}" \
"${WORKSHOP_IMAGE}@${AMD64_DIGEST}" \
"${WORKSHOP_IMAGE}@${ARM64_DIGEST}"
- name: Verify multi-platform image
run: |
image="${WORKSHOP_IMAGE}:sha-${GITHUB_SHA}"
manifest="$(docker buildx imagetools inspect "$image" --raw)"
for architecture in amd64 arm64; do
jq --exit-status --arg architecture "$architecture" \
'any(.manifests[]; .platform.os == "linux" and .platform.architecture == $architecture)' \
<<<"$manifest" >/dev/null
done
docker buildx imagetools inspect "$image"
+5
View File
@@ -25,6 +25,8 @@ packages/db/migrations/*/migrate.cjs
# production
build
tools/homarr-dev/homarr
tools/homarr-dev/homarr-dev
# misc
.DS_Store
@@ -92,3 +94,6 @@ homarr-backup*.zip
*.sqlite-wal
*.sqlite-shm
/scripts/benchmarks/
# PR description screenshots belong on the pull request, not in the repository
.screenshots/
+20
View File
@@ -30,6 +30,26 @@
"options": {
"reportUnusedDisableDirectives": "off"
},
"overrides": [
{
"files": ["apps/nextjs/src/**/*.ts", "apps/nextjs/src/**/*.tsx"],
"excludeFiles": ["**/*.spec.ts", "**/*.spec.tsx"],
"rules": {
"eslint/no-restricted-imports": [
"error",
{
"paths": [
{
"name": "@homarr/widgets",
"allowTypeImports": true,
"message": "Import a narrow @homarr/widgets export to preserve widget lazy loading."
}
]
}
]
}
}
],
"ignorePatterns": [
"**/*.config.js",
"**/*.config.mjs",
@@ -1,43 +1,175 @@
# Developer Docker CLI
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
The host-side developer CLI builds and runs local Homarr images and pull-request previews with non-blocking background operations. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication (`gh auth login`) is only needed for pull-request features.
## Browse development images
## Requirements
- Docker or OrbStack
- Go 1.25+ (when building the CLI from source)
- GitHub CLI authenticated with `gh auth login` (for pull-request and CI features)
- A [Nerd Font](https://www.nerdfonts.com/) enabled in your terminal (recommended for icons, status indicators, and TUI glyphs)
## Interactive Development Browser
```bash
pnpm dev:cli --
# or with global binary:
homarr
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
The main browser combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` (or `1`/`2`) to toggle between the image browser and the instance dashboard.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
A locally built PR image is folded into its remote PR row only when its recorded revision matches the current GitHub head SHA (marked `◆ local`). When the matching GHCR image is also available, pressing `Enter` opens a local/remote selector with local preselected. Outdated local builds remain separate rows.
## Build images
### Multi-Source Sidebar
Press `Tab` to cycle between sidebar tabs:
- **`[logs]`**: Live container output streams (press `f` to follow/pause, `L` to pin to the selected container, `Page Up`/`Page Down` to scroll).
- **`[build]`**: Real-time BuildKit step streaming for active local builds.
- **`[ci]`**: GitHub Actions CI check statuses, run duration, and workflow links for pull requests.
### Background Task Overlay
Pulls and builds run asynchronously in the background. Press `t` to open the tasks overlay, showing:
- Layer-by-layer download progress and transfer speeds (via Docker Engine API).
- Active build stages and logs.
- Task cancellation and status.
### Browser Shortcuts
| Key | Action |
| ---------------------- | --------------------------------------------------------------------------------------------- |
| `/` | Filter rows by PR number, title, author, branch, CI, image, state, or port |
| `↑` / `↓` or `j` / `k` | Navigate rows |
| `Enter` or `Space` | Start the available image; choose local or remote when both exist; stop if running |
| `R` | Build locally in background (or rebuild local image) |
| `p` | Pull remote PR image in background and start container |
| `m` | Manage mode (`b` rebuild, `i` delete image, `d` delete data, `c` remove container, `p` prune) |
| `t` | Toggle background tasks overlay |
| `Tab` | Cycle sidebar tabs (`[logs]`, `[build]`, `[ci]`) |
| `l` / `L` | Toggle sidebar visibility / Pin logs to selected instance |
| `f` | Follow or pause logs |
| `M` | Toggle demo mode for next launch |
| `b` | Toggle bot pull requests |
| `o` / `a` | Open PR on GitHub / Open running instance in browser |
| `c` | Copy instance URL to clipboard |
| `r` | Refresh data |
| `1` / `2` / `d` | Switch screen (development browser / instance dashboard) |
| `?` / `q` | Help overlay / Quit (cancels active background tasks) |
## Instance Dashboard
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
homarr dash
# or
pnpm dev:cli -- dash
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
The instance dashboard displays all running and stopped Homarr containers, published ports, health states, and resource volumes.
## Rebuild images
| Key | Action |
| ------------------ | ---------------------------------------- |
| `Enter` or `Space` | Start or stop selected instance |
| `s` / `S` | Stop / Restart instance |
| `x` | Remove container (preserves data volume) |
| `m` | Manage mode for selected instance |
| `R` | Rebuild from recorded build provenance |
## CLI Commands
The commands below use the installed `homarr` binary. Without global installation, prefix each command with `pnpm dev:cli --` (for example, `pnpm dev:cli -- list`).
### Launch Instances
```bash
pnpm dev:cli -- rebuild <name>
homarr run dev
homarr run --detach dev
homarr run --pr 6441
homarr run --pr 6441 --demo
homarr --pr 6441 --env WORKSHOP_WEB_URL=https://app-v2.preview.homarr.dev/
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
- `--pr <number>`: Pull and run a PR image from GHCR (shorthand: `homarr --pr <number>`).
- `--demo` (`-m`): Start with mock integrations and sample data.
- `--detach`: Start container in background.
- `--env` (`-e`): Pass additional environment variables (repeatable: `--env KEY=VALUE`).
## Run images
### Build & Rebuild
```bash
homarr --pr <number> --env HOMARR_WEBSITE_URL=https://app-v2.preview.homarr.dev/
homarr run <name>
homarr run --pr <number>
# Build current checkout as homarr:<name>
homarr build feature
# Build a pull request locally in a temporary checkout
homarr build --pr 6441
# Rebuild an existing image from recorded provenance
homarr rebuild feature
```
The direct `homarr --pr <number>` form is shorthand for `homarr run --pr <number>`. Add `--detach` to run in the
background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
Images built with the CLI record their source checkout, revision SHA, and optional PR number. `homarr rebuild` reads this provenance to reconstruct the build without manual configuration.
### GitHub CI Checks
```bash
# View CI checks for a specific PR
homarr ci 6441
# View CI checks for the current branch
homarr ci
# Watch CI checks until completion
homarr ci 6441 --watch
```
### Interactive Shell
```bash
# Open interactive shell in running container
homarr shell homarr-dev
# Run specific command in container
homarr shell homarr-dev pnpm db:migration:sqlite:run
```
### Image & Volume Management
```bash
# List local images with build provenance (source, revision, PR)
homarr images
# Delete local image
homarr images rm feature
# List data volumes with owner PR/tag and in-use status
homarr data
# Delete data volume (safely stops and removes owning container first)
homarr data rm homarr_feature_data
# Remove stopped containers (prompts without --force)
homarr prune --force
```
### Instance Operations & Diagnostics
```bash
# List instances in a script-friendly table
homarr list
# Follow container logs
homarr logs homarr-dev
# Stop, restart, remove, or open container in browser
homarr stop homarr-dev
homarr restart homarr-dev
homarr remove homarr-dev
homarr open homarr-dev
# Verify Docker daemon, Engine API, GHCR access, and gh CLI authentication
homarr doctor
```
@@ -25,3 +25,29 @@ You should also create a backup of the database, in case you break something.
4. Click "Execute" in the top right of the Window. ![](img/334116411-df1482f9-3aa9-4686-b77f-e0dca09e60b2.png)
5. Select /bin/bash and click Execute ![](img/334116518-e1ddb515-99c7-4159-81bf-76e889d1e808.png)
6. Run the Homarr command and press enter (eg. homarr reset-owner-password) ![](img/334116642-08c4f204-5cd0-4e4e-a785-0142e1b279ca.png)
## Build a local branch harness
The host-side operations CLI can build the current checkout and run it as a named, browser-ready Docker harness. This is separate from the recovery CLI described above and does not open the interactive developer TUI.
```sh
pnpm cli harness setup feature-board
```
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
```sh
pnpm cli harness port feature-board
```
Pass environment overrides by repeating `--env`:
```sh
pnpm cli harness setup feature-board \
--env WORKSHOP_WEB_URL=https://example.test \
--env FEATURE_FLAG=true
```
Other commands are `harness build <name>`, `harness run <name>`, and `harness stop <name>`. Use the returned URL with a browser automation harness for feature validation; do not assume the host port is `7575`.
@@ -58,12 +58,12 @@ A random secret can be generated by using the following command: `openssl rand -
## Docker
| Environment Variable | Description | Possible values | Default |
| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_ENDPOINTS` | JSON array of named, capability-scoped Docker or Podman endpoints. Supports socket, explicitly insecure TCP, and TLS transports. See the [Docker and Podman integration guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
| `DOCKER_HOSTNAMES` | Legacy comma-separated list of plaintext TCP hostnames. Ignored when `DOCKER_ENDPOINTS` is set | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Legacy comma-separated list of plaintext TCP ports. Ignored when `DOCKER_ENDPOINTS` is set | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Legacy comma-separated list of mounted Docker-compatible socket paths. Ignored when `DOCKER_ENDPOINTS` is set | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| Environment Variable | Description | Possible values | Default |
| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------- |
| `DOCKER_SOCKET_PATHS` | Comma-separated mounted Docker-compatible socket paths. Homarr discovers each path as a selectable Docker environment | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| `DOCKER_HOSTNAMES` | Comma-separated plaintext TCP hostnames. Pair each hostname with the port in the same position in `DOCKER_PORTS` | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated plaintext TCP ports paired with `DOCKER_HOSTNAMES` | For example `2375,2376` | - |
| `DOCKER_ENDPOINTS` | Optional advanced JSON configuration for custom names, TLS, or restricted capabilities. It takes precedence over the simple variables above. See the [Docker and Podman connection guide](/docs/integrations/docker) | A JSON array of endpoint descriptors | - |
## Database
@@ -6,19 +6,34 @@ tags:
Homarr has the following keyboard shortcuts:
| Shortcut | Description |
| ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| `Shift` + `Enter` on a supported widget | Opens its advanced view until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
| Shortcut | Description |
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
| `MOD` + `K` | Opens search, including local apps, boards, integrations, pages, commands, and preferences |
| `MOD` + `P` | Opens app search |
| `MOD` + `/` | Opens Homarr Assistant inside search |
| `MOD` + `Shift` + `/` (`MOD` + `?`) | Opens preference search |
| `Shift` + `A` | Opens the full Homarr Assistant |
| `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit Mode |
| Hold `Shift` for 500 ms over a supported widget | Opens its temporary advanced view |
| Hold `Shift` + `Ctrl`/`Cmd` over a supported widget | Keeps its advanced view open until it is closed |
| `Esc` | Stops keyboard tile editing, cancels an active pointer or touch move/resize, or closes an open advanced view or board menu |
## Search and commands
Search is designed as the fastest route to Homarr. Start typing after `MOD` + `K` to find local apps, boards,
integrations, pages, commands, and preferences without entering a separate mode first. Local Homarr results appear
before configured search-engine actions and Assistant fallbacks. Commands include opening the board switcher without
leaving the keyboard. Type `!` to enter Web mode when you want to browse configured engine shortcuts or discover
DuckDuckGo bangs; bang discovery is excluded from ordinary Search results.
Use the dedicated shortcuts above to start in apps, Assistant, or preferences. Inside search, choose a mode to narrow
the same query when needed. Media request search is available only when a supported Seerr integration is configured.
## Widget advanced view
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Focus a widget and press
`Shift` + `Enter`, or use its menu, to keep the advanced view open.
Supported widgets open a temporary advanced view when you hold `Shift` over them for 500 ms. Hold `Shift` plus `Ctrl`
on Windows/Linux or `Cmd` on macOS over the widget to keep the advanced view open, or use its menu.
## Board editing
@@ -55,7 +55,7 @@ The studio keeps related choices together:
2. **Discover** checks the actual database, Docker-compatible sockets, Kubernetes configuration, Workshop, and Assistant capabilities. Unavailable sources never block manual setup.
3. **Connect** shows detected integrations and apps first, identifying whether each app came from an explicit [`homarr.*` or Homepage Docker label](/docs/integrations/docker-labels) or image matching. Select several integrations and apps at once, review every URL, and use the service-specific link to open its API-key settings when one is available. If a container has no published port or URL label, add its HTTP or HTTPS address before importing it.
4. **Board** previews the Base layout, colors, widget radius, sidebars, selected apps, and widgets inferred from integrations or Docker labels. Choose 8 columns for a focused board, 10 for a balanced board, 12 for a wide board, or use the slider for a custom width up to 24 columns. Homarr still creates and reflows the protected Mobile layout automatically. A fast setup adds the integration-free Clock, Weather, and Bookmarks starter widgets.
5. **Extend** explains and optionally configures Homarr Assistant, the community Workshop, and the separate Homarr MCP endpoint.
5. **Extend** explains and optionally configures Assistant, the community Workshop, and the separate Homarr MCP endpoint.
6. **Review** tests the selected connections, then saves integrations, apps, widgets, settings, and the exact first board together. If any part fails, the database changes are rolled back and your form stays available to correct.
Docker discovery reports each configured host separately. Results from reachable hosts remain selectable if another socket
@@ -132,8 +132,8 @@ Here you have the following options in the header:
- **Search**: Here you can search for data. You can search for integrations, boards, actions, sites and much more. Some actions may be context dependent (eg. will only show when certain widgets are on your page)
- **Edit board**: Clicking this button will toggle edit mode. In this mode you can edit the contents of your board.
- **Settings**: Clicking this button will redirect you to a settings page where you can adjust the board settings (page title, colors, ...).
- **Board switcher**: Use this button to open another board that you can access.
- **Profile**: This avatar will be visible on every page. Clicking it will show a menu with actions.
- **Profile**: This avatar will be visible on every page. Its menu includes the board switcher; press `Shift+C` to open
it directly. The current board is left out of the results.
When entering the edit mode, you'll get some additional buttons to work with:
@@ -151,7 +151,7 @@ When entering the edit mode, you'll get some additional buttons to work with:
Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions.
Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
stays visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click **New item** in the add-content menu. New content is placed at the first available position on the main
@@ -198,9 +198,13 @@ In the modal you can edit the app that you want to display:
<figure>
<img src={editItemCurrent} alt="Current Edit item modal for an App tile" style={{ maxWidth: "620px" }} />
<figcaption>Use the Edit item modal to choose the app and configure how its tile is displayed.</figcaption>
<figcaption>
The Edit item workspace previews option changes as you configure the tile. Save changes to apply them to the board.
</figcaption>
</figure>
When adding a widget, set its initial grid width and height directly in the preview. These size controls are only shown before the widget is added.
As soon as you save your changes, the tile on the board will refresh and display your app.
Resize from any visible edge or corner handle. For a Container, activate its top-left grip before using its resize handles.
Press and hold a handle, move the pointer, and release it when the tile has the size you want.
@@ -369,7 +373,7 @@ Each page highlights the list view and the create action so you know where to fi
### Board tour
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, board switcher, and user menu.
When you first open a board, a tour highlights the board header elements: search, edit mode, settings, and the user menu.
### Tour controls
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 45 KiB

+31 -16
View File
@@ -24,12 +24,11 @@ import { dockerContainersWidget } from "@site/docs/widgets/docker-containers";
]}
/>
## Enabling the Docker integration
## Connecting Docker to Homarr
This integration must first be enabled.
If you directly run on the host system and you have docker installed, it will be enabled by default.
Otherwise, you'll need to mount the [docker socket](https://docs.docker.com/engine/security/protect-access/) to the Homarr container,
so Homarr can interact with the Docker service.
Docker connections come directly from Homarr's environment variables. They are automatically available in the Docker management table and Docker widget; you do not create a Docker integration under **Management → Integrations**.
If Homarr runs directly on a host with Docker installed, it uses `/var/run/docker.sock` by default. When Homarr runs in a container, mount the [Docker socket](https://docs.docker.com/engine/security/protect-access/) into it so Homarr can interact with Docker.
In Docker Compose, you can simply add this line:
@@ -122,7 +121,23 @@ services:
DOCKER_SOCKET_PATHS: /var/run/docker.sock,/var/run/podman.sock
```
For named endpoints, transport security, or least-privilege actions, prefer `DOCKER_ENDPOINTS`. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
For remote socket proxies, list the hostnames and matching ports in the same order:
```yaml
environment:
DOCKER_HOSTNAMES: docker-proxy-home,docker-proxy-lab
DOCKER_PORTS: 2375,2375
```
Homarr automatically discovers each comma-separated entry as a separate Docker environment. All environments are selected by default; the Docker widget lets you select one or more without any additional setup.
:::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy on a private Docker network, or TLS.
:::
### Advanced endpoint configuration
Most users should use the comma-separated variables above. Use `DOCKER_ENDPOINTS` only when you need custom names, TLS, or restricted capabilities. It is a JSON array where every endpoint declares its identity, transport, and capabilities:
```yaml
services:
@@ -163,16 +178,12 @@ Supported transports are:
- `tls`: `host`, `port`, and an absolute `caPath`; add both `certPath` and `keyPath` for mutual TLS
- `tcp`: `host`, `port`, and the explicit acknowledgement `allowInsecure: true`
:::warning[Plaintext TCP]
Plaintext Docker TCP can grant unauthenticated control of the Docker host. Prefer a local socket, a least-privilege socket proxy, or TLS. The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`.
:::
When `DOCKER_ENDPOINTS` is set, it takes precedence over the legacy `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS` variables.
The structured configuration refuses TCP unless `allowInsecure` is explicitly set to `true`. When `DOCKER_ENDPOINTS` is set, it takes precedence over `DOCKER_SOCKET_PATHS`, `DOCKER_HOSTNAMES`, and `DOCKER_PORTS`.
### Managing your Docker containers within Homarr
Homarr allows you to interact with Docker containers running on your system.
The **Service setup** section compares the visible containers with your existing Homarr apps and integrations. It keeps every service distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
The collapsed **Assisted Docker setup** helper optionally compares visible containers with your existing Homarr apps and integrations. Open it only when you want setup suggestions; it never blocks normal container management. Every service stays distinct by Docker endpoint and container ID, including multiple containers that use the same integration type.
Homarr can show a service as:
@@ -181,15 +192,15 @@ Homarr can show a service as:
- **Already represented** or **Linked** when an app or integration already matches
- **Address changed** when a service appears to match an integration but its reachable address has changed
Suggested addresses are ranked and explain where they came from. A published address or Docker endpoint host is normally usable from a browser; a container-name address is server-only and requires Homarr to share the container network. Wildcard addresses such as `0.0.0.0` are never offered as browser destinations. You can edit every suggestion before continuing.
Set the usual server address once, then choose whether services normally use different ports, subdomains, or paths. Homarr uses that pattern to prefill every detected service. Each suggestion has one editable URL field with the generated and Docker-reported addresses available as autocomplete options, so you can select a suggestion or type any address directly.
:::info[Review before changing]
Service discovery is read-only. Homarr never creates or changes an app or integration automatically; use the action on an individual suggestion to review and confirm its setup.
Service discovery is read-only. Homarr never creates or changes an app or integration automatically. **Set up integration** opens the standard prefilled integration form, while **Create app** opens the existing Docker app form, so you can review and confirm the final setup.
:::
Use the inbox filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same panel. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
Suggestions appear as a compact task list. Expand an item only when you want to edit its URL or inspect relevant setup statuses. Use the filters to focus on services that need attention or those already represented in Homarr. Refresh runs discovery again. Dismissed suggestions are stored in the current browser using their endpoint-qualified service identity and can be restored from the same helper. If multiple existing items could match, Homarr marks the result as ambiguous and does not choose one automatically.
The health badges are an evidence-based projection across Docker, integration configuration, app representation, and persisted widget links. Authentication, API request success, and widget query execution are labeled **Not observed** because Homarr does not currently retain trustworthy last-run results for these layers. Use the suggested integration test or board action to diagnose them live; **Not observed** is never presented as healthy.
Compact status icons summarize the setup information Homarr can act on. Unknown and non-applicable runtime layers are omitted to keep the helper focused; use the integration form's connection test or the relevant board action for live diagnostics.
If one Docker endpoint is unavailable, Homarr reports that endpoint while continuing to show services from healthy endpoints. An unavailable endpoint is not treated as an empty Docker installation.
@@ -216,6 +227,10 @@ This opens a dedicated page with an xterm.js terminal that streams the container
The Docker management table supports optional **CPU usage** and **Memory usage** columns.
These columns are hidden by default and can be toggled on from the table column visibility settings.
#### Choosing Docker environments in the widget
The Docker widget can show one or more configured Docker environments. Leave **Docker environments** empty to include every endpoint, which is also the default for existing widgets. Selecting one or more entries limits container and statistics requests to those endpoints.
#### Add apps from containers
Homarr also lets you add apps from containers. You can simply select all containers you want to add an app for and then click the `Add to Homarr` button.
+37
View File
@@ -29,6 +29,9 @@ Alternatively, you can [open the full interactive API reference](/api-reference)
Many endpoints will require you to authenticate. At the top of the API reference, you'll find a button to authenticate yourself with an API key.
You can obtain API keys from the "Authentication" tab at the top of the page.
API keys will not expire and can be used indefinitely.
When you create a key, its one-time value is shown inline above the key table. Copy it before closing the notice; the
creation action remains disabled until the value has been acknowledged. Deletion uses a two-click inline confirmation
instead of opening a separate dialog.
### API Key Format
@@ -72,6 +75,34 @@ API key authentication enables programmatic access for:
- Custom dashboards and monitoring tools
- Container orchestration systems that need to sync app definitions
## Permissions
API keys act as the user that created them, so every request is subject to that user's group
permissions. A call that the user is not allowed to make returns `FORBIDDEN`, and list endpoints
return only the records the user may see rather than failing.
### App endpoints require app management access
`GET /api/apps`, `GET /api/apps/paginated` and `GET /api/apps/search` (tRPC `app.all`,
`app.getPaginated`, `app.search`) require the **modify all apps** permission. They previously
returned the full app catalogue — including internal URLs — to any authenticated user.
If a script that used to work now returns `FORBIDDEN`, grant **modify all apps** to a group the key
owner belongs to, or switch to `GET /api/apps/selectable`, which returns the reduced fields used by
board pickers.
### Integration endpoints are filtered per key
`integration.all`, `integration.allThatSupportSearch`, `integration.allOfGivenCategory` and
`integration.search` filter server side. Without a global integration permission, a key only
receives the integrations its owner has been granted access to, so responses may contain fewer
records than before rather than returning an error. `integration.search` applies its `limit` after
filtering, so a scoped key still receives a full page of results.
Each returned integration carries a `permissions` object — check `hasUseAccess` before reading data
and `hasInteractAccess` before performing actions; `false` means the key owner lacks that level for
that integration, not that the call failed.
## Board Automation
The OpenAPI and MCP APIs include board management endpoints for common automation workflows:
@@ -84,6 +115,8 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
Board layout responses identify each layout with a `mobile`, `base`, or `custom` role. Mobile and Base are protected. Layout-saving clients must preserve existing roles, keep Mobile at breakpoint `0`, keep Base as the highest breakpoint, and use unique custom breakpoints between them. The layout save operation returns the canonical saved layouts, including server-generated IDs for new custom layouts. A non-Base layout can be regenerated from Base with the protected reset operation.
The internal `board.getManageOverview` tRPC query returns compact board previews limited to 12 layout rows by default. Callers may provide `previewRowLimit` from 1 through 48; `fullPreview: true` requests the server's 48-row maximum.
Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags.
## Docker action payload migration
@@ -110,6 +143,10 @@ Pass those pairs unchanged to `docker.startAll`, `docker.stopAll`, `docker.resta
names use underscores, such as `docker_restartAll`. Endpoint qualification prevents identical container IDs on different
Docker or Podman endpoints from targeting the wrong service.
`docker.getContainers` also accepts an optional `endpointIds` array. Omit it or pass an empty array to query every configured
Docker environment, or pass one or more IDs returned by `docker.getEndpoints` to query only those Dockerode clients. The
Docker widget uses the same behavior for its environment selector.
## Partial integration failures
Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include
+13 -2
View File
@@ -51,6 +51,17 @@ Note that the ping URL only accepts `http://` and `https://` URLs — custom URI
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
On the apps management page you find all apps that exist in your Homarr instance. Search by name or URL, open an app
directly, or use its row actions to edit and delete it. A filtered search can be cleared without leaving the page.
Any signed-in user can view this list. Creating, editing, and deleting apps requires the corresponding app permission; board permissions are not required.
### Accessing the page
The `/manage/apps` page is reachable when you can manage all apps (`app-modify-all`) or create apps (`app-create`):
- **Manage all (`app-modify-all`)**: the full list of apps is shown and searchable, and each row can be edited.
- **Create only (`app-create`)**: the page shows an empty "create an app" state instead of the list, so the names and
URLs of existing apps are never disclosed. The create action is still available.
- **Neither**: the page is not reachable and returns a not-found page.
Creating an app requires `app-create`, editing requires `app-modify-all`, and deleting requires `app-full-all`;
board permissions are not required.
+8 -5
View File
@@ -1,5 +1,5 @@
---
description: Configure and use Homarr Assistant.
description: Configure and use Assistant.
tags:
- Assistant
- AI
@@ -8,10 +8,10 @@ tags:
# Assistant
Homarr Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
Assistant can answer questions and manage Homarr for you. It uses the signed-in user's permissions and asks for
approval before making changes.
![Homarr Assistant conversation with suggested prompts](/img/assistant/conversation.png)
![Assistant conversation with suggested prompts](/img/assistant/conversation.png)
## Set it up
@@ -38,18 +38,21 @@ host gateway address.
## Use it
- Select **Assistant** in the user menu or press `Shift + A`.
- Ask from `Cmd/Ctrl + K`.
- Press `Cmd/Ctrl + /` to ask from search, or use `Cmd/Ctrl + K` and choose the Assistant fallback.
- Add the [Assistant widget](../widgets/assistant) to a board.
The assistant can work with boards, apps, integrations, widgets, Custom Widgets, and connected services. Use `@` to
add Homarr items as context, `/` for shortcuts, and the paperclip for supported files.
Use **Conversations** to search, switch, rename, export, or delete threads.
Each request includes the user's name, current page and board, home board, server time, browser time zone, and a small
summary of accessible Homarr resources. Live status is fetched only when needed.
## Permissions and privacy
- Read-only actions can run automatically. Changes require approval.
- Read-only actions can run automatically. Changes require approval by default. Automatic approvals apply only to the
current conversation, turn off when you switch conversations, and never answer questions for you.
- Homarr permissions apply to every tool call and mentioned item.
- API keys and custom headers are encrypted and never returned to the browser.
- Conversations are stored per user and can be exported or deleted.
+26 -16
View File
@@ -27,11 +27,18 @@ opening it. Containers include their visible widgets and app icons instead of ap
Names of boards must be unique because this will be used in the URL to load the desired board.
Press `Shift+C`, or select **Switch board** from the user menu, to open the board switcher. It shows structural
previews of every board you can access, with your current board at the end. Home-board markers and creator avatars
match the board management view. Use the search field at the top, or simply start typing, to filter by name. Use the
arrow keys to move through the results and press `Enter` to open the selected board.
## Create a new board
When creating a board, the availability of the board name will be checked for you.
Select **New board** on the boards management page. The creation form expands in the page, so you can enter the board
details without opening a dialog or leaving the board list. The availability of the board name is checked as you type.
![](./img/create-board-modal.png)
The board menu also keeps duplication in context: select **Duplicate board**, choose the new name in the form below that
board, and create the copy without navigating to a separate page.
### Name
@@ -51,6 +58,9 @@ This will enable public access for everyone - regardless of whether they are log
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
Deleting a board from the management list uses an inline safeguard. Select **Delete**, then select **Confirm** in the
same menu item. No separate confirmation dialog is opened.
## Board settings
You navigate to the board settings by clicking on these three dots:
@@ -204,10 +214,14 @@ changes, Homarr asks whether to discard them. If the editor cannot load, the boa
style={{ maxWidth: "100%" }}
/>
While edit mode is active, **Add board content** opens the same Universal Create surface as the global header action and
Spotlight. It can add a widget, app, integration, Container, board, Workshop item, or Custom Widget when the
current user and context allow it. Widget setup offers the main canvas, enabled rails, and existing Containers as
destinations before the widget is created, so the common flow does not require a second move step.
While edit mode is active, **Add board content** opens a menu for adding a widget, app, integration, or Container. The
available actions respect the current user's permissions. Widget setup offers the main canvas, enabled rails, and existing
Containers as destinations before the widget is created, so the common flow does not require a second move step.
Hold `Ctrl` on Windows or Linux, or `⌘` on macOS, while clicking board items to select several at once. The selection
toolbar can move the complete selection to another canvas, rail, or Container, delete it with an undo option, or clear the
selection. A short tip appears after entering edit mode as a reminder of the modifier key. A move is applied only when every
selected item fits in the destination.
The widget editor also lists its selected integrations. Users with full access can choose **Edit connection** to test,
diagnose, and repair an integration without leaving the board. A successful repair refreshes the affected widget; the
@@ -227,11 +241,7 @@ During a pointer or touch interaction, Escape cancels the move or resize and res
Invalid, outside, or otherwise forbidden drops are cancelled in the same way.
The **Move / resize item** action in the edit menu remains available for precise numeric input.
<img
src={keyboardTileEditingImage}
alt="A focused board tile in keyboard editing mode"
style={{ maxWidth: "100%" }}
/>
<img src={keyboardTileEditingImage} alt="A focused board tile in keyboard editing mode" style={{ maxWidth: "100%" }} />
#### Preview
@@ -312,7 +322,7 @@ Change the radius of your items and Containers.
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
When Homarr Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
When Assistant is enabled, users with modify permission can also ask it to edit the board's General, Background,
Appearance, Custom CSS, and app-status settings. The assistant reads the current stylesheet first and presents an
editable review form before requesting approval to save the change. It does not edit responsive layouts, access control,
or Danger zone actions.
@@ -330,8 +340,9 @@ This will disable the status check for apps.
Widgets expose their routine controls without requiring edit mode:
- Hold `Shift` while hovering a widget that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
remain visible but dimmed. Release `Shift` to close it. Hold `Shift` plus `Ctrl` on Windows/Linux or `Cmd` on macOS to
keep the view open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place
instead.
- When signed in, outside edit mode, and with **Enable right click on widgets** enabled, right-click a widget to inspect
its live query status, refresh its data, change quick options, open settings, or run supported actions. App tiles do
not use this widget menu, and available controls follow your board and integration permissions.
@@ -399,8 +410,7 @@ nor the integrations the user does not have access to.
Duplicating a board will create a new board with the same settings, all responsive layouts, and independent positions as the original board.
Duplicating a board that contains Custom Widgets also requires an administrator.
You can duplicate a board by clicking on the three dots in the list of boards and selecting "Duplicate board".
![](./img/duplicate-board-modal.png)
The name form expands below that board card and can be cancelled without leaving the list.
## Setting boards as your home board
@@ -30,15 +30,12 @@ Navigate to `Management` > `Tools` > `Certificates`.
## Add a certificate
To add a certificate click on the "Add certificate" button.
To add a certificate click on the "Add certificate" button. The upload form expands directly on the certificates page.
![](./img/add-button.png)
Then select the certificate file you want to add.
![](./img/add-modal.png)
And click the "Add" button.
Then select the certificate file you want to add and click the "Add" button. You can cancel to collapse the form without
leaving the page.
## Delete a certificate
@@ -46,7 +43,7 @@ To delete a certificate you can click on the trash icon in the certificate card.
![](./img/card.png)
Then confirm the deletion.
The first click arms the action and the second click confirms the deletion in place; no confirmation dialog is opened.
## Managing it through file system
@@ -125,7 +125,7 @@ model judges a result, the candidate must pass Homarr's real clipboard parser, c
checks for documented endpoints, authentication, request triggers, query bindings, response fields, and essential
runtime components.
To exercise the real Homarr Assistant lifecycle instead of accepting a returned JSON block, run:
To exercise the real Assistant lifecycle instead of accepting a returned JSON block, run:
```sh
OPENROUTER_API_KEY=... pnpm --filter @homarr/custom-widgets test:assistant-ai
@@ -41,6 +41,10 @@ needs attention.
4. Write responsive JSX using `data`, `status`, `options`, and temporary `inputs`.
5. Test and preview, inspect Data and the redacted request journal, then save.
While focus is inside a code editor, use `Ctrl+Z`/`Cmd+Z` to undo and `Ctrl+Shift+Z`/`Cmd+Shift+Z` to redo. The toolbar exposes the same actions. This history is local to that editor; builder-wide undo is not yet available.
Changing the definition, instance options, or credentials invalidates the previous preview and its live-action session. Run **Test and preview** again before treating the data or result as current. Automatic load queries are queued with up to four running at once so larger previews stay within the request limit.
<CanonicalCustomWidgetExample
id="named-widget-requests"
label="Named queries and actions"
@@ -43,7 +43,7 @@ interface HomarrCustomWidgetV2 {
### Design with AI
The **Design with AI** action is available while creating or editing a custom widget. Describe what the widget should show and,
when [Homarr Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
when [Assistant](../assistant) is enabled, send the request directly from the form. The generated prompt includes the
widget schema and current non-secret configuration, but not the latest preview response. It proposes the matching create or
update tool for your approval.
@@ -114,7 +114,8 @@ The install flow always links to the corresponding public Workshop entry under t
For example, installing **Tautulli Activity** asks for both the user's Tautulli URL and API key. A stable public source such as PokéAPI keeps its published URL without creating unnecessary setup work.
Workshop publishes technically valid widgets immediately; users can vote or report them, and Workshop moderators can
delete submissions. Community moderation is separate from Homarr administration and is not a security boundary.
delete submissions. The report form expands within the Workshop detail page instead of interrupting the review with a
dialog. Community moderation is separate from Homarr administration and is not a security boundary.
Publishers should replace personal private-network hosts with a clear service placeholder before publishing. Homarr
performs definitive validation again at install time.
@@ -17,6 +17,24 @@ To create an integration, choose **New integration** and select the supported ap
The integrations management page uses a searchable list. Each row shows the integration type and URL, with the actions you have permission to use.
### Accessing the page
The `/manage/integrations` page is reachable when at least one of the following applies:
- you can manage all integrations (global `integration-full-all`),
- you can create integrations (`integration-create`), or
- you have been granted full access to at least one specific integration.
What the page shows depends on the case:
- **Manage all (`integration-full-all`)**: the full list of integrations is shown.
- **Create only (`integration-create`)**: the page shows an empty "create an integration" state instead of the list, with the create action still available.
- **Delegated (full access on one or more)**: only the integrations you have full access to are listed, above a note making clear that the view is scoped to what you can manage.
- **None**: the page is not reachable and returns a not-found page.
Where you have a mix of access, the list combines the integrations you can manage. Integration lists are filtered
server side, so a user never receives data about integrations they lack access to.
Next, you'll need to fill out the required fields to configure the integration:
![](./img/form-create-integration.png)
@@ -63,9 +81,9 @@ When you connect a service from a board, Homarr keeps you in the same setup flow
confirms whether an application shortcut was linked and offers the widgets that support the new integration. Selecting a
widget returns to its settings with the new integration already selected.
Users with full access can also test and update a selected integration from **Edit widget → Edit integration** on a
board. Failed tests keep the editor open and show the same connection diagnostics as the management page. Successful
changes apply to every app and widget that uses the integration.
Users with full access can also test and update a selected integration directly in the **Edit widget → Edit integration**
tab on a board. Failed tests keep the editor open and show the same connection diagnostics as the management page.
Successful changes apply to every app and widget that uses the integration.
When app creation is not permitted, the setup form offers linking an existing accessible app instead of attempting to
create one.
+34
View File
@@ -0,0 +1,34 @@
---
tags:
- Administration
- Logs
- Troubleshooting
---
# Logs
The logs page shows recent Homarr application logs and continues updating as new events occur. Use it to investigate
widget, integration, and server errors without opening the container logs separately.
## Opening the logs page
Navigate to **Management** > **Tools** > **Logs**. The page and widget log links are only available to users with the
**View logs** permission.
When a widget reports an error, **Check logs for more details** opens the logs page in a new tab and marks the time when
the widget displayed the error. The related server error normally appears immediately above that marker. If those logs
have already been removed from the retained history, the marker explains that they are no longer available.
## Retained and live logs
Homarr retains the latest 5,000 log events in Redis. Opening the page first loads this history and then continues with
live events without a gap. The history is bounded, so older events are removed automatically.
The history survives normal application restarts when the configured Redis instance persists its data. For complete
platform-level history, startup failures, or events older than the retained limit, use the logs provided by Docker or
your deployment platform.
## Display controls
The severity selector applies to both retained and live events. Selecting a different level redraws the existing view
using that threshold. The font-size control changes the terminal text size and is remembered in the browser.
+1 -1
View File
@@ -17,7 +17,7 @@ remains available as a compatibility alias, but new clients should always use `/
With MCP, you can ask your AI assistant to manage apps, integrations, users, boards, Docker containers, media servers, DNS settings, and more - all through Homarr's existing tRPC API, now exposed as 50+ AI-callable tools.
The optional [Homarr Assistant](./assistant) uses these same tool definitions internally.
The optional [Assistant](./assistant) uses these same tool definitions internally.
Unlike external MCP clients, it uses the current Homarr session directly and asks for in-conversation
approval before running mutating procedures.
@@ -7,9 +7,13 @@ tags:
# Search Engines
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `CTRL + K`.
Using the search engines, terms can be quickly searched simply by opening the search bar at the top or using the keyboard shortcut `MOD + K`.
The management page provides a searchable list with the shortcut, URL template, source, and available row actions for each engine.
The default **Search** mode lists every configured search engine as a fallback action for the current query. To discover
engines by shortcut, switch to **Web** with `!`. DuckDuckGo bang discovery is intentionally limited to Web mode so an
ordinary search term never expands into similarly named bang providers.
You can add any search engine that has a search URL that accepts a search term as a parameter.
## Create search engine
@@ -26,6 +30,8 @@ The name can be chosen freely. It will be displayed in the search box popover.
Prefix that is used to choose the search engine in the popover using the keyboard.
It is recommended that this value is kept short (for example `g` for Google and `e` for Ecosia).
Type `!` in Spotlight to browse these shortcuts and, when DuckDuckGo bangs are enabled in preferences, discover
additional bang providers.
### Configuration
+21 -1
View File
@@ -13,7 +13,7 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
All settings sections remain visible in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
@@ -128,6 +128,26 @@ You can decide if app status is disabled on all boards.
You can set the default color scheme for users without their own color scheme.
## Branding
Branding settings let administrators adapt Homarr to the identity and visual style of their installation. Changes apply across the application and authentication pages after you save the settings.
### Identity
- **Application name** replaces the default Homarr name in page titles and branded surfaces.
- **Logo** is used in the header and other application branding.
- **Favicon** controls the browser tab icon. When it is empty, Homarr falls back to the configured logo.
### Colors and corners
Choose the primary and secondary colors used by the theme. **Lock primary color** prevents users from overriding the configured primary color. **Default corner style** controls the global radius used by cards, controls, and other interface elements.
### Sign-in page
You can set a greeting and a background image for authentication pages. The overlay slider adjusts the contrast between the background and the sign-in content. The visibility controls independently show or hide the application name, logo, and greeting.
Use an image URL that every user can load before signing in. A URL that requires an authenticated Homarr session will not work on the sign-in page.
## Culture
### Default language
+76 -16
View File
@@ -108,7 +108,7 @@ However, you can create as many groups as you'd like.
### Creating a group
Click on the button `New group` to create a new one.
Homarr will prompt you for a group name.
The group-name form expands above the group list and can be closed without leaving the page.
### Managing a group
@@ -163,19 +163,77 @@ Members can be removed from the group using the remove button at the end:
#### Permissions tab
In this tab, you manage what permissions the members of this group will have.
Currently, the following permissions are available:
Permissions are shown as a matrix with one row per category (boards, apps, integrations, and so on). Each row has a
segmented control that lets you pick a single **access level**, plus a checkbox for each **capability** the category
has.
- Boards
- **Create boards**: Allow members to create boards
- **View all boards**: Allow members to view all boards
- **Modify all boards**: Allow members to modify all boards (Does not include access control and danger zone)
- **Full board access**: Allow members to view, modify, and delete all boards (Including access control and danger zone)
- Integrations
- **Create integrations**: Allow members to create integrations
- **Use all integrations**: Allows members to add any integrations to their boards
- **Interact with any integration**: Allow members to interact with any integration
- **Full integration access**: Allow members to manage, use and interact with any integration
- **Administrator**: Members with this permission have full access to all features and settings
Levels in a category are cumulative: selecting a level grants that level and every level below it in the same
category. Choosing **No access** grants no access level for that category.
##### Capabilities are separate from the access level
A capability is something you can grant on its own, independently of how much of everyone else's data the members
can see. Creating is the usual example: you can let members create their own boards without letting them view or
modify anyone else's boards. Apps additionally have **use all apps**, which lets members put any app on a board
without being able to edit apps.
Some access levels already include a capability through permission inheritance (marked in the table below); the
checkbox is then ticked, disabled, and explains which level includes it.
##### Administration
Selecting the **Administrator** level grants full access to everything. While it is selected, every other category
is disabled and explains that administrators already have that access. Setting the administrator row back to
**No access** re-enables the other categories with the selections they had before.
##### Access levels
The access levels below are cumulative, in ascending order, with the category's capabilities listed separately:
| Category | Access levels (ascending) | Capabilities | Already included at |
| -------------- | ---------------------------------------------------------------------------------------- | ------------------------------- | ---------------------------------------------------- |
| Boards | view all boards &rarr; modify all boards &rarr; full board access | create boards | full board access |
| Apps | modify all apps &rarr; full app access | use all apps, create apps | create apps: modify all apps and full app access; use all apps: full app access |
| Integrations | use all integrations &rarr; interact with any integration &rarr; full integration access | create integrations | full integration access |
| Search engines | modify all search engines &rarr; full search engine access | create search engines | modify all search engines, full search engine access |
| Medias | view all medias &rarr; full media access | upload medias | full media access |
| Other | view logs | — | — |
| Administrator | administrator | — | — |
Apps are the one category where "use" is not a level. Being able to modify every app does **not** imply being able
to put any app on a board — only full app access grants both — so the two are kept independent rather than stacked,
and picking **Modify** never silently grants use of every app.
The levels grant the following:
- **Boards**: view all boards; modify all boards (not access control and danger zone); full board access (view, modify and delete every board, including access control and the danger zone).
- **Apps**: modify all apps; full app access (manage, use and delete any app).
- **Integrations**: use all integrations; interact with any integration; full integration access (manage, use and interact with any integration).
- **Search engines**: modify all search engines; full search engine access (manage and delete any search engine).
- **Medias**: view all medias; full media access (manage and delete any media).
- **Other**: view logs.
##### Presets
Above the rows you can apply a preset to fill in a common configuration in one click:
- **Viewer**: read-only - view all boards and medias, use any app on a board, and use existing integrations.
- **Editor**: day-to-day management - modify all boards and apps, create boards and integrations, interact with any integration, modify search engines, and upload medias, without deleting resources or managing access.
- **Admin**: grants the administrator permission (full access to everything).
When the current selection does not match any preset, a **Custom** badge is shown.
##### Also grants
Some levels include additional permissions automatically. When a level implies extra permissions (for example,
modifying all apps also allows creating apps), a dimmed **Also grants: …** hint is shown below the category row,
listing the implied permissions.
##### Effective permissions
Below the rows, the **Effective permissions** panel can be expanded to review everything members of this group will
be able to do - the full, de-duplicated set of permissions, including everything granted indirectly through the
levels - grouped by category, with a count for each.
Custom Widget authoring, import, migration, preview, secrets, Workshop access, and placement configuration are reserved
for administrators and are not separate assignable group permissions. Board modifiers can still move, resize, change
@@ -201,7 +259,8 @@ An invitation link can be used indefinitely.
### Creating an invitation
Click on the button:
Select the create-invitation button. The expiration form expands above the invitation table instead of opening a
dialog or navigating to another page:
![](./img/create-invitation-button.png)
@@ -211,12 +270,12 @@ The default is 4 hours in the future.
![](./img/invitation-expiry-date.png)
Next, click `Create` and you'll get a confirmation with an ID, Token and a link that can be copied:
Next, click `Create` and the same section shows the ID, Token and a link that can be copied:
![](./img/create-invitation-confirmation.png)
Both ID and Token are also in the URL. Ensure that you copy either ID & Token or the URL - it will never be shown again.
Clicking the `Copy & close` button will copy the URL to your clipboard.
Use **Copy** to copy the URL, then close the section when you are finished.
### Self-onboarding
@@ -235,3 +294,4 @@ If an invitation needs to be deleted before or after the expiry, the delete butt
The link will become invalid.
If the invitation expires, the link will also expire.
The first click changes the action to **Confirm**; the second click deletes the invitation without opening a dialog.
@@ -0,0 +1,47 @@
---
title: "Air Quality & UV"
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { airQualityWidget } from ".";
<WidgetHeader widget={airQualityWidget} categories={["Health", "Weather"]} />
The Air Quality & UV widget shows the current air quality index for a selected location. It adds the UV index,
dominant pollutant, particulate matter and the highest available pollen reading as space allows. Open the advanced
view for a 24-hour AQI and UV chart, all available pollutant and pollen readings, and seven-day daily maxima.
Select the European or US AQI scale, or use the automatic setting. Automatic uses the US scale for viewers whose
locale region is the United States and the European scale everywhere else. The selected scale stays in widget options
instead of being repeated beside every reading. Pollen values are individual measured concentrations; the widget does
not combine them into a single pollen index.
The categories and colors summarize published index bands and are not medical advice. Pollutant concentrations use
micrograms per cubic meter (`µg/m³`), and pollen uses grains per cubic meter.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={airQualityWidget.configuration} />
### Data source
Homarr uses the free Open-Meteo Air Quality API. The widget refreshes active data every 15 minutes and requests a
seven-day forecast for European AQI, US AQI, UV, particulate matter, ozone, nitrogen dioxide, sulphur dioxide, carbon
monoxide, and available alder, birch, grass, mugwort, olive and ragweed pollen. Identical location requests reuse the
server response for 15 minutes. If a refresh fails, Homarr can keep a recent successful response visible for up to 24
hours instead of replacing it with an empty state.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests. If you need to allowlist the
service, use `https://air-quality-api.open-meteo.com`.
@@ -0,0 +1,43 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconWind } from "@tabler/icons-react";
export const airQualityWidget: WidgetDefinition = {
icon: IconWind,
name: "Air Quality & UV",
description: "Displays air quality, ultraviolet index, pollutants and pollen for a location.",
path: "../../widgets/air-quality",
configuration: {
items: [
{
name: "Air quality location",
description: "Location used for air quality, UV and pollen data",
values: "Select a location through search or longitude/latitude",
defaultValue: "Paris / 48.85341, 2.3488",
},
{
name: "AQI standard",
description: "Choose the European or US air quality scale, or select one from the viewer's locale",
values: { type: "select", options: ["Automatic for locale", "European AQI", "US AQI"] },
defaultValue: "Automatic for locale",
},
{
name: "Show UV index",
description: "Display the current UV index and its forecast",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollutant concentrations",
description: "Display particulate matter and gaseous pollutant concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show pollen concentrations",
description: "Display available pollen concentrations",
values: { type: "boolean" },
defaultValue: "yes",
},
],
},
};
+1 -1
View File
@@ -11,7 +11,7 @@ import { appWidget } from ".";
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots
@@ -23,6 +23,8 @@ This widget adapts its display based on the linked integration:
Only options relevant to your linked integration are shown in the configuration panel.
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
### Supported Integrations
<WidgetIntegrations
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
<WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions.
This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. Alerts are updated in real time via WebSocket subscriptions.
### Screenshots
@@ -16,7 +16,7 @@ import beszelSystemGrid from "./img/beszel-system-grid.png";
<WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Resizing the widget adjusts its rows and columns to keep the cards balanced. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
+7 -6
View File
@@ -1,6 +1,6 @@
---
title: "Bookmarks"
description: "Displays multiple app links"
description: "Keeps useful links together"
hide_title: true
---
@@ -13,11 +13,12 @@ import { bookmarksWidget } from ".";
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget allows you to display multiple links in a single widget.
Select one or more existing Apps directly in the widget settings; these bookmarks keep a reference to the App, so changes to its name, icon, or URL are reflected automatically.
For links that do not need an App, add their URLs under **Custom links** instead.
Its normal view adapts each configured layout to the available width and height: lower-priority hostnames are hidden before names or icons, and larger collections scroll instead of crushing cards.
Advanced view shows every bookmark as a roomy card with its full title, hostname, icon, and complete description regardless of the compact layout and visibility choices.
The bookmarks widget keeps direct URLs and Homarr Apps in one ordered list.
Paste a URL and press Enter, paste several comma-separated or one-per-line URLs, or search for an existing App.
URLs do not need an App record; Homarr uses the site's favicon and falls back to a link icon when it cannot load one.
Bookmarks selected from Apps continue to use the App name, URL, icon, and description.
**Adaptive** layout is the practical default: it changes between icons, compact rows, and tiles as the widget changes shape. Every compact item exposes its destination on hover, while advanced view expands items into descriptive cards.
### Screenshots
+18 -12
View File
@@ -4,7 +4,7 @@ import { IconBookmark } from "@tabler/icons-react";
export const bookmarksWidget: WidgetDefinition = {
icon: IconBookmark,
name: "Bookmarks",
description: "Displays multiple app links",
description: "Keeps useful links together",
path: "../../widgets/bookmarks",
configuration: {
items: [
@@ -16,9 +16,21 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Layout",
description: "Layout in which the bookmarks are displayed.",
values: { type: "select", options: ["Vertical", "Horizontal", "Grid", "Grid horizontal"] },
defaultValue: "Vertical",
description: "How bookmarks use the available widget space.",
values: { type: "select", options: ["Adaptive", "Vertical", "Horizontal", "Grid", "Compact grid", "Icons"] },
defaultValue: "Adaptive",
},
{
name: "Appearance",
description: "Card surface style.",
values: { type: "select", options: ["Soft", "Filled", "Outline", "Plain"] },
defaultValue: "Soft",
},
{
name: "Card spacing",
description: "Space between bookmark cards.",
values: { type: "select", options: ["Extra small", "Small", "Medium", "Large", "Extra large"] },
defaultValue: "Extra small",
},
{
name: "Hide title",
@@ -46,16 +58,10 @@ export const bookmarksWidget: WidgetDefinition = {
},
{
name: "Bookmarks",
description: "List of bookmarks to display in the widget.",
values: "Select one or more Apps directly, then order them as you like.",
description: "URLs and existing Apps shown by the widget.",
values: "Paste one or many URLs, or find an existing App. Drag to reorder.",
defaultValue: "No bookmarks selected",
},
{
name: "Custom links",
description: "URLs to display without first creating an App.",
values: "List of URLs.",
defaultValue: "No custom links",
},
],
},
};
@@ -69,6 +69,7 @@ import advancedView from "./img/advanced-view.png";
### Interactions
- Colored dots directly below a date mark its events. They scale with the widget and are hidden when the calendar is too short.
- Hover over a marked date to preview its events in a popover.
- The advanced view replaces the month grid with a grouped event list, keeps every event attributed to its source integration, and expands titles, locations, and descriptions. Use its previous and next controls to change months.
- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
+21 -1
View File
@@ -15,6 +15,26 @@ import { clockWidget } from ".";
The clock widget displays the current date and time in a customizable format.
Advanced view turns it into a World Clock. The configured primary timezone stays prominent while New York, Paris, and
Tokyo are included as secondary defaults. You can add, rename, remove, and reorder up to six cities in widget options;
London, Sydney, and UTC are also available as quick presets.
The digital advanced view keeps the primary timezone prominent and presents every secondary city as a concise,
comparable card. The primary clock includes a segmented 24-hour rail with a current-time marker, elapsed and remaining
time, six-hour ticks, and a legend for night, dawn, day, and dusk. These phases are based on local clock time and are not
astronomical sunrise or sunset calculations. Secondary cards stop after their localized date and timezone offsets.
The compact widget remains a single numeric clock. The time-format selector shows common Day.js presets with live
examples and applies to the primary clock. Choosing a format with seconds enables second-level updates; 12-hour and
24-hour display are selected through those same formats. The date-format selector likewise
shows live Day.js examples and their tokens. Secondary cities always include a short localized date and a
Yesterday, Today, or Tomorrow label relative to the primary timezone. UTC and relative offsets follow current timezone
rules, including daylight-saving changes.
Weather remains optional and uses its own configured location rather than a World Clock city. Its icon is static by
default and can be animated explicitly; reduced-motion preferences always disable animation. An unsupported saved
timezone is identified without preventing valid clocks from rendering.
### Screenshots
import localClock from "./img/local.png";
@@ -27,7 +47,7 @@ import tokyoClock from "./img/tokyo-12-hour.png";
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div>
<WidgetInteractionGuide advancedView={false} />
<WidgetInteractionGuide />
### Adding the widget
+35 -22
View File
@@ -20,18 +20,6 @@ export const clockWidget: WidgetDefinition = {
values: { type: "string" },
defaultValue: "-",
},
{
name: "24-hour format",
description: "Use 24-hour format instead of 12-hour format.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Display seconds",
description: "Whether to show seconds in the clock.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Use fixed timezone",
description: "Select custom timezone to display instead of the client timezone.",
@@ -52,21 +40,46 @@ export const clockWidget: WidgetDefinition = {
},
{
name: "Date format",
description: "Format of the date displayed below the clock.",
values: "A collection of date formats, e.g. 'DD/MM/YYYY', 'MM/DD/YYYY', etc.",
description: "Choose a common Day.js date format with a live example and its tokens.",
values: "YYYY-MM-DD, DD/MM/YYYY, MM/DD/YYYY, or common long and short date formats",
defaultValue: "dddd, MMMM D",
},
{
name: "Custom time format",
description: "Format of the time displayed in the clock.",
values: 'ISO 8601 format, e.g. "HH:mm:ss", "hh:mm A", etc.',
defaultValue: "-",
name: "Time format",
description:
"Choose a common Day.js time format with a live example. The format controls 12/24-hour display and seconds.",
values: "HH:mm, HH:mm:ss, h:mm A, or h:mm:ss A",
defaultValue: "HH:mm",
},
{
name: "Custom date format",
description: "Format of the date displayed below the clock.",
values: 'ISO 8601 format, e.g. "YYYY-MM-DD", "DD/MM/YYYY", etc.',
defaultValue: "-",
name: "Show weather",
description: "Show a compact weather summary from an independently configured location.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Weather location",
description: 'Location used by the weather summary. Only shown if "Show weather" is enabled.',
values: "A searched city or custom latitude and longitude",
defaultValue: "Brisbane",
},
{
name: "Use Fahrenheit",
description: 'Use Fahrenheit in the weather summary. Only shown if "Show weather" is enabled.',
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Animate weather icon",
description: "Animate the weather icon unless the device requests reduced motion.",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "World clock cities",
description: "Add, label, and order up to six IANA timezones for advanced view.",
values: "Up to six labeled IANA timezones",
defaultValue: "New York, Paris, and Tokyo",
},
],
},
+1 -1
View File
@@ -15,7 +15,7 @@ import { coolifyIntegration } from "@site/docs/integrations/coolify";
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version.
The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. Compact resource badges and footer details remain visible without height-based hiding.
### Supported Integrations
@@ -0,0 +1,44 @@
---
title: "Countdowns"
description: "Tracks upcoming events and recurring anniversaries."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { countdownWidget } from ".";
<WidgetHeader widget={countdownWidget} categories={["Time"]} />
The Countdowns widget tracks up to 20 events without an integration. Add each event in widget options, choose its local
date and time, and select an IANA timezone. Events can be reordered by dragging them.
The compact widget shows the first configured event, so dragging an event to the top selects the countdown displayed on
the board. Advanced view uses that same event for its larger summary and preserves the configured order within its
upcoming and completed groups. List cards keep their occurrence date at the bottom, below the optional progress bar.
One-time events remain completed after their target. A yearly event keeps the same local date and wall-clock time in its
configured timezone, including when daylight-saving offsets change. A February 29 event waits until the next leap year.
If a configured local time falls in a daylight-saving gap, the first valid time afterward is used; if it occurs twice,
the earlier occurrence is used.
Progress is optional. One-time events need a start date before a percentage can be calculated. Yearly events show
progress from the previous occurrence to the next. When enabled, seconds remain visible for every upcoming event.
Browser notifications are an optional local preference in advanced view and are disabled by default. Permission is
requested only after you enable notifications, and alerts can fire once per occurrence while Homarr is open. Browser
storage uses the board and widget identifiers to keep preferences separate and prevent duplicate alerts; clearing site
data resets them. Widget previews do not persist alert preferences.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={countdownWidget.configuration} />
+31
View File
@@ -0,0 +1,31 @@
import { WidgetDefinition } from "@site/src/types";
import { IconHourglass } from "@tabler/icons-react";
export const countdownWidget: WidgetDefinition = {
icon: IconHourglass,
name: "Countdowns",
description: "Tracks upcoming events and recurring anniversaries.",
path: "../../widgets/countdown",
configuration: {
items: [
{
name: "Events",
description: "Add, edit, and order one-time or yearly events with a date, time, and IANA timezone.",
values: "Up to 20 events",
defaultValue: "No events",
},
{
name: "Show progress",
description: "Show progress from an optional start date, or between yearly occurrences.",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show seconds",
description: "Keep seconds visible for every upcoming event, including events more than a day away.",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height. Bulk actions keep successful results visible and report the number of failures.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
### Screenshots
@@ -7,20 +7,15 @@ hide_title: true
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem";
import { dockerContainersWidget } from ".";
import { dockerIntegration } from "@site/docs/integrations/docker";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them.
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
:::info
This widget can only be used with administrator privileges.
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
:::
### Screenshots
@@ -29,16 +24,6 @@ import dockerTable from "./img/table.png";
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: dockerIntegration,
},
]}
/>
### Interactions
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
@@ -8,6 +8,12 @@ export const dockerContainersWidget: WidgetDefinition = {
path: "../../widgets/docker-containers",
configuration: {
items: [
{
name: "Docker environments",
description: "Select one or more automatically discovered Docker environments, or leave empty to show all",
values: "Any Docker socket or host configured for Homarr",
defaultValue: "All Docker environments",
},
{
name: "Columns to show",
description: "Select which columns are visible in the table",
+5 -6
View File
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
### Features
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
@@ -72,11 +72,10 @@ import filterPanelImg from "./img/filter-panel.png";
:::note
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
from the footer.
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
the filter panel available from the footer.
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
@@ -16,6 +16,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
### Screenshots
![Firewall Widget](img/firewall_widget.png)
+2
View File
@@ -67,6 +67,8 @@ add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domai
- **Wikipedia homepage:** https://en.wikipedia.org/wiki/Main_Page
- **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
Homarr passes the configured URL directly to the browser. The iframe sandbox and configured permissions still apply.
### Configuration
<WidgetConfig configuration={iframeWidget.configuration} />
@@ -15,6 +15,8 @@ import { immichServerStatsWidget } from ".";
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
### Screenshots
import screen from "./img/screen.png";
@@ -14,6 +14,8 @@ import { indexerManagerWidget } from ".";
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
### Screenshots
import bigItem from "./img/big.png";
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
Provides a list of newly added media or upcoming releases from your media server.
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
### Supported Integrations
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
### Screenshots
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
### Screenshots
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
]}
/>
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
<WidgetInteractionGuide />
@@ -20,7 +20,7 @@ import { gotifyIntegration } from "@site/docs/integrations/gotify";
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
The notifications widget allows you to display notifications from various integrations in a unified view.
The notifications widget allows you to display notifications from various integrations in a unified view. Compact mode presents them as a vertical list of padded notification cards.
### Screenshots
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
<WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
### Supported Integrations
+2 -2
View File
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
The releases widget enables you to track a list of repositories and see their respective latest release.
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
It also supports importing releases from your local docker containers.
### Screenshots
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
+1 -1
View File
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
### Screenshots
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
The entity state enables you to display the current state of an entity.
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
### Supported Integrations
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
Displays CPU, RAM and network of your host in real time
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
### Screenshots
+54
View File
@@ -0,0 +1,54 @@
---
title: "Timer / Pomodoro"
description: "Runs a simple timer or a configurable Pomodoro sequence."
hide_title: true
---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { timerWidget } from ".";
<WidgetHeader widget={timerWidget} categories={["Productivity", "Time"]} />
The Timer / Pomodoro widget runs entirely in your browser and does not require an integration. The compact widget
always keeps its start or pause button and reset button visible. A running timer uses a saved deadline, so reopening
the board or returning to a background tab restores the correct remaining time instead of restarting the countdown.
Timer mode uses one configured duration. Pomodoro mode starts with a focus session, follows most completed focus
sessions with a short break, and uses a long break after the configured number of focus sessions. Automatic break and
focus starts are separate options and are disabled by default. When automatic starts are enabled, elapsed phases are
reconciled from their original deadlines after the board returns to the foreground.
Advanced view shows the current sequence, configured durations, and local alert preferences. Browser notifications are
requested only when enabled. Notification and sound preferences, live progress, deadlines, and cycle position are
stored locally using the board and widget identifiers. They are separate for each browser or device, are available to
guest viewers, and are removed by clearing site data. Widget previews are ephemeral and do not write local timer state.
Timer sounds are enabled by default and use the same low-volume Tiks sound system as onboarding: start and pause use
rising and falling soft-theme cues, reset uses a short click, and phase completion uses the glass theme's resonant
notification ding. You can disable them in advanced view. The cues respect the device's reduced-motion preference and
supplement the visible timer state rather than replacing it.
When automatic start is disabled, a completed phase labels the next step as ready and flashes the widget edges with the
board's secondary color. The border glow and completion chime run together as a five-beat alarm burst at
300-millisecond intervals, then stop. Reduced-motion and forced-colors modes replace the flash with a static border.
The current phase stays pinned to the compact widget's top-left corner and wraps when needed. Timer text and controls
use the dashboard theme's responsive size scale, with secondary details hidden when the widget preview is narrow or
short.
Alerts can fire while Homarr is open. Browser and operating-system notification settings may still suppress them.
The timer's mode, durations, session count, and automatic-start behavior are permanent board settings and are edited
from the widget context menu.
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={timerWidget.configuration} />
+61
View File
@@ -0,0 +1,61 @@
import type { WidgetDefinition } from "@site/src/types";
import { IconClockPlay } from "@tabler/icons-react";
export const timerWidget: WidgetDefinition = {
icon: IconClockPlay,
name: "Timer / Pomodoro",
description: "Runs a simple timer or a configurable Pomodoro sequence.",
path: "../../widgets/timer",
configuration: {
items: [
{
name: "Mode",
description: "Run one reusable timer or a repeating focus and break sequence",
values: { type: "select", options: ["Timer", "Pomodoro"] },
defaultValue: "Pomodoro",
},
{
name: "Timer minutes",
description: "Duration of the reusable timer",
values: "1-1440 minutes",
defaultValue: "10",
},
{
name: "Focus minutes",
description: "Duration of each Pomodoro focus session",
values: "1-180 minutes",
defaultValue: "25",
},
{
name: "Short break minutes",
description: "Duration of breaks between most focus sessions",
values: "1-60 minutes",
defaultValue: "5",
},
{
name: "Long break minutes",
description: "Duration of the break at the end of a Pomodoro cycle",
values: "1-180 minutes",
defaultValue: "15",
},
{
name: "Focus sessions before a long break",
description: "Number of completed focus sessions in one Pomodoro cycle",
values: "1-12 sessions",
defaultValue: "4",
},
{
name: "Automatically start breaks",
description: "Start the next short or long break when a focus session completes",
values: { type: "boolean" },
defaultValue: "no",
},
{
name: "Automatically start focus sessions",
description: "Start the next focus session when a break completes",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
The Timetable widget provides real-time departure times for a selected station.
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
<WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -15,7 +15,7 @@ import { umamiIntegration } from "@site/docs/integrations/umami";
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
### Supported Integrations
+1 -1
View File
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
<WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. Compact mode shows every configured ring and statistic without dropping them based on widget height.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
+18 -15
View File
@@ -13,23 +13,21 @@ import { weatherWidget } from ".";
<WidgetHeader widget={weatherWidget} categories={["Weather"]} />
The Weather Widget uses your location to display the current, highest and lowest temperature.
In the compact view, select a condition icon or forecast day to open details with a mouse, keyboard or touch.
The compact forecast shows up to the configured number of days. The advanced view combines current conditions with the
available daily forecast, including humidity, wind, gusts, sunrise and sunset. Both views reuse the same cached Open
Meteo response.
The Weather Widget displays current conditions and a responsive forecast for a selected location. New widgets start
with the city and a three-day forecast visible. As the widget gets smaller, it keeps the current condition and
temperature readable before removing secondary details or forecast days.
### Screenshots
Select a forecast day to inspect precipitation, apparent temperature, humidity, wind, UV index, sunrise, sunset and
daylight duration with a mouse, keyboard or touch. Open the advanced view for a current-condition summary, a 24-hour
temperature and precipitation chart, and the complete seven-day forecast. The chart has an equivalent data table for
assistive technologies.
import forecastWeather from "./img/forecast.png";
import currentWeather from "./img/current.png";
import advancedWeather from "./img/advanced-view.png";
The **Show humidity** option controls humidity across the compact current-conditions view, advanced summary, hourly
details, and daily forecast details. Forecast cards use a roomier adaptive grid in advanced view, with four columns on
the standard desktop surface and fewer columns as the surface narrows.
<div className="flex flex-col gap-1">
<img src={forecastWeather} alt="Weather compact forecast" width={238} height={112} />
<img src={currentWeather} alt="Weather compact current conditions" width={198} height={93} />
<img src={advancedWeather} alt="Weather advanced view with seven-day forecast details" width={800} height={484} />
</div>
Weather icons are static by default. Animation can be enabled in the widget configuration, but the operating system's
reduced-motion preference always disables it.
<WidgetInteractionGuide />
@@ -43,5 +41,10 @@ import advancedWeather from "./img/advanced-view.png";
### Data source
We use the Open Meteo API to fetch the weather and locations.
We use the Open Meteo API to fetch the weather and locations. Active weather data refreshes every ten minutes, while
identical location requests can reuse a server response for five minutes. A manual refresh may therefore return the
same still-current server response. If a refresh fails, Homarr keeps the last successful data visible and shows an
error indicator.
Set `NO_EXTERNAL_CONNECTION=true` to disable this and other internet-backed requests.
If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
+19 -3
View File
@@ -28,8 +28,24 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Show humidity",
description: "Display humidity in current conditions, hourly details, and daily forecasts",
values: {
type: "boolean",
},
defaultValue: "yes",
},
{
name: "Animate weather icons",
description: "Animate condition icons unless the operating system requests reduced motion",
values: {
type: "boolean",
},
defaultValue: "no",
},
{
name: "Use imperial speed units",
description: "Display wind speed in mph instead of km/h",
@@ -59,7 +75,7 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Has forecast",
@@ -67,13 +83,13 @@ export const weatherWidget: WidgetDefinition = {
values: {
type: "boolean",
},
defaultValue: "no",
defaultValue: "yes",
},
{
name: "Amount of forecast days",
description: "Number of days to show in the forecast",
values: "1-7",
defaultValue: "5",
defaultValue: "3",
},
],
},
+4 -2
View File
@@ -38,8 +38,10 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
We would rather become millionaires in a more ethical manner.
+3
View File
@@ -72,6 +72,9 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
@@ -1,4 +1,4 @@
import { IconCloud, IconWind, IconArrowUpRight, IconArrowDownRight } from "@tabler/icons-react";
import { IconCloud, IconCloudRain, IconSun } from "@tabler/icons-react";
import { CommonWidgetProps, WidgetCard } from "./card";
import clsx from "clsx";
@@ -6,21 +6,27 @@ export const WeatherWidget = ({ className }: CommonWidgetProps) => {
return (
<WidgetCard width={1} className={clsx("gap-2", className)}>
<div className="flex gap-2 justify-center items-center">
<IconCloud size={20} />
<span className="text-xl font-bold">10.8°C</span>
</div>
<div className="flex gap-2 justify-center items-center">
<IconWind size={14} />
<span className="text-sm">5 km/h</span>
</div>
<div className="flex gap-1 justify-center items-center">
<div className="flex justify-center items-center">
<IconArrowUpRight size={14} />
<span className="text-sm">13.5°C</span>
<IconSun size={22} />
<div className="flex flex-col leading-none">
<span className="text-xl font-bold">18.4°C</span>
<span className="text-xs text-muted-foreground">Paris</span>
</div>
<div className="flex justify-center items-center">
<IconArrowDownRight size={14} />
<span className="text-sm">6.4°C</span>
</div>
<div className="flex justify-evenly items-center w-full">
<div className="flex flex-col items-center text-xs">
<span>Mon</span>
<IconSun size={16} />
<span>21°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Tue</span>
<IconCloud size={16} />
<span>19°</span>
</div>
<div className="flex flex-col items-center text-xs">
<span>Wed</span>
<IconCloudRain size={16} />
<span>16°</span>
</div>
</div>
</WidgetCard>
+354
View File
@@ -2106,6 +2106,9 @@
"enum": [
"clock",
"weather",
"airQuality",
"countdown",
"timer",
"app",
"iframe",
"video",
@@ -3450,6 +3453,9 @@
"enableRightClickOnWidgets": {
"type": "boolean"
},
"headerPreferences": {
"type": "string"
},
"defaultSearchEngineId": {
"anyOf": [
{
@@ -3485,6 +3491,7 @@
"firstDayOfWeek",
"pingIconsEnabled",
"enableRightClickOnWidgets",
"headerPreferences",
"defaultSearchEngineId",
"openSearchInNewTab",
"ddgBangs",
@@ -4343,6 +4350,353 @@
}
}
},
"/api/users/header-preferences": {
"patch": {
"operationId": "userRouter-changeHeaderPreferences",
"tags": ["users"],
"security": [
{
"apikey": []
}
],
"parameters": [],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"headerPreferences": {
"oneOf": [
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 3
},
"visible": {
"type": "boolean"
},
"searchDisplay": {
"type": "string",
"enum": ["input", "icon"]
},
"logoDisplay": {
"default": "logoAndText",
"type": "string",
"enum": ["logo", "logoAndText"]
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"center": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
},
"right": {
"maxItems": 100,
"type": "array",
"items": {
"oneOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "builtin"
},
"id": {
"type": "string",
"enum": [
"logo",
"search",
"home",
"boardSwitcher",
"assistant",
"docker",
"boardEdit",
"boardSettings",
"settings",
"themeToggle",
"user"
]
}
},
"required": ["type", "id"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"const": "board"
},
"boardId": {
"type": "string",
"minLength": 1,
"maxLength": 255
}
},
"required": ["type", "boardId"],
"additionalProperties": false
}
],
"type": "object"
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "searchDisplay", "zones"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 1
},
"visible": {
"type": "boolean"
},
"items": {
"minItems": 1,
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["version", "visible", "items"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"version": {
"type": "number",
"const": 2
},
"visible": {
"type": "boolean"
},
"zones": {
"type": "object",
"properties": {
"left": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"center": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
},
"right": {
"maxItems": 6,
"type": "array",
"items": {
"type": "string",
"enum": ["logo", "search", "boardSwitcher", "assistant", "docker", "user"]
}
}
},
"required": ["left", "center", "right"],
"additionalProperties": false
}
},
"required": ["version", "visible", "zones"],
"additionalProperties": false
}
],
"type": "object"
}
},
"required": ["id", "headerPreferences"]
}
}
}
},
"responses": {
"200": {
"description": "Successful response",
"content": {
"application/json": {
"schema": {}
}
}
},
"400": {
"description": "Invalid input data",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.BAD_REQUEST"
}
}
}
},
"401": {
"description": "Authorization not provided",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.UNAUTHORIZED"
}
}
}
},
"403": {
"description": "Insufficient access",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.FORBIDDEN"
}
}
}
},
"404": {
"description": "Not found",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.NOT_FOUND"
}
}
}
},
"500": {
"description": "Internal server error",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/error.INTERNAL_SERVER_ERROR"
}
}
}
}
}
}
},
"/api/users/ddg-bangs": {
"patch": {
"operationId": "userRouter-changeDdgBangs",
+2
View File
@@ -72,10 +72,12 @@
"@rexa-developer/tiks": "catalog:",
"@scalar/api-reference-react": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/query-sync-storage-persister": "catalog:",
"@tanstack/react-devtools": "catalog:",
"@tanstack/react-query": "catalog:",
"@tanstack/react-query-devtools": "catalog:",
"@tanstack/react-query-next-experimental": "catalog:",
"@tanstack/react-query-persist-client": "catalog:",
"@trpc/client": "catalog:",
"@trpc/next": "catalog:",
"@trpc/react-query": "catalog:",
@@ -0,0 +1,53 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1920 1080" role="img" aria-labelledby="title description">
<title id="title">Abstract homelab network</title>
<description id="description">A dark indigo field with subtle connected nodes and cyan signal paths.</description>
<defs>
<radialGradient id="base" cx="50%" cy="40%" r="80%">
<stop offset="0" stop-color="#172047"/>
<stop offset="0.48" stop-color="#0b1028"/>
<stop offset="1" stop-color="#060814"/>
</radialGradient>
<radialGradient id="indigoGlow">
<stop offset="0" stop-color="#748ffc" stop-opacity="0.32"/>
<stop offset="1" stop-color="#748ffc" stop-opacity="0"/>
</radialGradient>
<radialGradient id="cyanGlow">
<stop offset="0" stop-color="#3bc9db" stop-opacity="0.22"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0"/>
</radialGradient>
<linearGradient id="signal" x1="0" y1="0" x2="1" y2="1">
<stop stop-color="#748ffc" stop-opacity="0.05"/>
<stop offset="0.5" stop-color="#748ffc" stop-opacity="0.34"/>
<stop offset="1" stop-color="#3bc9db" stop-opacity="0.05"/>
</linearGradient>
<filter id="blur">
<feGaussianBlur stdDeviation="55"/>
</filter>
</defs>
<rect width="1920" height="1080" fill="url(#base)"/>
<ellipse cx="330" cy="90" rx="560" ry="430" fill="url(#indigoGlow)" filter="url(#blur)"/>
<ellipse cx="1680" cy="940" rx="620" ry="400" fill="url(#cyanGlow)" filter="url(#blur)"/>
<g fill="none" stroke="url(#signal)" stroke-width="1.5">
<path d="M-80 825C240 570 475 760 720 510s470-165 650-330 330-85 630-250"/>
<path d="M-120 1010C220 740 470 920 780 660s520-105 700-270 300-125 560-180"/>
<path d="M110 40c180 190 350 220 545 130s390-55 520 80 320 155 620 60" opacity=".45"/>
</g>
<g fill="#9aa8ff" opacity=".48">
<circle cx="248" cy="660" r="3"/>
<circle cx="718" cy="511" r="4"/>
<circle cx="1118" cy="287" r="3"/>
<circle cx="1450" cy="110" r="4"/>
</g>
<g fill="#5ee0ee" opacity=".42">
<circle cx="435" cy="850" r="3"/>
<circle cx="908" cy="604" r="4"/>
<circle cx="1350" cy="510" r="3"/>
<circle cx="1715" cy="285" r="4"/>
</g>
<g fill="none" stroke="#aeb8ff" stroke-opacity=".055">
<circle cx="330" cy="90" r="260"/>
<circle cx="330" cy="90" r="345"/>
<circle cx="1680" cy="940" r="250"/>
<circle cx="1680" cy="940" r="335"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.4 KiB

@@ -1,6 +1,7 @@
"use client";
import type { PropsWithChildren } from "react";
import { useMemo } from "react";
import type { MantineColorScheme, MantineColorSchemeManager } from "@mantine/core";
import { DirectionProvider, MantineProvider, v8CssVariablesResolver } from "@mantine/core";
import dayjs from "dayjs";
@@ -10,18 +11,23 @@ import { useSession } from "@homarr/auth/client";
import { parseCookies, setClientCookie } from "@homarr/common";
import { colorSchemeCookieKey } from "@homarr/definitions";
import { theme } from "@homarr/ui";
import type { ServerSettings } from "@homarr/server-settings";
import { createBrandTheme } from "~/theme/branding";
export const CustomMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme }>) => {
branding,
}: PropsWithChildren<{ defaultColorScheme: MantineColorScheme; branding?: ServerSettings["branding"] }>) => {
const manager = useColorSchemeManager();
const configuredTheme = useMemo(() => (branding ? createBrandTheme(branding) : theme), [branding]);
return (
<DirectionProvider>
<MantineProvider
defaultColorScheme={defaultColorScheme}
colorSchemeManager={manager}
theme={theme}
theme={configuredTheme}
cssVariablesResolver={v8CssVariablesResolver}
>
{children}
@@ -0,0 +1,131 @@
import { dehydrate, QueryClient } from "@tanstack/react-query";
import type { PersistedClient } from "@tanstack/react-query-persist-client";
import { afterEach, describe, expect, test, vi } from "vitest";
import {
createSessionQueryPersistence,
getQueryPersistenceStorageKey,
queryPersistenceBuster,
shouldPersistDashboardQuery,
} from "./query-persistence";
const createSuccessfulQuery = (queryKey: readonly unknown[], data: unknown) => {
const queryClient = new QueryClient();
queryClient.setQueryData(queryKey, data);
const query = queryClient.getQueryCache().find({ queryKey });
if (!query) throw new Error("Expected query to be present");
return { query, queryClient };
};
const createMemoryStorage = (): Storage => {
const values = new Map<string, string>();
return {
get length() {
return values.size;
},
clear: () => values.clear(),
getItem: (key) => values.get(key) ?? null,
key: (index) => Array.from(values.keys())[index] ?? null,
removeItem: (key) => values.delete(key),
setItem: (key, value) => values.set(key, value),
};
};
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe("dashboard query persistence", () => {
test("keeps usable dashboard data and skips volatile or unrelated queries", () => {
const widget = createSuccessfulQuery([["widget", "weather", "atLocation"], { type: "query" }], { temp: 21 });
const supporting = createSuccessfulQuery([["app", "byIds"], { type: "query" }], [{ id: "app-1" }]);
const ping = createSuccessfulQuery([["widget", "app", "ping"], { type: "query" }], { status: "online" });
const beszel = createSuccessfulQuery([["widget", "beszel", "getSystemStats"], { type: "query" }], []);
const customApi = createSuccessfulQuery([["widget", "customApi", "getData"], { type: "query" }], {
apiKey: "secret",
});
const unrelated = createSuccessfulQuery([["board", "getBoardByName"], { type: "query" }], { name: "Home" });
const pendingClient = new QueryClient();
const pending = pendingClient.getQueryCache().build(pendingClient, {
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
queryFn: () => Promise.resolve([]),
});
const failed = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], []);
failed.query.setState({ ...failed.query.state, error: new Error("refresh failed"), status: "error" });
expect(shouldPersistDashboardQuery(widget.query)).toBe(true);
expect(shouldPersistDashboardQuery(supporting.query)).toBe(true);
expect(shouldPersistDashboardQuery(ping.query)).toBe(false);
expect(shouldPersistDashboardQuery(beszel.query)).toBe(false);
expect(shouldPersistDashboardQuery(customApi.query)).toBe(false);
expect(shouldPersistDashboardQuery(unrelated.query)).toBe(false);
expect(shouldPersistDashboardQuery(pending)).toBe(false);
expect(shouldPersistDashboardQuery(failed.query)).toBe(true);
});
test("round-trips dashboard data with SuperJSON in an isolated session scope", async () => {
vi.useFakeTimers();
const storage = createMemoryStorage();
const userA = createSessionQueryPersistence("user-a", storage);
const userB = createSessionQueryPersistence("user-b", storage);
const { queryClient } = createSuccessfulQuery([["widget", "calendar", "findAllEvents"], { type: "query" }], {
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
const cachedQuery = queryClient.getQueryCache().find({
queryKey: [["widget", "calendar", "findAllEvents"], { type: "query" }],
});
if (!cachedQuery) throw new Error("Expected cached query to be present");
cachedQuery.setState({ ...cachedQuery.state, error: new Error("refresh failed"), status: "error" });
queryClient.getMutationCache().build(
queryClient,
{ mutationKey: ["integration", "create"], mutationFn: async () => undefined },
{
context: undefined,
data: undefined,
error: null,
failureCount: 0,
failureReason: null,
isPaused: true,
status: "pending",
submittedAt: Date.now(),
variables: { apiKey: "secret" },
},
);
const persistedClient: PersistedClient = {
timestamp: Date.now(),
buster: queryPersistenceBuster,
clientState: dehydrate(queryClient, userA.dehydrateOptions),
};
expect(persistedClient.clientState.mutations).toHaveLength(0);
await userA.persister.persistClient(persistedClient);
await vi.advanceTimersByTimeAsync(1_000);
const restored = await userA.persister.restoreClient();
expect(restored?.clientState.queries[0]?.state.data).toEqual({
startsAt: new Date("2026-08-22T12:00:00.000Z"),
});
expect(restored?.clientState.queries[0]?.state.status).toBe("success");
expect(restored?.clientState.queries[0]?.state.error).toBeNull();
expect(await userB.persister.restoreClient()).toBeUndefined();
await userA.persister.removeClient();
expect(storage.getItem(getQueryPersistenceStorageKey("user-a"))).toBeNull();
});
test("treats unavailable session storage as an empty cache", async () => {
const blockedStorage = createMemoryStorage();
blockedStorage.getItem = () => {
throw new DOMException("blocked", "SecurityError");
};
blockedStorage.removeItem = () => {
throw new DOMException("blocked", "SecurityError");
};
const persistence = createSessionQueryPersistence("blocked", blockedStorage);
expect(await persistence.persister.restoreClient()).toBeUndefined();
expect(() => persistence.persister.removeClient()).not.toThrow();
});
});
@@ -0,0 +1,97 @@
import type { QueryKey } from "@tanstack/react-query";
import { createSyncStoragePersister } from "@tanstack/query-sync-storage-persister";
import { removeOldestQuery } from "@tanstack/react-query-persist-client";
import type { PersistedClient, PersistQueryClientProviderProps } from "@tanstack/react-query-persist-client";
import { parse, stringify } from "superjson";
import { isPersistableDashboardQueryKey, queryCacheDefaultGcTimeMs } from "@homarr/api/query-cache";
export const queryPersistenceBuster = "v6-dashboard-data";
const queryPersistenceStoragePrefix = "homarr:widget-query-cache";
export const getQueryPersistenceStorageKey = (scope: string | null) =>
`${queryPersistenceStoragePrefix}:${encodeURIComponent(scope ?? "anonymous")}`;
interface PersistableQuery {
queryKey: QueryKey;
state: { data: unknown; status: string };
}
export const shouldPersistDashboardQuery = (query: PersistableQuery) =>
query.state.data !== undefined && isPersistableDashboardQueryKey(query.queryKey);
const serializePersistedClient = (client: PersistedClient) =>
stringify({
...client,
clientState: {
...client.clientState,
queries: client.clientState.queries.map((query) => ({
...query,
state: {
...query.state,
error: null,
errorUpdateCount: 0,
errorUpdatedAt: 0,
fetchFailureCount: 0,
fetchFailureReason: null,
status: "success",
},
})),
},
});
const getSessionStorage = () => {
if (typeof window === "undefined") return undefined;
try {
return window.sessionStorage;
} catch {
return undefined;
}
};
const protectStorage = (storage: Storage | undefined) => {
if (!storage) return undefined;
return {
getItem(key: string) {
try {
return storage.getItem(key);
} catch {
return null;
}
},
setItem(key: string, value: string) {
storage.setItem(key, value);
},
removeItem(key: string) {
try {
storage.removeItem(key);
} catch {
return undefined;
}
},
};
};
export type SessionQueryPersistence = PersistQueryClientProviderProps["persistOptions"];
export const createSessionQueryPersistence = (
scope: string | null,
storage: Storage | undefined = getSessionStorage(),
): SessionQueryPersistence => ({
persister: createSyncStoragePersister({
storage: protectStorage(storage),
key: getQueryPersistenceStorageKey(scope),
serialize: serializePersistedClient,
deserialize: parse,
retry: removeOldestQuery,
}),
maxAge: queryCacheDefaultGcTimeMs,
buster: queryPersistenceBuster,
dehydrateOptions: {
shouldDehydrateMutation: () => false,
shouldDehydrateQuery: shouldPersistDashboardQuery,
},
});
@@ -1,10 +1,11 @@
"use client";
import type { PropsWithChildren } from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import dynamic from "next/dynamic";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { QueryCache, QueryClient } from "@tanstack/react-query";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
import { PersistQueryClientProvider } from "@tanstack/react-query-persist-client";
import {
createWSClient,
httpBatchStreamLink,
@@ -24,6 +25,8 @@ import type { AppRouter } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import {
dashboardSupportingQueryPolicies,
isTrpcForbiddenError,
isWidgetDataQueryKey,
queryCacheDefaultGcTimeMs,
queryCacheDefaultRefetchIntervalMs,
queryCacheDefaultStaleTimeMs,
@@ -35,6 +38,8 @@ import { showWarningNotification } from "@homarr/notifications";
import { widgetQueryRefetchIntervals } from "@homarr/widgets/refetch-intervals";
import { getSessionQueryScope, SessionQueryScopeGuard } from "./session-query-scope";
import { createSessionQueryPersistence } from "./query-persistence";
import type { SessionQueryPersistence } from "./query-persistence";
import { createQueryRetry } from "./query-retry";
const DevelopmentTools =
@@ -67,21 +72,29 @@ export function TRPCReactProvider({ children }: PropsWithChildren) {
const { data: session } = useSession();
const sessionQueryScope = getSessionQueryScope(session);
const [initialSessionQueryScope] = useState(() => sessionQueryScope);
const [queryPersistence] = useState(() => createSessionQueryPersistence(initialSessionQueryScope));
const handleScopeChange = useCallback(() => {
void queryPersistence.persister.removeClient();
reloadPage();
}, [queryPersistence]);
return (
<SessionQueryScopeGuard
initialScope={initialSessionQueryScope}
currentScope={sessionQueryScope}
onScopeChange={reloadPage}
onScopeChange={handleScopeChange}
>
<ScopedTRPCReactProvider>{children}</ScopedTRPCReactProvider>
<ScopedTRPCReactProvider queryPersistence={queryPersistence}>{children}</ScopedTRPCReactProvider>
</SessionQueryScopeGuard>
);
}
const reloadPage = () => window.location.reload();
const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
const ScopedTRPCReactProvider = ({
children,
queryPersistence,
}: PropsWithChildren<{ queryPersistence: SessionQueryPersistence }>) => {
const wsClient = useMemo(
() =>
createWSClient({
@@ -98,6 +111,13 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
);
const [queryClient] = useState(() => {
const client = new QueryClient({
queryCache: new QueryCache({
onError(error, query) {
if (!isTrpcForbiddenError(error) || !isWidgetDataQueryKey(query.queryKey)) return;
void queryPersistence.persister.removeClient();
query.setState({ data: undefined, dataUpdatedAt: 0, isInvalidated: true });
},
}),
defaultOptions: {
queries: {
staleTime: queryCacheDefaultStaleTimeMs,
@@ -186,10 +206,10 @@ const ScopedTRPCReactProvider = ({ children }: PropsWithChildren) => {
return (
<clientApi.Provider client={trpcClient} queryClient={queryClient}>
<QueryClientProvider client={queryClient}>
<PersistQueryClientProvider client={queryClient} persistOptions={queryPersistence}>
<ReactQueryStreamedHydration transformer={superjson}>{children}</ReactQueryStreamedHydration>
{process.env.NODE_ENV === "development" && <DevelopmentTools />}
</QueryClientProvider>
</PersistQueryClientProvider>
</clientApi.Provider>
);
};
@@ -7,7 +7,7 @@ import type { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { CustomPasswordInput } from "@homarr/ui";
import { userRegistrationSchema } from "@homarr/validation/user";
@@ -19,7 +19,7 @@ interface RegistrationFormProps {
}
export const RegistrationForm = ({ invite }: RegistrationFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const router = useRouter();
const { mutate, isPending } = clientApi.user.register.useMutation();
const form = useZodForm(userRegistrationSchema, {
@@ -1,12 +1,14 @@
import { notFound } from "next/navigation";
import { IconUserPlus } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { isProviderEnabled } from "@homarr/auth/server";
import { and, db, eq } from "@homarr/db";
import { invites } from "@homarr/db/schema";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { defaultBrandingSettings, getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { RegistrationForm } from "./_registration-form";
@@ -20,11 +22,9 @@ interface InviteUsagePageProps {
}
export default async function InviteUsagePage(props: InviteUsagePageProps) {
const searchParams = await props.searchParams;
const params = await props.params;
if (!isProviderEnabled("credentials")) notFound();
const session = await auth();
const [searchParams, params, session] = await Promise.all([props.searchParams, props.params, auth()]);
if (session) notFound();
const invite = await db.query.invites.findFirst({
@@ -45,13 +45,33 @@ export default async function InviteUsagePage(props: InviteUsagePageProps) {
if (!invite || invite.expirationDate < new Date()) notFound();
const t = await getScopedI18n("user.page.invite");
const [t, serverSettings] = await Promise.all([getI18n("user.page.invite"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomAppName = branding.authBranding.showAppName && branding.appName !== defaultBrandingSettings.appName;
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const title = showCustomAppName ? t("titleWithAppName", { appName: branding.appName }) : t("title");
const defaultDescription = showCustomAppName
? t("subtitleWithAppName", { appName: branding.appName })
: t("subtitle");
const description = showCustomGreeting ? branding.greeting : defaultDescription;
return (
<OnboardingAuthShell
title={t("title")}
description={t("subtitle")}
title={title}
description={description}
icon={<IconUserPlus size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
footer={t("description", { username: invite.creator.name! })}
>
@@ -12,7 +12,7 @@ import { revalidatePathActionAsync } from "@homarr/common/client";
import type { UseFormReturnType } from "@homarr/form";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { userSignInSchema } from "@homarr/validation/user";
@@ -28,7 +28,7 @@ interface LoginFormProps {
const extendedValidation = userSignInSchema.extend({ provider: z.enum(["credentials", "ldap"]) });
export const LoginForm = ({ providers, oidcClientName, isOidcAutoLoginEnabled, callbackUrl }: LoginFormProps) => {
const t = useScopedI18n("user");
const t = useI18n("user");
const searchParams = useSearchParams();
const isError = searchParams.has("error");
const router = useRouter();
@@ -196,7 +196,7 @@ interface PasswordForgottenCollapseProps {
}
const PasswordForgottenCollapse = ({ username }: PasswordForgottenCollapseProps) => {
const [visible, { toggle }] = useDisclosure(false);
const tForgotPassword = useScopedI18n("user.action.login.forgotPassword");
const tForgotPassword = useI18n("user.action.login.forgotPassword");
const commandUsername = username.trim().length >= 1 ? username.trim() : "<username>";
@@ -2,10 +2,12 @@ import { redirect } from "next/navigation";
import { Alert, Code, Text } from "@mantine/core";
import { IconLogin } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { env } from "@homarr/auth/env";
import { auth } from "@homarr/auth/next";
import { OnboardingAuthShell } from "@homarr/onboarding";
import { getScopedI18n } from "@homarr/translation/server";
import { getBrandingColorOverrides } from "@homarr/server-settings";
import { getI18n } from "@homarr/translation/server";
import { sanitizeRedirectionUrl } from "@homarr/validation/redirection-url";
import { env as appEnv } from "~/env";
@@ -19,17 +21,35 @@ interface LoginProps {
}
export default async function Login(props: LoginProps) {
const searchParams = await props.searchParams;
const session = await auth();
const [searchParams, session] = await Promise.all([props.searchParams, auth()]);
if (session) {
redirect(sanitizeRedirectionUrl(searchParams.callbackUrl));
}
const t = await getScopedI18n("user.page.login");
const [t, serverSettings] = await Promise.all([getI18n("user.page.login"), getRscServerSettingsAsync()]);
const branding = serverSettings.branding;
const wordmarkColors = getBrandingColorOverrides(branding);
const showCustomGreeting = branding.authBranding.showGreeting && branding.greeting.length > 0;
const description = showCustomGreeting ? branding.greeting : t("subtitle");
return (
<OnboardingAuthShell title={t("title")} description={t("subtitle")} icon={<IconLogin size={24} />}>
<OnboardingAuthShell
title={t("title")}
description={description}
icon={<IconLogin size={24} />}
appName={branding.appName}
showAppName={branding.authBranding.showAppName}
showAppLogo={branding.authBranding.showLogo}
primaryColor={branding.primaryColor}
secondaryColor={branding.secondaryColor}
wordmarkPrimaryColor={wordmarkColors.primaryColor}
wordmarkSecondaryColor={wordmarkColors.secondaryColor}
logoImageUrl={branding.logoImageUrl ?? undefined}
backgroundImageUrl={branding.signInBackgroundImageUrl ?? undefined}
backgroundOverlay={branding.signInBackgroundOverlay}
radius={branding.defaultRadius}
>
{appEnv.DEMO_MODE ? (
<Alert icon={<IconLogin size={18} />} color="blue" variant="light">
<Text size="sm" fw={500}>
@@ -1,11 +1,11 @@
import { IconLayoutOff } from "@tabler/icons-react";
import { getScopedI18n } from "@homarr/translation/server";
import { getI18n } from "@homarr/translation/server";
import { BoardNotFound } from "~/components/board/not-found";
export default async function BoardNotFoundPage() {
const tNotFound = await getScopedI18n("board.error.notFound");
const tNotFound = await getI18n("board.error.notFound");
return (
<BoardNotFound
icon={IconLayoutOff}
@@ -19,35 +19,11 @@
align-self: start;
box-sizing: border-box;
height: var(--board-gutter-viewport-height);
padding-top: 24px;
margin-top: -24px;
overflow: clip;
}
.gutter[data-board-editing="true"] {
outline: 1px dashed var(--mantine-color-default-border);
outline-offset: -1px;
outline: 0.0625rem solid var(--mantine-color-default-border);
outline-offset: -0.0625rem;
border-radius: var(--mantine-radius-md);
}
.gutter[data-board-editing="true"]::before {
position: sticky;
top: 0;
inset-inline-start: 0;
z-index: 2;
display: block;
width: fit-content;
height: 24px;
padding-inline: var(--mantine-spacing-xs);
margin-top: -24px;
overflow: hidden;
font-size: var(--mantine-font-size-xs);
line-height: 24px;
color: var(--mantine-color-dimmed);
text-overflow: ellipsis;
white-space: nowrap;
pointer-events: none;
content: attr(aria-label);
background: color-mix(in srgb, var(--mantine-color-body) 88%, transparent);
border-radius: var(--mantine-radius-sm);
}
@@ -1,36 +1,50 @@
"use client";
import type { PropsWithChildren } from "react";
import { Box, Paper, Stack, Text } from "@mantine/core";
import { IconBulb } from "@tabler/icons-react";
import { useCurrentLayout, useInitialViewportWidth, useLayoutOverride, useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { getRepresentativeLayoutWidth } from "@homarr/boards/layout-preview";
import { useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { FloatingTip } from "@homarr/ui";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardAppsSpotlightRegistrar } from "~/components/board/board-apps-spotlight-registrar";
import { BoardEmptyState } from "~/components/board/board-empty-state";
import { BoardSetupChecklist } from "~/components/board/board-setup-checklist";
import {
getBoardLaneColumnCount,
getInitialBoardLogicalHeight,
getLogicalTrackSize,
getLogicalGridSize,
LOGICAL_GRID_GAP,
getRootSectionForLane,
} from "~/components/board/layout";
import { ScaledBoardCanvas } from "~/components/board/layout/scaled-board-canvas";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardGridEditorBoundary } from "~/components/board/sections/grid/board-grid-editor-boundary";
import { BoardGridPortalHost, BoardGridPortalRenderer } from "~/components/board/sections/grid/grid-portal-host";
import { GridEditorRegistryProvider } from "~/components/board/sections/grid/grid-editor-registry";
import { BoardGridPortalHost } from "~/components/board/sections/grid/grid-portal-host";
import { BoardSectionCollapseProvider } from "~/components/board/sections/section-collapse";
import { BoardBackgroundVideo } from "~/components/layout/background";
import { BoardSelectionProvider } from "~/components/board/selection/board-selection-context";
import { BoardSelectionToolbar } from "~/components/board/selection/board-selection-toolbar";
import classes from "./_client.module.css";
const APP_SHELL_INLINE_PADDING = 32;
const BoardSelectionGridProvider = ({ children }: PropsWithChildren) => (
<GridEditorRegistryProvider>
<BoardSelectionProvider>{children}</BoardSelectionProvider>
</GridEditorRegistryProvider>
);
export const ClientBoard = () => {
const board = useRequiredBoard();
const t = useScopedI18n("board.landmark");
const tPreview = useScopedI18n("board.setting.section.layout.preview");
const t = useI18n("board.landmark");
const tPreview = useI18n("board.setting.section.layout.preview");
const tTips = useI18n("tips");
const [isEditMode] = useEditMode();
const currentLayoutId = useCurrentLayout();
const initialViewportWidth = useInitialViewportWidth();
@@ -48,86 +62,99 @@ export const ClientBoard = () => {
const rightColumnCount = getBoardLaneColumnCount(currentLayout, "right");
const laneWidths = [leftColumnCount, mainColumnCount, rightColumnCount]
.filter((columnCount) => columnCount > 0)
.map(getLogicalTrackSize);
.map(getLogicalGridSize);
const logicalWidth =
laneWidths.reduce((total, width) => total + width, 0) + (laneWidths.length - 1) * LOGICAL_GRID_GAP;
const initialLogicalHeight = getInitialBoardLogicalHeight(board, currentLayoutId);
const representativeWidth = layoutOverrideId ? getRepresentativeLayoutWidth(currentLayout, board.layouts) : null;
const initialAvailableWidth = Math.max(1, (representativeWidth ?? initialViewportWidth) - APP_SHELL_INLINE_PADDING);
const gridTemplateColumns = [
leftColumnCount > 0 ? `${getLogicalTrackSize(leftColumnCount)}px` : null,
`${getLogicalTrackSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalTrackSize(rightColumnCount)}px` : null,
leftColumnCount > 0 ? `${getLogicalGridSize(leftColumnCount)}px` : null,
`${getLogicalGridSize(mainColumnCount)}px`,
rightColumnCount > 0 ? `${getLogicalGridSize(rightColumnCount)}px` : null,
]
.filter((value) => value !== null)
.join(" ");
const content = (
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<BoardGridPortalRenderer />
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<BoardSelectionGridProvider>
<BoardAdvancedFocusProvider>
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardAppsSpotlightRegistrar />
<BoardBackgroundVideo />
<BoardEmptyState />
<BoardSetupChecklist />
<BoardSectionCollapseProvider>
<BoardGridPortalHost>
<ScaledBoardCanvas
logicalWidth={logicalWidth}
initialLogicalHeight={initialLogicalHeight}
initialAvailableWidth={initialAvailableWidth}
label={board.name}
>
<BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns }}>
{leftColumnCount > 0 && leftSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("leftRail")}
data-board-gutter="left"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${leftSection.id}`}
section={leftSection}
columnCount={leftColumnCount}
requestedRowCount={0}
railPlacement="left"
/>
</aside>
)}
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
<BoardSelectionToolbar />
<FloatingTip
opened={isEditMode}
showDelay={2_000}
dismissAfter={3_000}
transitionDuration={200}
closable={false}
alertProps={{ color: "primaryColor", icon: <IconBulb size={18} />, variant: "light" }}
>
{tTips("multiSelectApps")}
</FloatingTip>
</Box>
</BoardAdvancedFocusProvider>
</BoardSelectionGridProvider>
);
if (representativeWidth === null) return content;
@@ -4,6 +4,7 @@ import { notFound, redirect } from "next/navigation";
import { TRPCError } from "@trpc/server";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { makeQueryClient } from "@homarr/api/shared";
import { IntegrationProvider } from "@homarr/auth/client";
import { auth } from "@homarr/auth/next";
@@ -15,12 +16,11 @@ import type { WidgetKind } from "@homarr/definitions";
import { getI18n } from "@homarr/translation/server";
import { prefetchForKindAsync } from "@homarr/widgets/prefetch";
import { createMetaTitle } from "~/metadata";
import { env } from "~/env";
import { createBoardLayout } from "../_layout-creator";
import type { Board, Item } from "../_types";
import { ClientBoard } from "./_client";
import { BoardContentHeaderActions } from "./_header-actions";
import { BoardContentEditAction, BoardContentSettingsAction } from "./_header-actions";
const logger = createLogger({ module: "createBoardContentPage" });
const getQueryClient = cache(makeQueryClient);
@@ -36,7 +36,8 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
}: Props<TParams>) => {
return {
layout: createBoardLayout({
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardEditAction: <BoardContentEditAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
headerBoardSettingsAction: <BoardContentSettingsAction demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
}),
@@ -103,18 +104,24 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
},
generateMetadataAsync: async ({ params }: { params: Promise<TParams> }): Promise<Metadata> => {
try {
const board = await getInitialBoard(await params);
const t = await getI18n();
const [board, t, serverSettings] = await Promise.all([
getInitialBoard(await params),
getI18n("board"),
getRscServerSettingsAsync(),
]);
const brandLogo = serverSettings.branding.logoImageUrl ?? "/logo/logo.png";
const brandFavicon = serverSettings.branding.faviconImageUrl ?? brandLogo;
const favicon = !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : brandFavicon;
return {
title: board.metaTitle ?? createMetaTitle(t("board.content.metaTitle", { boardName: board.name })),
title: board.metaTitle ? { absolute: board.metaTitle } : t("content.metaTitle", { boardName: board.name }),
icons: {
icon: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
apple: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : undefined,
icon: favicon,
apple: favicon,
},
appleWebApp: {
startupImage: {
url: !isNullOrWhitespace(board.faviconImageUrl) ? board.faviconImageUrl : "/logo/logo.png",
url: favicon,
},
},
};
@@ -2,14 +2,19 @@
import { useEffect, useRef } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { IconPlus } from "@tabler/icons-react";
import { Group, Menu } from "@mantine/core";
import { IconBox, IconChevronDown, IconLayoutGridAdd, IconPlug, IconPlus, IconResize } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useModalAction } from "@homarr/modals";
import { AppSelectModal } from "@homarr/modals-collection";
import { useI18n } from "@homarr/translation/client";
import { useBoardAddActions } from "~/components/board/use-board-add-actions";
import { UniversalCreateModal } from "~/components/create/universal-create-modal";
import { useItemActions } from "~/components/board/items/item-actions";
import { ItemSelectModal } from "~/components/board/items/item-select-modal";
import { useContainerActions } from "~/components/board/sections/container/container-actions";
import { IntegrationSelectModal } from "~/components/integration/integration-select-modal";
import { HeaderButton } from "~/components/layout/header/button";
export default function BoardEditActions() {
@@ -17,10 +22,16 @@ export default function BoardEditActions() {
}
const AddMenu = () => {
const { data: session } = useSession();
const board = useRequiredBoard();
const { addWidget, addApp, connectService, addContainer, canConnectService } = useBoardAddActions();
const { openModal } = useModalAction(UniversalCreateModal);
const t = useI18n();
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
const { openModal: openAppSelectModal } = useModalAction(AppSelectModal);
const { openModal: openIntegrationSelectModal } = useModalAction(IntegrationSelectModal);
const { addContainer } = useContainerActions();
const { createItem } = useItemActions();
const tBoard = useI18n("board");
const tIntegration = useI18n("integration");
const tContainer = useI18n("section.container");
const pathname = usePathname();
const router = useRouter();
const searchParams = useSearchParams();
@@ -29,29 +40,69 @@ const AddMenu = () => {
useEffect(() => {
if (handledInitialAdd.current || searchParams.get("add") !== "true") return;
handledInitialAdd.current = true;
addWidget();
openItemSelectModal({ boardId: board.id });
const nextSearchParams = new URLSearchParams(searchParams.toString());
nextSearchParams.delete("add");
const query = nextSearchParams.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}, [addWidget, board.id, pathname, router, searchParams]);
}, [board.id, openItemSelectModal, pathname, router, searchParams]);
const handleSelectItem = () => {
openItemSelectModal({ boardId: board.id });
};
const handleSelectApp = () => {
openAppSelectModal({
onSelect: (app) => {
createItem({
kind: "app",
options: { appId: app.id },
});
},
onSelectMany: (apps) => {
for (const app of apps) {
createItem({
kind: "app",
options: { appId: app.id },
});
}
},
withCreate: session?.user.permissions.includes("app-create") ?? false,
});
};
const handleAddIntegration = () => {
openIntegrationSelectModal({ completionBoardId: board.id });
};
return (
<HeaderButton
aria-label={t("board.action.addContent")}
onClick={() =>
openModal({
boardActions: {
widget: addWidget,
app: addApp,
integration: canConnectService ? connectService : undefined,
container: addContainer,
},
})
}
>
<IconPlus stroke={1.5} />
</HeaderButton>
<Menu position="bottom-end">
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={tBoard("action.addContent")}>
<Group gap={4} wrap="nowrap">
<IconPlus stroke={1.5} />
<IconChevronDown color="gray" size={16} />
</Group>
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-3px, 0)" }}>
<Menu.Item leftSection={<IconResize size={20} />} onClick={handleSelectItem}>
{tBoard("emptyState.addWidget")}
</Menu.Item>
<Menu.Item leftSection={<IconBox size={20} />} onClick={handleSelectApp}>
{tBoard("emptyState.addApp")}
</Menu.Item>
{session?.user.permissions.includes("integration-create") && (
<Menu.Item leftSection={<IconPlug size={20} />} onClick={handleAddIntegration}>
{tIntegration("action.create")}
</Menu.Item>
)}
<Menu.Divider />
<Menu.Item leftSection={<IconLayoutGridAdd size={20} />} onClick={addContainer}>
{tContainer("action.create")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
);
};
@@ -3,18 +3,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
import dynamic from "next/dynamic";
import { useRouter, useSearchParams } from "next/navigation";
import { Box, Center, Loader, Menu, ScrollArea } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { notifications } from "@mantine/notifications";
import {
IconDeviceFloppy,
IconLayoutBoard,
IconPencil,
IconPencilOff,
IconReplace,
IconSettings,
IconX,
} from "@tabler/icons-react";
import { IconDeviceFloppy, IconPencil, IconPencilOff, IconSettings, IconX } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -24,9 +15,7 @@ import { env } from "@homarr/common/env";
import { hotkeys } from "@homarr/definitions";
import { useConfirmModal } from "@homarr/modals";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { Link } from "@homarr/ui";
import { useI18n } from "@homarr/translation/client";
import { useBoardPermissions } from "~/components/board/permissions/client";
import { loadGridEditorAsync, scheduleGridEditorWarmup } from "~/components/board/sections/grid/grid-editor-loader";
import { HeaderButton } from "~/components/layout/header/button";
@@ -50,35 +39,37 @@ const loadBoardEditorAsync = async () =>
]);
const BoardEditActions = dynamic(loadEditActionsAsync, { ssr: false });
export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
export const BoardContentEditAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const t = useI18n();
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess) {
return <SelectBoardsMenu />;
}
if (!hasChangeAccess) return null;
return (
<>
{isEditMode && <BoardEditActions />}
<EditModeMenu demoReadOnly={demoReadOnly} />
{!demoReadOnly && (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("board.action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
)}
<SelectBoardsMenu />
</>
);
};
export const BoardContentSettingsAction = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const board = useRequiredBoard();
const t = useI18n("board");
const { hasChangeAccess } = useBoardPermissions(board);
if (!hasChangeAccess || demoReadOnly) return null;
return (
<TourTarget id="board-settings">
<HeaderButton href={`/boards/${board.name}/settings`} aria-label={t("action.settings")}>
<IconSettings stroke={1.5} />
</HeaderButton>
</TourTarget>
);
};
const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const [isEditMode, { open, close }] = useEditMode();
const [editorLoadState, setEditorLoadState] = useState<"scheduled" | "loading" | "ready" | "error">("scheduled");
@@ -88,8 +79,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const router = useRouter();
const searchParams = useSearchParams();
const { openConfirmModal } = useConfirmModal();
const commonT = useI18n();
const t = useScopedI18n("board.action.edit");
const tCommon = useI18n("common");
const tBoardSetting = useI18n("board.setting");
const t = useI18n("board.action.edit");
const returnToLayoutSettings =
isEditMode &&
searchParams.get("edit") === "true" &&
@@ -104,7 +96,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onSuccess() {
showSuccessNotification({
title: t("notification.success.title"),
title: tCommon("notification.update.success"),
message: t("notification.success.message"),
});
void utils.board.getBoardByName.invalidate({ name: board.name });
@@ -115,7 +107,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
},
onError() {
showErrorNotification({
title: t("notification.error.title"),
title: tCommon("notification.update.error"),
message: t("notification.error.message"),
});
},
@@ -178,9 +170,9 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
const cancelLayoutEdit = useCallback(() => {
openConfirmModal({
title: commonT("board.setting.section.layout.edit.cancel.title"),
children: commonT("board.setting.section.layout.edit.cancel.message"),
confirmProps: { children: commonT("common.action.discard"), color: "red" },
title: tBoardSetting("section.layout.edit.cancel.title"),
children: tBoardSetting("section.layout.edit.cancel.message"),
confirmProps: { children: tCommon("action.discard"), color: "red" },
onConfirm() {
void (async () => {
await utils.board.getBoardByName.invalidate({ name: board.name });
@@ -189,7 +181,7 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
})();
},
});
}, [board.name, close, commonT, openConfirmModal, router, utils.board.getBoardByName]);
}, [board.name, close, openConfirmModal, router, tBoardSetting, tCommon, utils.board.getBoardByName]);
useHotkeys([[hotkeys.toggleBoardEdit, () => void toggle()]]);
usePreventLeaveWithDirty(isEditMode);
@@ -200,14 +192,14 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
<HeaderButton
onClick={() => saveBoard(board)}
loading={isPending}
aria-label={commonT("board.setting.section.layout.edit.save")}
aria-label={tBoardSetting("section.layout.edit.save")}
>
<IconDeviceFloppy stroke={1.5} />
</HeaderButton>
<HeaderButton
onClick={cancelLayoutEdit}
disabled={isPending}
aria-label={commonT("board.setting.section.layout.edit.cancel.action")}
aria-label={tBoardSetting("section.layout.edit.cancel.action")}
>
<IconX stroke={1.5} />
</HeaderButton>
@@ -234,50 +226,10 @@ const EditModeMenu = ({ demoReadOnly }: { demoReadOnly: boolean }) => {
);
};
const SelectBoardsMenu = () => {
const t = useI18n();
const [isOpen, setIsOpen] = useState(false);
const utils = clientApi.useUtils();
const { data: boards = [], isPending } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isOpen });
const preloadBoards = () => void utils.board.getAllBoards.prefetch();
return (
<TourTarget id="board-switcher">
<Box onFocus={preloadBoards} onPointerEnter={preloadBoards}>
<Menu position="bottom-end" opened={isOpen} onChange={setIsOpen}>
<Menu.Target>
<HeaderButton w="auto" px={4} aria-label={t("board.action.switch")}>
<IconReplace stroke={1.5} />
</HeaderButton>
</Menu.Target>
<Menu.Dropdown style={{ transform: "translate(-7px, 0)" }}>
<ScrollArea.Autosize mah={300}>
{isPending && (
<Center p="xs">
<Loader size="xs" />
</Center>
)}
{boards.map((board) => (
<Menu.Item
key={board.id}
component={Link}
href={`/boards/${board.name}`}
leftSection={<IconLayoutBoard size={20} />}
>
{board.name}
</Menu.Item>
))}
</ScrollArea.Autosize>
</Menu.Dropdown>
</Menu>
</Box>
</TourTarget>
);
};
const anchorSelector = "a[href]:not([target='_blank'])";
const usePreventLeaveWithDirty = (isDirty: boolean) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const { openConfirmModal } = useConfirmModal();
const router = useRouter();
@@ -293,13 +245,13 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
event.stopPropagation();
openConfirmModal({
title: t("board.action.edit.confirmLeave.title"),
children: t("board.action.edit.confirmLeave.message"),
title: tBoard("action.edit.confirmLeave.title"),
children: tBoard("action.edit.confirmLeave.message"),
onConfirm() {
router.push(target.href);
},
confirmProps: {
children: t("common.action.discard"),
children: tCommon("action.discard"),
},
});
};
@@ -325,5 +277,5 @@ const usePreventLeaveWithDirty = (isDirty: boolean) => {
window.removeEventListener("popstate", handlePopState);
window.removeEventListener("beforeunload", handleBeforeUnload);
};
}, [isDirty, openConfirmModal, router, t]);
}, [isDirty, openConfirmModal, router, tBoard, tCommon]);
};
@@ -1,30 +1,33 @@
"use client";
import type { PropsWithChildren } from "react";
import type { MantineColorsTuple } from "@mantine/core";
import { colorsTuple, createTheme, darken, lighten, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { colorsTuple, createTheme, MantineProvider, rem, v8CssVariablesResolver } from "@mantine/core";
import { useRequiredBoard } from "@homarr/boards/context";
import type { ColorScheme } from "@homarr/definitions";
import { useSettings } from "@homarr/settings";
import { useColorSchemeManager } from "../../_client-providers/mantine";
import { generateColorScale } from "~/theme/branding";
export const BoardMantineProvider = ({
children,
defaultColorScheme,
}: PropsWithChildren<{ defaultColorScheme: ColorScheme }>) => {
const board = useRequiredBoard();
const { branding } = useSettings();
const colorSchemeManager = useColorSchemeManager();
const primaryColor = branding.lockPrimaryColor ? branding.primaryColor : board.primaryColor;
const theme = createTheme({
colors: {
primaryColor: generateColors(board.primaryColor),
secondaryColor: generateColors(board.secondaryColor),
iconColor: board.iconColor ? generateColors(board.iconColor) : colorsTuple("#000000"),
primaryColor: generateColorScale(primaryColor),
secondaryColor: generateColorScale(board.secondaryColor),
iconColor: board.iconColor ? generateColorScale(board.iconColor) : colorsTuple("#000000"),
},
primaryColor: "primaryColor",
autoContrast: true,
defaultRadius: "sm",
defaultRadius: branding.defaultRadius,
fontSizes: {
"2xl": rem(24),
"3xl": rem(28),
@@ -43,29 +46,3 @@ export const BoardMantineProvider = ({
</MantineProvider>
);
};
export const generateColors = (hex: string) => {
const lightnessForColors = [-0.25, -0.2, -0.15, -0.1, -0.05, 0, 0.05, 0.1, 0.15, 0.2] as const;
const rgbaColors = lightnessForColors.map((lightness) => {
if (lightness < 0) {
return lighten(hex, -lightness);
}
return darken(hex, lightness);
});
return rgbaColors.map((color) => {
return (
"#" +
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
color
.split("(")[1]!
.replaceAll(" ", "")
.replace(")", "")
.split(",")
.map((color) => parseInt(color, 10))
.slice(0, 3)
.map((color) => color.toString(16).padStart(2, "0"))
.join("")
);
}) as unknown as MantineColorsTuple;
};
@@ -1,5 +1,6 @@
import { IconHomeOff } from "@tabler/icons-react";
import { getRscServerSettingsAsync } from "@homarr/api/server-settings-server";
import { auth } from "@homarr/auth/next";
import { db } from "@homarr/db";
import { boards } from "@homarr/db/schema";
@@ -7,6 +8,7 @@ import { getI18n } from "@homarr/translation/server";
import type { BoardNotFoundProps } from "~/components/board/not-found";
import { BoardNotFound } from "~/components/board/not-found";
import { homarrLogoPath } from "~/components/layout/logo/constants";
export default async function NotFoundBoardHomePage() {
const boardNotFoundProps = await getPropsAsync();
@@ -16,15 +18,16 @@ export default async function NotFoundBoardHomePage() {
const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
const boardCount = await db.$count(boards);
const t = await getI18n();
const t = await getI18n("board");
if (boardCount === 0) {
const { branding } = await getRscServerSettingsAsync();
return {
icon: { src: "/favicon.ico", alt: "Homarr logo" },
title: t("board.error.noBoard.title"),
description: t("board.error.noBoard.description"),
link: { label: t("board.error.noBoard.link"), href: "/manage/boards" },
notice: t("board.error.noBoard.notice"),
icon: { src: branding.logoImageUrl ?? homarrLogoPath, alt: `${branding.appName} logo` },
title: t("error.noBoard.title"),
description: t("error.noBoard.description"),
link: { label: t("error.noBoard.link"), href: "/manage/boards" },
notice: t("error.noBoard.notice"),
};
}
@@ -39,9 +42,9 @@ const getPropsAsync = async (): Promise<BoardNotFoundProps> => {
return {
icon: IconHomeOff,
title: t(`board.error.homeBoard.title`),
description: t(`board.error.homeBoard.${type}.description`),
link: { label: t(`board.error.homeBoard.${type}.link`), href },
notice: t(`board.error.homeBoard.${type}.notice`),
title: t("error.homeBoard.title"),
description: t(`error.homeBoard.${type}.description` as never),
link: { label: t(`error.homeBoard.${type}.link` as never), href },
notice: t(`error.homeBoard.${type}.notice` as never),
};
};
@@ -22,9 +22,10 @@ import { IconX } from "@tabler/icons-react";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { BoardColorInput } from "@homarr/ui";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
import { generateColorScale } from "~/theme/branding";
import type { FormValues } from "./_settings-form";
interface Props {
@@ -37,22 +38,29 @@ const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
const theme = useMantineTheme();
const { branding } = useSettings();
return (
<SectionCard title={t("board.setting.section.appearance.title")}>
<SectionCard title={tBoard("setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<BoardColorInput label={t("board.field.primaryColor.label")} {...form.getInputProps("primaryColor")} />
<BoardColorInput
label={tBoard("field.primaryColor.label")}
description={branding.lockPrimaryColor ? tBoard("field.primaryColor.locked") : undefined}
disabled={branding.lockPrimaryColor}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<BoardColorInput label={t("board.field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
<BoardColorInput label={tBoard("field.secondaryColor.label")} {...form.getInputProps("secondaryColor")} />
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
<Anchor onClick={toggle}>{showPreview ? tCommon("preview.hide") : tCommon("preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
@@ -63,7 +71,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<InputWrapper label={tBoard("field.opacity.label")}>
<Slider
my={6}
min={0}
@@ -77,7 +85,7 @@ export const ColorSettingsContent = ({ form }: Props) => {
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
label={tBoard("field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
@@ -90,20 +98,20 @@ export const ColorSettingsContent = ({ form }: Props) => {
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
{tBoard("field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
label={tBoard("field.itemRadius.label")}
description={tBoard("field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
{ label: tBoard("field.itemRadius.option.xs"), value: "xs" },
{ label: tBoard("field.itemRadius.option.sm"), value: "sm" },
{ label: tBoard("field.itemRadius.option.md"), value: "md" },
{ label: tBoard("field.itemRadius.option.lg"), value: "lg" },
{ label: tBoard("field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
@@ -120,7 +128,8 @@ interface ColorsPreviewProps {
const ColorsPreview = ({ previewColor }: ColorsPreviewProps) => {
const theme = useMantineTheme();
const colors = previewColor && hexRegex.test(previewColor) ? generateColors(previewColor) : generateColors("#000000");
const colors =
previewColor && hexRegex.test(previewColor) ? generateColorScale(previewColor) : generateColorScale("#000000");
return (
<Group gap={0} wrap="nowrap">
@@ -23,7 +23,7 @@ interface Props {
}
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n();
const tBoard = useI18n("board");
const { data: session } = useSession();
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
@@ -44,7 +44,7 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<SectionCard title={t("board.setting.section.background.title")}>
<SectionCard title={tBoard("setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
@@ -67,8 +67,8 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
label={tBoard("field.backgroundImageUrl.label")}
placeholder={`${tBoard("field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
@@ -87,13 +87,13 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
group: tBoard("field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
group: tBoard("field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
@@ -123,21 +123,21 @@ export const BackgroundSettingsContent = ({ form }: Props) => {
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
label={tBoard("field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
label={tBoard("field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
label={tBoard("field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
@@ -176,19 +176,20 @@ const useBackgroundOptionData = <
defaultValue: keyof TOptions;
},
) => {
const t = useI18n();
const tBoard = useI18n("board");
const tCommon = useI18n("common");
return data.values.map(
(value) =>
({
label: t(`board.field.${key}.option.${value as string}.label` as never),
description: t(`board.field.${key}.option.${value as string}.description` as never),
label: tBoard(`field.${key}.option.${value as string}.label` as never),
description: tBoard(`field.${key}.option.${value as string}.description` as never),
value: value as string,
badge:
data.defaultValue === value
? {
color: "blue",
label: t("common.select.badge.recommended"),
label: tCommon("select.badge.recommended"),
}
: undefined,
}) satisfies SelectItemWithDescriptionBadge,
@@ -13,13 +13,13 @@ interface Props {
}
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n();
const t = useI18n("board");
return (
<SectionCard title={t("board.setting.section.behavior.title")}>
<SectionCard title={t("setting.section.behavior.title")}>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
label={t("field.disableStatus.label")}
description={t("field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
@@ -19,7 +19,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
const groupMutation = clientApi.board.saveGroupBoardPermissions.useMutation();
const userMutation = clientApi.board.saveUserBoardPermissions.useMutation();
const utils = clientApi.useUtils();
const t = useI18n();
const tBoard = useI18n("board");
const { data: permissions } = clientApi.board.getBoardPermissions.useQuery(
{
@@ -52,7 +52,7 @@ export const BoardAccessSettings = ({ board, initialPermissions }: Props) => {
mutate: userMutation.mutate,
isPending: userMutation.isPending,
}}
translate={(key) => t(`board.setting.section.access.permission.item.${key}.label`)}
translate={(key) => tBoard(`setting.section.access.permission.item.${key}.label` as never)}
permission={{
items: boardPermissions,
default: "view",
@@ -10,7 +10,7 @@ import { IconInfoCircle } from "@tabler/icons-react";
import { useSession } from "@homarr/auth/client";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { useI18n } from "@homarr/translation/client";
import { SectionCard } from "~/components/manage/section-card";
import { WorkshopCssImportButton } from "~/components/workshop/workshop-css-import-button";
@@ -24,11 +24,11 @@ interface Props {
export const CustomCssSettingsContent = ({ form }: Props) => {
const { data: session } = useSession();
const isAdmin = session?.user.permissions.includes("admin") ?? false;
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const t = useI18n("board");
const customCssT = useI18n("board.field.customCss");
return (
<SectionCard title={t("board.setting.section.customCss.title")}>
<SectionCard title={t("setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
@@ -46,7 +46,7 @@ interface CustomCssInputProps {
}
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
const customCssT = useI18n("board.field.customCss");
return (
<Input.Wrapper
@@ -1,85 +1,87 @@
"use client";
import { useCallback } from "react";
import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@mantine/core";
import { Box, Button, Group, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useZodForm } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { InlineConfirmButton } from "@homarr/ui";
import { boardRenameSchema } from "@homarr/validation/board";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
const t = useScopedI18n("board.setting");
const t = useI18n("board.setting");
const tCommonAction = useI18n("common.action");
const router = useRouter();
const { openConfirmModal } = useConfirmModal();
const { openModal } = useModalAction(BoardRenameModal);
const utils = clientApi.useUtils();
const [isRenaming, setIsRenaming] = useState(false);
const renameInputRef = useRef<HTMLInputElement>(null);
const renameForm = useZodForm(boardRenameSchema.omit({ id: true }), {
initialValues: {
name: board.name,
},
});
const { mutate: renameBoard, isPending: isRenamePending } = clientApi.board.renameBoard.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const { mutate: changeVisibility, isPending: isChangeVisibilityPending } =
clientApi.board.changeBoardVisibility.useMutation();
const { mutate: deleteBoard, isPending: isDeletePending } = clientApi.board.deleteBoard.useMutation();
const utils = clientApi.useUtils();
const visibility = board.isPublic ? "public" : "private";
const onRenameClick = useCallback(
() =>
openModal({
useEffect(() => {
if (isRenaming) renameInputRef.current?.focus();
}, [isRenaming]);
const handleRename = renameForm.onSubmit((values) => {
renameBoard(
{ id: board.id, name: values.name },
{
onSuccess() {
router.push(`/boards/${values.name}/settings`);
},
},
);
});
const cancelRename = () => {
renameForm.reset();
setIsRenaming(false);
};
const handleVisibilityChange = () => {
changeVisibility(
{
id: board.id,
previousName: board.name,
onSuccess: (name) => router.push(`/boards/${name}/settings`),
}),
[board.id, board.name, router, openModal],
);
const onVisibilityClick = useCallback(() => {
openConfirmModal({
title: t(`section.dangerZone.action.visibility.confirm.${visibility}.title`),
children: t(`section.dangerZone.action.visibility.confirm.${visibility}.description`),
onConfirm: () => {
changeVisibility(
{
id: board.id,
visibility: visibility === "public" ? "private" : "public",
},
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
);
visibility: visibility === "public" ? "private" : "public",
},
});
}, [
board.id,
board.name,
changeVisibility,
t,
utils.board.getBoardByName,
utils.board.getHomeBoard,
visibility,
openConfirmModal,
]);
const onDeleteClick = useCallback(() => {
openConfirmModal({
title: t("section.dangerZone.action.delete.confirm.title"),
children: t("section.dangerZone.action.delete.confirm.description"),
onConfirm: () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
{
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
},
});
}, [board.id, deleteBoard, router, t, openConfirmModal]);
);
};
const handleDelete = () => {
deleteBoard(
{ id: board.id },
{
onSettled: () => {
router.push("/");
},
},
);
};
return (
<DangerZoneRoot>
@@ -87,9 +89,28 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
isRenaming ? (
<Box component="form" onSubmit={handleRename}>
<Group wrap="wrap" justify="end">
<TextInput
ref={renameInputRef}
aria-label={t("section.dangerZone.action.rename.label")}
size="sm"
{...renameForm.getInputProps("name")}
/>
<Button variant="default" onClick={cancelRename} disabled={isRenamePending}>
{tCommonAction("cancel")}
</Button>
<Button type="submit" color="red" loading={isRenamePending}>
{tCommonAction("confirm")}
</Button>
</Group>
</Box>
) : (
<Button variant="subtle" color="red" onClick={() => setIsRenaming(true)}>
{t("section.dangerZone.action.rename.button")}
</Button>
)
}
/>
{!hideVisibility && (
@@ -97,19 +118,31 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isChangeVisibilityPending}
onConfirm={handleVisibilityChange}
confirmLabel={tCommonAction("confirm")}
>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
</InlineConfirmButton>
}
/>
)}
<DangerZoneItem
label={t("section.dangerZone.action.delete.label")}
label={tCommonAction("delete")}
description={t("section.dangerZone.action.delete.description")}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")}
</Button>
<InlineConfirmButton
variant="subtle"
color="red"
loading={isDeletePending}
onConfirm={handleDelete}
confirmLabel={tCommonAction("confirm")}
>
{tCommonAction("delete")}
</InlineConfirmButton>
}
/>
</DangerZoneRoot>
@@ -8,9 +8,9 @@ import { useUpdateBoard } from "@homarr/boards/updater";
import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { useSettings } from "@homarr/settings";
import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import type { FormValues } from "./_settings-form";
@@ -20,25 +20,27 @@ interface Props {
}
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n();
const t = useI18n("board");
const { branding } = useSettings();
const defaultMetaTitle = t("content.metaTitle", { boardName: board.name });
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle, defaultMetaTitle);
return (
<SectionCard title={t("board.setting.section.general.title")}>
<SectionCard title={t("setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
label={t("field.pageTitle.label")}
placeholder={branding.appName}
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
label={t("field.metaTitle.label")}
placeholder={defaultMetaTitle}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
@@ -46,16 +48,16 @@ export const GeneralSettingsContent = ({ board, form }: Props) => {
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
label={t("field.logoImageUrl.label")}
placeholder={branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
label={t("field.faviconImageUrl.label")}
placeholder={branding.faviconImageUrl ?? branding.logoImageUrl ?? "/logo/logo.png"}
withAsterisk={false}
/>
</Grid.Col>
@@ -76,9 +78,9 @@ const useLogoPreview = (url: string | null) => {
}, [logoDebounced, updateBoard]);
};
const useMetaTitlePreview = (title: string | null) => {
const useMetaTitlePreview = (title: string | null, fallbackTitle: string) => {
const [metaTitleDebounced] = useDebouncedValue(title ?? "", 200);
useDocumentTitle(metaTitleDebounced);
useDocumentTitle(metaTitleDebounced.trim() || fallbackTitle);
return {
isPending: (title ?? "") !== metaTitleDebounced,
Loaded 100 of 1023 files, more files were not shown because too many files have changed in this diff. Show more