From a2763d1f6f0b4407882795eb5aecd68d88c7d2ca Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 1 Aug 2026 21:14:33 +0200 Subject: [PATCH] feat: modernize dashboard widgets --- PRODUCT.md | 28 +- .../advanced/keyboard-shortcuts/index.mdx | 16 +- .../after-the-installation.mdx | 5 + apps/docs/docs/management/boards/index.mdx | 14 + apps/docs/docs/widgets/anchor-note/index.mdx | 8 +- apps/docs/docs/widgets/app/index.mdx | 5 + .../widgets/archive-team-warrior/index.mdx | 38 ++ .../widgets/archive-team-warrior/index.ts | 19 + apps/docs/docs/widgets/audio-stats/index.mdx | 4 + apps/docs/docs/widgets/bazarr/index.mdx | 6 + .../docs/docs/widgets/beszel-alerts/index.mdx | 4 + .../docs/widgets/beszel-system-grid/index.mdx | 4 + .../widgets/beszel-system-stats/index.mdx | 4 + .../widgets/beszel-system-table/index.mdx | 8 +- apps/docs/docs/widgets/bookmarks/index.mdx | 10 +- apps/docs/docs/widgets/calendar/index.mdx | 8 +- apps/docs/docs/widgets/clock/index.mdx | 4 + apps/docs/docs/widgets/coolify/index.mdx | 8 + apps/docs/docs/widgets/custom-api/index.mdx | 15 +- .../docs/widgets/dns-hole-controls/index.mdx | 17 +- .../docs/widgets/dns-hole-summary/index.mdx | 8 +- apps/docs/docs/widgets/downloads/index.mdx | 6 +- apps/docs/docs/widgets/firewall/index.mdx | 4 + .../docs/widgets/health-monitoring/index.mdx | 4 + apps/docs/docs/widgets/iframe/index.mdx | 17 +- .../widgets/immich-album-carousel/index.mdx | 6 + .../widgets/immich-server-stats/index.mdx | 6 + .../docs/widgets/indexer-manager/index.mdx | 4 + .../docs/docs/widgets/media-missing/index.mdx | 6 + .../docs/widgets/media-releases/index.mdx | 10 +- .../docs/widgets/media-request-list/index.mdx | 4 + .../widgets/media-request-stats/index.mdx | 7 +- apps/docs/docs/widgets/media-server/index.mdx | 11 +- .../docs/widgets/media-transcoding/index.mdx | 5 + .../widgets/minecraft-server-status/index.mdx | 8 +- .../network-controller-status/index.mdx | 6 + .../network-controller-summary/index.mdx | 8 +- apps/docs/docs/widgets/notebook/index.mdx | 4 + .../docs/docs/widgets/notifications/index.mdx | 6 + .../docs/docs/widgets/paperless-ngx/index.mdx | 4 + apps/docs/docs/widgets/patchmon/index.mdx | 8 + apps/docs/docs/widgets/releases/index.mdx | 4 + apps/docs/docs/widgets/rss-feed/index.mdx | 8 + .../widgets/smart-home-entity-state/index.mdx | 7 + .../smart-home-execute-automation/index.mdx | 6 + .../docs/widgets/speedtest-tracker/index.mdx | 8 + apps/docs/docs/widgets/stock-price/index.mdx | 7 + apps/docs/docs/widgets/system-disks/index.mdx | 4 + .../docs/widgets/system-resources/index.mdx | 4 + apps/docs/docs/widgets/timetable/index.mdx | 4 + apps/docs/docs/widgets/tracearr/index.mdx | 6 + apps/docs/docs/widgets/traefik/index.mdx | 4 + apps/docs/docs/widgets/umami/index.mdx | 4 + apps/docs/docs/widgets/ups/index.mdx | 8 + apps/docs/docs/widgets/uptime-kuma/index.mdx | 8 + apps/docs/docs/widgets/video/index.mdx | 4 + apps/docs/docs/widgets/video/index.tsx | 2 +- apps/docs/docs/widgets/vpn/index.mdx | 4 + apps/docs/docs/widgets/weather/index.mdx | 7 + .../src/components/widgets/interactions.tsx | 47 ++ .../app/[locale]/boards/(content)/_client.tsx | 117 ++-- .../advanced-focus/advanced-focus.module.css | 132 ++++ .../board/advanced-focus/context.tsx | 213 ++++++ .../board/advanced-focus/escape.spec.ts | 54 ++ .../components/board/advanced-focus/escape.ts | 13 + .../board/advanced-focus/geometry.spec.ts | 30 + .../board/advanced-focus/geometry.ts | 26 + .../items/actions/test/empty-position.spec.ts | 33 +- .../components/board/items/item-actions.tsx | 8 +- .../components/board/items/item-content.tsx | 370 +++++++++-- .../board/items/item-select-modal.module.css | 1 - .../board/items/item-select-modal.tsx | 1 - .../board/items/widget-context-menu.tsx | 378 +++++------ .../items/widget-hover-overlay.module.css | 9 + .../board-empty-space-context-menu.tsx | 177 +++++ .../nextjs/src/components/layout/constants.ts | 1 + apps/nextjs/src/components/layout/shell.tsx | 3 +- e2e/board-advanced-interactions.spec.ts | 364 +++++++++++ .../src/router/test/widgets/timetable.spec.ts | 114 ++++ .../widgets/__tests__/beszel-failures.spec.ts | 17 + .../__tests__/custom-api-security.spec.ts | 18 + .../api/src/router/widgets/beszel-failures.ts | 5 + packages/api/src/router/widgets/beszel.ts | 17 + packages/api/src/router/widgets/calendar.ts | 49 +- .../src/router/widgets/custom-api-security.ts | 21 + packages/api/src/router/widgets/custom-api.ts | 1 - packages/api/src/router/widgets/firewall.ts | 35 +- .../api/src/router/widgets/indexer-manager.ts | 30 +- .../api/src/router/widgets/media-organizer.ts | 23 +- .../api/src/router/widgets/media-requests.ts | 2 + .../api/src/router/widgets/media-server.ts | 7 +- .../api/src/router/widgets/notifications.ts | 43 +- packages/api/src/router/widgets/rssFeed.ts | 5 + packages/api/src/router/widgets/smart-home.ts | 35 +- packages/api/src/router/widgets/timetable.ts | 76 ++- packages/api/src/router/widgets/vpn.ts | 41 +- packages/api/src/settle-integrations.ts | 10 +- .../api/src/test/settle-integrations.spec.ts | 17 + packages/boards/src/updater.spec.ts | 18 + packages/boards/src/updater.ts | 52 +- packages/common/src/bounded-response.ts | 45 ++ packages/common/src/server.ts | 1 + .../audiobookshelf-integration.ts | 9 + .../audiobookshelf/audiobookshelf-types.ts | 8 + .../src/immich/immich-integration.ts | 2 - packages/integrations/src/index.ts | 1 + .../interfaces/smart-home/smart-home-types.ts | 2 +- .../overseerr/overseerr-integration.spec.ts | 30 + .../src/overseerr/overseerr-integration.ts | 36 +- .../src/traefik/traefik-bounds.spec.ts | 12 + .../src/traefik/traefik-bounds.ts | 12 + .../src/traefik/traefik-integration.spec.ts | 43 ++ .../src/traefik/traefik-integration.ts | 80 ++- .../integrations/src/traefik/traefik-types.ts | 10 + .../src/smart-home-entity-state.spec.ts | 24 + .../src/smart-home-entity-state.ts | 23 +- packages/request-handler/src/timetable-url.ts | 26 + .../request-handler/src/timetable.spec.ts | 79 +++ packages/request-handler/src/timetable.ts | 19 +- packages/request-handler/src/vpn.ts | 18 +- packages/translation/src/lang/en.json | 559 +++++++++++++++- packages/widgets/src/_inputs/common.tsx | 1 + .../_inputs/widget-dynamic-select-input.tsx | 10 +- .../widgets/src/anchor-note/component.tsx | 136 +++- packages/widgets/src/anchor-note/index.ts | 2 + packages/widgets/src/app/component.tsx | 24 +- .../src/archive-team-warrior/component.tsx | 111 +++- .../widgets/src/archive-team-warrior/index.ts | 2 + .../widgets/src/audio-stats/component.tsx | 78 ++- packages/widgets/src/audio-stats/index.ts | 1 + packages/widgets/src/bazarr/component.tsx | 27 +- packages/widgets/src/bazarr/index.ts | 2 + packages/widgets/src/bazarr/layout.spec.ts | 18 + .../widgets/src/beszel-alerts/component.tsx | 10 +- .../src/beszel-system-grid/component.tsx | 24 +- .../src/beszel-system-stats/component.tsx | 64 +- .../widgets/src/beszel-system-stats/index.ts | 3 +- .../src/beszel-system-stats/selection.spec.ts | 19 + .../src/beszel-system-stats/selection.ts | 32 + .../src/beszel-system-table/component.tsx | 161 +++-- packages/widgets/src/bookmarks/component.tsx | 96 ++- packages/widgets/src/bookmarks/index.tsx | 3 +- packages/widgets/src/bookmarks/layout.spec.ts | 17 + .../src/calendar/calendar-event-list.tsx | 277 ++++---- .../widgets/src/calendar/calendar-events.ts | 77 +++ .../widgets/src/calendar/calendar.spec.ts | 41 +- packages/widgets/src/calendar/component.tsx | 245 +++---- packages/widgets/src/calendar/index.ts | 1 + packages/widgets/src/clock/component.tsx | 106 ++- packages/widgets/src/coolify/component.tsx | 38 +- packages/widgets/src/coolify/index.ts | 1 + .../widgets/src/coolify/instance-card.tsx | 18 +- .../src/coolify/single-instance-layout.tsx | 18 +- packages/widgets/src/custom-api/component.tsx | 1 - packages/widgets/src/custom-api/index.ts | 18 +- packages/widgets/src/definition.spec.ts | 19 + packages/widgets/src/definition.ts | 101 ++- .../src/dns-hole/controls/component.tsx | 82 ++- .../src/dns-hole/summary/component.tsx | 74 ++- packages/widgets/src/docker/component.tsx | 101 ++- packages/widgets/src/docker/layout.spec.ts | 37 ++ packages/widgets/src/docker/layout.ts | 19 + packages/widgets/src/downloads/component.tsx | 193 +++--- .../widgets/src/downloads/helpers.spec.ts | 28 + packages/widgets/src/downloads/helpers.ts | 27 + packages/widgets/src/downloads/index.ts | 1 + .../widgets/src/firewall/bandwidth.spec.ts | 40 ++ packages/widgets/src/firewall/bandwidth.ts | 39 ++ packages/widgets/src/firewall/component.tsx | 462 +++++++------ .../widgets/src/firewall/firewall-version.tsx | 37 +- packages/widgets/src/firewall/index.ts | 1 + .../cluster/accordion-state.spec.ts | 17 + .../cluster/accordion-state.ts | 4 + .../cluster/cluster-health.tsx | 23 +- .../src/health-monitoring/component.tsx | 48 +- .../widgets/src/health-monitoring/index.ts | 3 +- .../integration-selection.spec.ts | 25 + .../integration-selection.ts | 12 + .../src/health-monitoring/system-health.tsx | 377 ++++++----- packages/widgets/src/iframe/component.spec.ts | 26 + packages/widgets/src/iframe/component.tsx | 72 +- .../src/immich/album-carousel/component.tsx | 187 +++++- .../src/immich/album-carousel/index.ts | 33 +- .../src/immich/server-stats/component.tsx | 45 +- .../widgets/src/immich/server-stats/index.ts | 1 + packages/widgets/src/index.tsx | 1 + .../widgets/src/indexer-manager/component.tsx | 59 +- packages/widgets/src/indexer-manager/index.ts | 18 +- .../widgets/src/media-missing/component.tsx | 168 ++++- .../widgets/src/media-missing/tabs.spec.ts | 18 + packages/widgets/src/media-missing/tabs.ts | 13 + .../widgets/src/media-releases/component.tsx | 82 ++- .../src/media-requests/list/component.tsx | 43 +- .../src/media-requests/stats/component.tsx | 102 +-- .../widgets/src/media-server/component.tsx | 617 +++++++++++------- packages/widgets/src/media-server/index.ts | 1 + .../src/media-transcoding/component.tsx | 123 +++- .../widgets/src/media-transcoding/index.ts | 2 + .../src/media-transcoding/layout.spec.ts | 35 + .../panels/statistics.panel.tsx | 3 +- .../src/minecraft/server-status/component.tsx | 60 +- .../widgets/src/modals/widget-edit-modal.tsx | 4 + .../network-status/component.tsx | 33 +- .../network-status/variants/stat-row.tsx | 14 +- .../network-status/variants/wifi-variant.tsx | 18 +- .../network-status/variants/wired-variant.tsx | 18 +- .../network-controller/summary/component.tsx | 82 ++- packages/widgets/src/notebook/notebook.tsx | 229 +++++-- .../widgets/src/notifications/component.tsx | 126 ++-- packages/widgets/src/notifications/index.ts | 9 +- packages/widgets/src/options.ts | 2 + .../widgets/src/paperless-ngx/component.tsx | 24 +- packages/widgets/src/paperless-ngx/index.ts | 2 + packages/widgets/src/patchmon/component.tsx | 28 +- packages/widgets/src/patchmon/index.ts | 2 + packages/widgets/src/releases/component.tsx | 259 ++++---- packages/widgets/src/releases/index.ts | 1 + packages/widgets/src/rssFeed/component.tsx | 95 ++- .../widgets/src/rssFeed/feed-display.spec.ts | 23 + packages/widgets/src/rssFeed/index.ts | 1 + .../src/smart-home/entity-state/component.tsx | 74 ++- .../src/smart-home/entity-state/index.ts | 10 +- .../execute-automation/component.tsx | 71 +- .../smart-home/execute-automation/index.ts | 1 + .../src/speedtest-tracker/averages.tsx | 4 +- .../src/speedtest-tracker/component.tsx | 78 ++- .../src/speedtest-tracker/helpers.spec.ts | 28 + .../widgets/src/speedtest-tracker/helpers.ts | 36 +- .../widgets/src/speedtest-tracker/index.ts | 1 + .../src/speedtest-tracker/latest-result.tsx | 5 +- packages/widgets/src/stocks/component.spec.ts | 18 + packages/widgets/src/stocks/component.tsx | 67 +- packages/widgets/src/stocks/index.ts | 1 + .../widgets/src/system-disks/component.tsx | 108 ++- .../widgets/src/system-disks/display.spec.ts | 14 + .../src/system-resources/component.tsx | 270 +++++--- .../src/system-resources/history.spec.ts | 29 + packages/widgets/src/timetable/component.tsx | 123 ++-- packages/widgets/src/timetable/index.ts | 4 +- packages/widgets/src/tracearr/bitrate.spec.ts | 18 + packages/widgets/src/tracearr/bitrate.ts | 20 + packages/widgets/src/tracearr/component.tsx | 69 +- packages/widgets/src/tracearr/index.ts | 1 + packages/widgets/src/traefik/component.tsx | 161 +++-- packages/widgets/src/traefik/index.ts | 1 + packages/widgets/src/umami/component.tsx | 9 +- packages/widgets/src/umami/index.ts | 3 + packages/widgets/src/umami/umami-content.tsx | 157 +++-- packages/widgets/src/ups/component.tsx | 99 ++- packages/widgets/src/ups/index.ts | 1 + .../widgets/src/uptime-kuma/aggregate.spec.ts | 31 + packages/widgets/src/uptime-kuma/aggregate.ts | 30 + .../widgets/src/uptime-kuma/component.tsx | 110 +++- packages/widgets/src/uptime-kuma/index.ts | 1 + packages/widgets/src/video/component.tsx | 42 +- packages/widgets/src/vpn/component.tsx | 44 +- packages/widgets/src/vpn/index.ts | 1 + .../widgets/src/weather/animated-icon.tsx | 2 - .../widgets/src/weather/component.spec.ts | 16 + packages/widgets/src/weather/component.tsx | 81 ++- packages/widgets/src/weather/index.ts | 1 + 261 files changed, 9392 insertions(+), 2697 deletions(-) create mode 100644 apps/docs/docs/widgets/archive-team-warrior/index.mdx create mode 100644 apps/docs/docs/widgets/archive-team-warrior/index.ts create mode 100644 apps/docs/src/components/widgets/interactions.tsx create mode 100644 apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css create mode 100644 apps/nextjs/src/components/board/advanced-focus/context.tsx create mode 100644 apps/nextjs/src/components/board/advanced-focus/escape.spec.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/escape.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/geometry.ts create mode 100644 apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx create mode 100644 apps/nextjs/src/components/layout/constants.ts create mode 100644 e2e/board-advanced-interactions.spec.ts create mode 100644 packages/api/src/router/test/widgets/timetable.spec.ts create mode 100644 packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts create mode 100644 packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts create mode 100644 packages/api/src/router/widgets/beszel-failures.ts create mode 100644 packages/api/src/router/widgets/custom-api-security.ts create mode 100644 packages/boards/src/updater.spec.ts create mode 100644 packages/common/src/bounded-response.ts create mode 100644 packages/integrations/src/overseerr/overseerr-integration.spec.ts create mode 100644 packages/integrations/src/traefik/traefik-bounds.spec.ts create mode 100644 packages/integrations/src/traefik/traefik-bounds.ts create mode 100644 packages/integrations/src/traefik/traefik-integration.spec.ts create mode 100644 packages/request-handler/src/smart-home-entity-state.spec.ts create mode 100644 packages/request-handler/src/timetable-url.ts create mode 100644 packages/request-handler/src/timetable.spec.ts create mode 100644 packages/widgets/src/bazarr/layout.spec.ts create mode 100644 packages/widgets/src/beszel-system-stats/selection.spec.ts create mode 100644 packages/widgets/src/beszel-system-stats/selection.ts create mode 100644 packages/widgets/src/bookmarks/layout.spec.ts create mode 100644 packages/widgets/src/calendar/calendar-events.ts create mode 100644 packages/widgets/src/definition.spec.ts create mode 100644 packages/widgets/src/docker/layout.spec.ts create mode 100644 packages/widgets/src/docker/layout.ts create mode 100644 packages/widgets/src/downloads/helpers.spec.ts create mode 100644 packages/widgets/src/downloads/helpers.ts create mode 100644 packages/widgets/src/firewall/bandwidth.spec.ts create mode 100644 packages/widgets/src/firewall/bandwidth.ts create mode 100644 packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts create mode 100644 packages/widgets/src/health-monitoring/cluster/accordion-state.ts create mode 100644 packages/widgets/src/health-monitoring/integration-selection.spec.ts create mode 100644 packages/widgets/src/health-monitoring/integration-selection.ts create mode 100644 packages/widgets/src/iframe/component.spec.ts create mode 100644 packages/widgets/src/media-missing/tabs.spec.ts create mode 100644 packages/widgets/src/media-missing/tabs.ts create mode 100644 packages/widgets/src/media-transcoding/layout.spec.ts create mode 100644 packages/widgets/src/rssFeed/feed-display.spec.ts create mode 100644 packages/widgets/src/speedtest-tracker/helpers.spec.ts create mode 100644 packages/widgets/src/stocks/component.spec.ts create mode 100644 packages/widgets/src/system-disks/display.spec.ts create mode 100644 packages/widgets/src/system-resources/history.spec.ts create mode 100644 packages/widgets/src/tracearr/bitrate.spec.ts create mode 100644 packages/widgets/src/tracearr/bitrate.ts create mode 100644 packages/widgets/src/uptime-kuma/aggregate.spec.ts create mode 100644 packages/widgets/src/uptime-kuma/aggregate.ts create mode 100644 packages/widgets/src/weather/component.spec.ts diff --git a/PRODUCT.md b/PRODUCT.md index 8b4a6c01a..bac6f41bd 100644 --- a/PRODUCT.md +++ b/PRODUCT.md @@ -6,30 +6,34 @@ product ## Users -Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work. +Homarr is for self-hosters and administrators who assemble dashboards for services across home labs and private infrastructure. They need dense, reliable controls and status at a glance, while retaining enough guidance to configure advanced features without hiding how they work. ## Product Purpose -Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service. Boards must load immediately, stay visually stable, keep widgets legible across screen sizes, and make editing feel direct and forgiving. +Homarr provides one place to organize, observe, and operate self-hosted services. Success means users can build useful dashboards quickly, understand their state, recover from configuration errors, act on services, and reshape their workspace without repeatedly leaving the board. Boards must load immediately, stay visually stable, and keep widgets legible across screen sizes. ## Brand Personality -Direct, capable, and approachable. Copy should be concise and technical when precision matters, while the interface should remain familiar to users who are not developers. +Direct, capable, precise, and approachable. Homarr should feel calm under load, be truthful about system state, and reward users who refine details. ## Anti-references -Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, hidden configuration magic, inconsistent controls, excessive modal usage, and interfaces that expose implementation complexity before the user needs it. +- Decorative dashboards, oversized metric cards, and motion without state meaning. +- Wizard flows that fight ordinary scrolling or rigid layouts that force one workflow. +- Hidden configuration magic and implementation complexity exposed before it is needed. +- Clever custom controls where familiar platform or Mantine interactions already work. ## Design Principles -1. Keep the user in control by making configuration and consequences visible. -2. Prefer familiar product patterns and progressive disclosure over custom interaction concepts. -3. Make simple tasks short without limiting advanced use cases. -4. Isolate failures so one service or widget cannot disrupt the dashboard. -5. Use the existing Mantine design language consistently across management and board surfaces. -6. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback. -7. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible. +1. Keep configuration, consequences, and system state visible. +2. Make simple tasks short without limiting advanced workflows. +3. Preserve expert depth while prioritizing urgent information at small sizes. +4. Reveal dense workspaces progressively through a consistent advanced focus mode. +5. Isolate failures and show loading, stale, partial, empty, and failed states truthfully. +6. Use the existing Mantine design language consistently across management and board surfaces. +7. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback. +8. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible. ## Accessibility & Inclusion -Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations. +Target WCAG 2.2 AA. Every pointer interaction needs a keyboard and coarse-pointer path. Support screen readers, visible and restored focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations. diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 3666a5eeb..e3aa71389 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,11 +6,14 @@ 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 | +| 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` | Closes an open advanced view or board menu | ## Board editing @@ -30,6 +33,9 @@ Commands at a grid boundary or at the tile's minimum size are ignored and announ Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. +Advanced view is available outside edit mode. Widgets without a distinct advanced experience adapt to their normal +dimensions instead. A progress ring follows the pointer while the temporary advanced view is preparing to open. + ::: :::note diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 0ddee5935..4fcd403bc 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -191,6 +191,11 @@ When entering the edit mode, you'll get some additional buttons to work with: - **Add item**: Click this button to add new elements to your page. - **Exit edit mode**: Click this button to save your changes and exit edit mode. +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 +keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place. + To continue, click the option "New Item": {"Screenshot diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f10ead270..c89d7fb68 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -248,6 +248,20 @@ The styling will only be applied on the respective board page. This will disable the status check for apps. +#### Work directly from the board + +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. +- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run + supported actions. Available controls follow your board and integration permissions. +- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board + section and press `Shift` + `F10` for the keyboard equivalent. + +Widget and empty-space context menus follow the **Enable right click on widgets** preference in your user settings. + ### Access control With the access control, you can permit a single user or a group to your board. diff --git a/apps/docs/docs/widgets/anchor-note/index.mdx b/apps/docs/docs/widgets/anchor-note/index.mdx index db925e95a..ea7c00fea 100644 --- a/apps/docs/docs/widgets/anchor-note/index.mdx +++ b/apps/docs/docs/widgets/anchor-note/index.mdx @@ -4,6 +4,8 @@ description: "Display and edit a selected note from Anchor" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -15,6 +17,8 @@ import { anchorIntegration } from "@site/docs/integrations/anchor"; The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr. +Advanced view always shows the note title and update time alongside the complete formatted document, permission, creation date, and pinned or archived state. Compact title and update-time switches do not hide that information in advanced view. + ### Screenshots import anchorNoteWidgetScreenshot from "./img/widget.png"; @@ -33,10 +37,12 @@ import anchorNoteWidgetScreenshot from "./img/widget.png"; ### Interactions -- Click **Edit** to start editing the selected note. +- Hover the widget, focus within it, or use a touch device to reveal **Edit**, then select it to start editing the note. - Click **Save** to persist changes to Anchor. - Click **Cancel** to discard unsaved changes. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index 77d8ec878..dd5cef590 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -25,6 +25,11 @@ import piHoleApp from "./img/pi-hole-ping-icon.png"; app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip + +### Interactions + +App tiles stay compact and open their configured destination directly. Right-click a tile to inspect its status or change its settings when the board and your preferences allow it. + ### Adding the widget diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.mdx b/apps/docs/docs/widgets/archive-team-warrior/index.mdx new file mode 100644 index 000000000..5eceed2c2 --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.mdx @@ -0,0 +1,38 @@ +--- +title: "ArchiveTeam Warrior" +description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs." +hide_title: true +--- + +import { archiveTeamWarriorIntegration } from "@site/docs/integrations/archiveteam-warrior"; +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 { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { archiveTeamWarriorWidget } from "."; + + + +Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time. + +### Supported integration + + + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.ts b/apps/docs/docs/widgets/archive-team-warrior/index.ts new file mode 100644 index 000000000..5a66b9fbb --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.ts @@ -0,0 +1,19 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconArchive } from "@tabler/icons-react"; + +export const archiveTeamWarriorWidget: WidgetDefinition = { + icon: IconArchive, + name: "ArchiveTeam Warrior", + description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs.", + path: "../../widgets/archive-team-warrior", + configuration: { + items: [ + { + name: "Show broadcast message", + description: "Show the message broadcast by the selected Warrior project when space allows.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/audio-stats/index.mdx b/apps/docs/docs/widgets/audio-stats/index.mdx index 88fa87f79..f79a588c3 100644 --- a/apps/docs/docs/widgets/audio-stats/index.mdx +++ b/apps/docs/docs/widgets/audio-stats/index.mdx @@ -4,6 +4,8 @@ description: "Displays media and listening statistics from Navidrome or Audioboo hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -36,6 +38,8 @@ Only options relevant to your linked integration are shown in the configuration ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/bazarr/index.mdx b/apps/docs/docs/widgets/bazarr/index.mdx index 6a87a4e0f..8323bff27 100644 --- a/apps/docs/docs/widgets/bazarr/index.mdx +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -4,6 +4,8 @@ description: "Displays missing subtitle counts and health indicators from your B hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -34,6 +36,10 @@ import widgetOverview from "./img/widget-overview.png"; ]} /> +Advanced view shows every missing-subtitle and health summary regardless of compact visibility options, together with the already-fetched Sonarr and Radarr SignalR states and announcement count. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-alerts/index.mdx b/apps/docs/docs/widgets/beszel-alerts/index.mdx index 4a669f3f4..b2affc3f1 100644 --- a/apps/docs/docs/widgets/beszel-alerts/index.mdx +++ b/apps/docs/docs/widgets/beszel-alerts/index.mdx @@ -4,6 +4,8 @@ description: "View Beszel alert configurations and history with triggered/ok sta hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -32,6 +34,8 @@ This widget displays your Beszel alert configurations separated by status (trigg ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx index 993974914..70c477593 100644 --- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx @@ -4,6 +4,8 @@ description: "Card grid view of all Beszel-monitored systems with real-time metr hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -34,6 +36,8 @@ Select a system card to open the complete statistics view, including system, fil ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx index e29d083ba..4bc00990e 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -4,6 +4,8 @@ description: "Time-series charts for CPU, memory, disk, network, and Docker cont hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -32,6 +34,8 @@ This widget displays time-series charts for a selected Beszel system, including ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index 9063727fa..e1bbd8c93 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -4,6 +4,8 @@ description: "Table view of all Beszel-monitored systems with sortable columns a hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -13,9 +15,9 @@ import { beszelIntegration } from "@site/docs/integrations/beszel"; -This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. +This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. System and load-average status dots are displayed inline with their values. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. -Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts. +Select a system row to open the complete statistics view. ### Interactions @@ -35,6 +37,8 @@ Select a system row to open the complete statistics view, including system, file ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index ed8be9bb6..3e2710788 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -4,6 +4,8 @@ description: "Displays multiple app links" 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"; @@ -13,18 +15,20 @@ import { bookmarksWidget } from "."; The bookmarks widget allows you to display multiple app links in a single widget. You can add links to your favorite applications, websites, or any other resources you want quick access to. +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. ### Screenshots import gridIconsOnly from "./img/grid-icons-only.png"; import gridWithTitle from "./img/grid-with-title.png"; import verticalListAll from "./img/vertical-list-all.png"; -import vericalListSimple from "./img/vertical-list-simple.png"; +import verticalListSimple from "./img/vertical-list-simple.png";
bookmarks grid icons-only - bookmarks vertical-list no-domain + bookmarks vertical-list no-domain
bookmarks grid with-title @@ -32,6 +36,8 @@ import vericalListSimple from "./img/vertical-list-simple.png";
+ + ### Adding the widget diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index f84a0daae..0405021a4 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -4,6 +4,8 @@ description: "Display events from your integrations in a calendar view within a hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -70,8 +72,10 @@ import withoutIntegrations from "./img/without-integrations.png"; ### Interactions - Hover over a marked date to preview its events in a popover. -- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away). -- Click the date again or click outside to close the pinned popover. +- 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. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index 0c41d7ae7..6ec75e9f6 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current date and time." 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"; @@ -25,6 +27,8 @@ import tokyoClock from "./img/tokyo-12-hour.png"; clock format-12-hours timezone-tokyo + + ### Adding the widget diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index 807ad502d..b843f4971 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -4,6 +4,8 @@ description: "Overview of your Coolify instance with servers, applications, and hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -25,6 +27,12 @@ The Coolify widget displays a compact overview of your Coolify instance. It can ]} /> +Advanced view opens the Servers, Applications, and Services sections regardless of their compact visibility switches. It identifies each Coolify integration, shows server `is_reachable` and `is_usable` values, and lists applications nested in services when the API provides them. Healthy integrations remain visible if another source fails, with the failed source identified by a warning. + +IP addresses remain masked in advanced view until you explicitly use the eye control. Public resource, Coolify dashboard, and log links retain the widget's safe new-tab handling. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 0e745077b..908625499 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -1,24 +1,26 @@ --- title: "Custom API" -description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions." +description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { customApiWidget } from "."; -The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets). - A definition contains API sources, named requests, a widget options schema, and one safe JSX template. Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately. Homarr does not send these credentials to the browser. Homarr does not include them in an export. -### Add the widget + + +### Adding the widget @@ -29,9 +31,8 @@ Select a refresh interval. The board item stores the definition ID and the valid Credentials remain on the server. :::note - -Enable the definition before you use it. The definition must also be available to the board. - +Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker. +Action buttons can only be executed by administrators. ::: ### Permissions and actions diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index 74c8cc88b..e97407da7 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -1,9 +1,11 @@ --- title: "DNS Hole Controls" -description: "Control PiHole or AdGuard from your dashboard." +description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -18,7 +20,9 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns -This widget allows you to enable or disable your DNS Hole. +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's width and height. ### Screenshots @@ -46,9 +50,12 @@ import smallSize from "./img/small.png"; ### Interactions -- Enable / Disable one dns-hole by clicking on the badge. -- Disable one dns-hole in x seconds by clicking on the clock icon. -- Do all of the above for all dns-holes by using the top button row. +- Enable or disable one DNS service by clicking its status badge. +- Temporarily disable one service with the clock action. +- Apply an action to all available services from the top action row. Homarr keeps successful results visible and reports how many sources failed. +- Interaction controls only appear when the current user has permission for that integration. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/dns-hole-summary/index.mdx b/apps/docs/docs/widgets/dns-hole-summary/index.mdx index 99fd69dbd..abd9d093d 100644 --- a/apps/docs/docs/widgets/dns-hole-summary/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-summary/index.mdx @@ -4,6 +4,8 @@ description: "Displays the summary of your DNS Hole." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -15,7 +17,9 @@ import { dnsHoleSummaryWidget } from "."; -This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more. +When several DNS integrations are selected, Homarr keeps healthy sources visible if one source fails and shows a warning identifying the unavailable integration. + +This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. When the widget has enough room, it adds a scrollable status row naming each selected source. ### Screenshots @@ -50,6 +54,8 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index cb1aee0a2..47e7a901f 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -4,6 +4,8 @@ description: "Allows you to view and manage your Downloads from both Torrent and hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { aria2Integration } from "@site/docs/integrations/aria2"; import { delugeIntegration } from "@site/docs/integrations/deluge"; import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; @@ -43,7 +45,7 @@ import contextMenuImg from "./img/context-menu.png"; - **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 - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Row expansion** — click any row to see full details inline without opening a separate modal -- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category +- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode - **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts - **Client filtering** — filter downloads by integration or status using the footer filter panel - **Global controls** — pause/resume all downloads across all clients from the footer bar @@ -95,6 +97,8 @@ import contextMenuImg from "./img/context-menu.png"; - **Filter** by integration or status using the filter button in the footer - **Sort** by clicking column headers (with persistent sort direction) + + ### Adding the widget diff --git a/apps/docs/docs/widgets/firewall/index.mdx b/apps/docs/docs/widgets/firewall/index.mdx index 425ab8854..aec2d89d9 100644 --- a/apps/docs/docs/widgets/firewall/index.mdx +++ b/apps/docs/docs/widgets/firewall/index.mdx @@ -4,6 +4,8 @@ description: "Displays a summary of firewalls." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -28,6 +30,8 @@ import { opnsenseIntegration } from "../../integrations/opnsense"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index 603207451..bc50cf2c7 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -4,6 +4,8 @@ description: "Displays information showing the health and status of your system( hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -62,6 +64,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 2c183368f..5f410a7f3 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -4,6 +4,8 @@ description: "Embed any content from the internet. Some websites may restrict ac 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"; @@ -26,6 +28,8 @@ import youtubeStream from "./img/nasa-iss-stream.png"; iframe youtube-iss-stream + + ### Adding the widget @@ -35,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png"; Although you can integrate many websites, some may not work. This is because the administrators of those sites disallow iframes for security reasons, as users could easily be tricked -by embeding the original site on a malicious page. +by embedding the original site on a malicious page. Some proxies disallow iframes by default for this reason. -You can use https://iframetest.com to check, if the site is allowing iframes. +You can use https://iframetest.com to check whether the site allows iframes. If you manage all of your own applications, you can usually change that by configuring -the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes. +the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete +`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers. For example, to allow your site to be embedded in iframes from specific domains: @@ -49,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains: ```apache Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com" -Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com" ``` **Nginx:** ```nginx add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"; -add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; ``` ### Example URLs @@ -82,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an ![transparent iframe](./img/transparent-iframe.png) -### Third party content +### Third-party content :::warning -The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository. +The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository. We cannot guarantee the functionality nor security of these tools. ::: diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 89675ca24..5d14a9dc4 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -4,6 +4,8 @@ description: "Shows a slideshow of pictures from an Immich album or your full li hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { immichIntegration } from "../../integrations/immich"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -29,6 +31,10 @@ import screen from "./img/screen.png"; ]} /> + + +Hover or focus the widget to pause its slideshow; touch devices keep the control available. A paused slideshow keeps its resume control visible. The advanced view displays the complete photo without cropping, groups previous, pause or resume, and next together, and includes every loaded photo in its thumbnail strip. If an Immich refresh fails, the last loaded album stays visible with a stale-data warning. + ### Adding the widget diff --git a/apps/docs/docs/widgets/immich-server-stats/index.mdx b/apps/docs/docs/widgets/immich-server-stats/index.mdx index 4fcb5d4cc..e2c967365 100644 --- a/apps/docs/docs/widgets/immich-server-stats/index.mdx +++ b/apps/docs/docs/widgets/immich-server-stats/index.mdx @@ -4,6 +4,8 @@ description: "Information about your Immich instance" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { immichIntegration } from "../../integrations/immich"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -29,6 +31,10 @@ import screen from "./img/screen.png"; ]} /> +In advanced mode, the widget also ranks the largest albums by asset count. To keep the dashboard response lightweight, it shows up to 50 albums. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index aed26828b..2deeecd66 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -4,6 +4,8 @@ description: "View the status of your indexers." 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 { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -37,6 +39,8 @@ import { prowlarrIntegration } from "@site/docs/integrations/prowlarr"; - Test all indexers by clicking on the test button. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-missing/index.mdx b/apps/docs/docs/widgets/media-missing/index.mdx index 06799ae2e..8ea527d36 100644 --- a/apps/docs/docs/widgets/media-missing/index.mdx +++ b/apps/docs/docs/widgets/media-missing/index.mdx @@ -4,6 +4,8 @@ description: "Track missing media and active downloads from Radarr and Sonarr" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -16,6 +18,8 @@ import { sonarrIntegration } from "@site/docs/integrations/sonarr"; Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs. +Advanced view always shows both Missing and Queued panels side by side when space allows, requests at least 50 items per source, exposes queue status and time remaining, and keeps partial-integration warnings visible. Compact tab visibility and page-size choices continue to shape the board tile. + ### Screenshots import widgetMissing from "./img/missing.png"; @@ -58,6 +62,8 @@ The widget adapts to its container size, so it looks good at any grid size: Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx index 71af2b04a..79dfb4f17 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -1,9 +1,11 @@ --- title: "Media releases" -description: "Display newly added medias or upcoming releases from different integrations" +description: "Display newly added media or upcoming releases from different integrations" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -17,6 +19,10 @@ import { plexIntegration } from "@site/docs/integrations/plex"; Provides a list of newly added media or upcoming releases from your media server. +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. + +Advanced view displays every release in a roomy poster layout with its complete available metadata, description, tags, and visible source name, regardless of the compact layout and visibility options. + ### Screenshots import backdrop from "./img/backdrop.png"; @@ -43,6 +49,8 @@ import poster from "./img/poster.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 1c491a051..68f3a27ab 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -45,6 +47,8 @@ import mediaRequestList from "./img/list.png"; - Approve / decline a request by clicking on the thumbs up or down icon. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index fc7d7f465..3adfcbffb 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -4,6 +4,8 @@ description: "Statistics about your media requests" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -17,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections. +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. ### Supported Integrations @@ -41,6 +43,9 @@ import mediaRequestStats from "./img/stats.png"; ### Interactions - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- A warning indicator identifies unavailable integrations while totals from healthy integrations remain visible. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index d759ff6f7..a8a9a517e 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -4,6 +4,8 @@ description: "Show the current streams on your media servers" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -19,8 +21,12 @@ import { plexIntegration } from "@site/docs/integrations/plex"; -The media server widget enables you to watch and view current active sessions over all your media servers. -It also displays useful information, that you can use when providing support to users or debugging a problem. +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. + +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. +Sessions without user metadata remain visible and are labeled as an unknown user. ### Screenshots @@ -48,6 +54,7 @@ import mediaTable from "./img/table.png"; }, ]} /> + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-transcoding/index.mdx b/apps/docs/docs/widgets/media-transcoding/index.mdx index 62dd00af3..7edde95ca 100644 --- a/apps/docs/docs/widgets/media-transcoding/index.mdx +++ b/apps/docs/docs/widgets/media-transcoding/index.mdx @@ -4,6 +4,8 @@ description: "Statistics, current queue and worker status of your media transcod hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -18,6 +20,7 @@ import { tdarrIntegration } from "@site/docs/integrations/tdarr"; The media transcoding widget provides you with an overview of your media transcoding status. It shows you the current queue, worker status, and statistics about your transcoding jobs. +Advanced view presents all three panels together. If refreshes fail after data has loaded, Homarr keeps the last useful result visible and marks it as stale. ### Screenshots @@ -41,6 +44,8 @@ import statisticsCard from "./img/statistics.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index d96a2f190..4df364116 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -4,6 +4,8 @@ description: "Displays the status of a Minecraft server." 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"; @@ -24,9 +26,11 @@ import bedrockItem from "./img/bedrock.png";
minecraft-server-status java-edition - minecraft-server-status bedrock-editon + minecraft-server-status bedrock-edition
+ + ### Adding the widget @@ -38,4 +42,4 @@ import bedrockItem from "./img/bedrock.png"; ### Data source We use the Minecraft Server Status API to fetch the server status. -If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us` +If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`. diff --git a/apps/docs/docs/widgets/network-controller-status/index.mdx b/apps/docs/docs/widgets/network-controller-status/index.mdx index 29a5bbb8e..64ddb6ad0 100644 --- a/apps/docs/docs/widgets/network-controller-status/index.mdx +++ b/apps/docs/docs/widgets/network-controller-status/index.mdx @@ -4,6 +4,8 @@ description: "Display connected devices on a network" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -18,6 +20,8 @@ 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. + ### Screenshots import wifiCard from "./img/wifi.png"; @@ -36,6 +40,8 @@ import wiredCard from "./img/wired.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx index c7c2f4d4d..3850cc25c 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -4,6 +4,8 @@ description: "Displays the summary of a Network Controller" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -16,7 +18,7 @@ import { networkControllerSummaryWidget } from "."; -Shows information whether your WAN is connected, Wi-Fi is active and several other information. +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. ### Screenshots @@ -34,6 +36,10 @@ 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. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index 393137ed2..a245600aa 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown." 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"; @@ -24,6 +26,8 @@ import editOn from "./img/edit.png"; notebook edit-on + + ### Adding the widget diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index 3ca096e3c..5616f297b 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -4,6 +4,8 @@ description: "Display notification history from an integration" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -32,6 +34,10 @@ import notificationsScreenshot from "./img/notifications.png"; items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]} /> +Advanced view always shows available notification logos and sources and removes the compact body line limit. External notification links retain the same safe new-tab behavior. + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index 140d667c0..a2ef00ebe 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -4,6 +4,8 @@ description: "Displays document management statistics including inbox ratio, doc hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -26,6 +28,8 @@ This widget displays statistics from your Paperless-ngx instance, including an i ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index cde2a0da5..2175a3945 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -4,6 +4,8 @@ description: "Displays host patch statistics from PatchMon including update and hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -70,6 +72,10 @@ The widget adapts its layout based on the available space: When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text. +### Advanced view + +Advanced view is a complete PatchMon posture view. It always shows the compliance hero, all eight host and package statistics, the full OS distribution with versions, and the last-updated time. Compact visibility switches and OS limits still shape the board tile, but do not hide already-fetched security information in advanced view. Threshold and chart-style choices remain in effect. + ### Supported Integrations + + ### Adding the widget diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index faf386a13..017ea97ca 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w 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"; @@ -33,6 +35,8 @@ 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. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx index d4e0b2d74..cec1fcedf 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -4,6 +4,8 @@ description: "Monitor and display one or more generic RSS, ATOM or JSON feeds." 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"; @@ -19,6 +21,10 @@ import screenshot from "./img/screenshot.png"; rss-feed noted-lol +Advanced view keeps the configured feeds and post limit unchanged while showing each fetched post's full description, image, source, relative time, and exact publication timestamp. Right-to-left text and safe external links continue to follow the widget configuration. + + + ### Adding the widget @@ -27,6 +33,8 @@ import screenshot from "./img/screenshot.png"; +If one configured feed fails, the widget keeps entries from healthy feeds visible and shows a warning. If a refresh fails after entries have loaded, the previous entries stay visible with a stale-data warning. + ### Examples Here's a small collection of examples, that you can use for testing or actual use (in no particular order): diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx index f01e4f0df..2d12f3d71 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -4,6 +4,8 @@ description: "Display the state of an entity and toggle it optionally" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -20,6 +22,8 @@ The entity state enables you to display the current state of an entity. 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. +Advanced view shows the safe Home Assistant metadata owned by Homarr: the friendly name, entity ID, unit, device class, icon, last-changed time, and last-updated time. Arbitrary attributes remain intentionally excluded. + ### Screenshots import sun from "./img/sun.png"; @@ -43,6 +47,9 @@ import temperature from "./img/temperature.png"; ### Interactions - Toggle entity state by clicking on the entity (only when clickable enabled). +- If Homarr cannot refresh an entity, the last known state stays visible but toggling is disabled until the connection recovers. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx index f31a79b93..249c85fa0 100644 --- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx +++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx @@ -4,6 +4,8 @@ description: "Trigger an automation with one click" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -18,6 +20,8 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant The execute automation widget allows you to trigger an automation with one click. +Advanced view shows the automation ID, interaction permission, last successful run in the current browser session, and the latest action error together. Session-only history is not presented as Home Assistant history. + ### Screenshots import empty from "./img/empty.png"; @@ -47,6 +51,8 @@ import withDisplayname from "./img/with-displayname.png"; - Trigger the automation by clicking on the widget. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 4b0760e0a..2308d2d95 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -4,6 +4,8 @@ description: "Displays speed test results from your Speedtest Tracker instance." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -16,6 +18,10 @@ 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. + +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. + ### Screenshots import screenshot from "./img/screenshot.png"; @@ -32,6 +38,8 @@ import screenshot from "./img/screenshot.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index dcd091be7..87fe70b11 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current stock price of a company." 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"; @@ -12,6 +14,9 @@ import { stockPriceWidget } from "."; Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. +The widget prioritizes the symbol, current price, and trend. Company name, change, time range, and a taller chart appear progressively when both widget dimensions can support them. + +In advanced view, the widget always shows the returned symbol and company name, configured range and interval, current and previous close, absolute and percentage change, range minimum and maximum, and the full price-history chart. These values use the same configured Yahoo Finance query as the compact widget; advanced view does not change the symbol, range, or interval. ### Screenshots @@ -31,6 +36,8 @@ import nvidia from "./img/nvidia.png"; stock-price nvidia + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index b3be87e1d..5015ac725 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -4,6 +4,8 @@ description: "Disk usage of your system" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -42,6 +44,8 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx index 16ad19750..cdc258f4c 100644 --- a/apps/docs/docs/widgets/system-resources/index.mdx +++ b/apps/docs/docs/widgets/system-resources/index.mdx @@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index bea69cfb4..8fb3b7ccd 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -4,6 +4,8 @@ description: "Displays departure times for a station." 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"; @@ -23,6 +25,8 @@ import streetDepartures from "./img/street-departures.png"; timetable-widget-bus-and-tram-departures + + ### Adding the widget diff --git a/apps/docs/docs/widgets/tracearr/index.mdx b/apps/docs/docs/widgets/tracearr/index.mdx index 797f83a3e..8d31d9d5e 100644 --- a/apps/docs/docs/widgets/tracearr/index.mdx +++ b/apps/docs/docs/widgets/tracearr/index.mdx @@ -4,6 +4,8 @@ description: "Monitor media server streams, user activity, and policy violations 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"; @@ -15,6 +17,8 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr"; 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. +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. + ### Sections | Section | Description | @@ -34,6 +38,8 @@ import streamsAndStats from "./img/streams-and-stats.png"; tracearr-streams-and-stats + + ### Adding the widget diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx index dc9f6a6c4..dfc2f9247 100644 --- a/apps/docs/docs/widgets/traefik/index.mdx +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -26,6 +28,8 @@ This widget displays a compact overview of your Traefik instance, including tota ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/umami/index.mdx b/apps/docs/docs/widgets/umami/index.mdx index b208a3311..6f2c2bb7f 100644 --- a/apps/docs/docs/widgets/umami/index.mdx +++ b/apps/docs/docs/widgets/umami/index.mdx @@ -4,6 +4,8 @@ description: "Display visitor stats from your Umami analytics instance" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -25,6 +27,8 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/ups/index.mdx b/apps/docs/docs/widgets/ups/index.mdx index 3e57ee57b..cba3c5238 100644 --- a/apps/docs/docs/widgets/ups/index.mdx +++ b/apps/docs/docs/widgets/ups/index.mdx @@ -4,6 +4,8 @@ description: "Monitor the status of your UPS devices through a NUT server." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -17,6 +19,10 @@ This widget displays the status of the UPS devices reported by a Network UPS Too status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and output voltage. +Every device keeps an integration-scoped identity. Advanced view always labels each device with its owning integration, while compact layouts add the source label only when multiple healthy integrations are present. + +When multiple UPS integrations are selected, devices 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. + ### Layouts import screenshotFull from "./img/screenshot.png"; @@ -41,6 +47,8 @@ The widget adapts to the size of the tile, so it stays readable from a full dash ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx index 0a7e649e8..e0150a94d 100644 --- a/apps/docs/docs/widgets/uptime-kuma/index.mdx +++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx @@ -4,6 +4,8 @@ description: "Displays monitor uptime statistics, average uptime percentage, and hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -15,6 +17,10 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma"; This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. +Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches. + +When multiple Uptime Kuma integrations are selected, monitors 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. + ### Supported Integrations + + ### Adding the widget diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx index e5225a022..fbf41c4e3 100644 --- a/apps/docs/docs/widgets/video/index.mdx +++ b/apps/docs/docs/widgets/video/index.mdx @@ -4,6 +4,8 @@ description: "Embed a video stream or video from a camera or a website." 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"; @@ -25,6 +27,8 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp ![video big-buck-bunny](./img/bunny.png) + + ### Adding the widget diff --git a/apps/docs/docs/widgets/video/index.tsx b/apps/docs/docs/widgets/video/index.tsx index e0df87ce0..7d57a27ad 100644 --- a/apps/docs/docs/widgets/video/index.tsx +++ b/apps/docs/docs/widgets/video/index.tsx @@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = { icon: IconDeviceCctv, name: "Video Stream", description: "Embed a video stream or video from a camera or a website.", - path: "../../widgets/stock-price", + path: "../../widgets/video", configuration: { items: [ { diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx index 0c774a7b0..0e23619e5 100644 --- a/apps/docs/docs/widgets/vpn/index.mdx +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -4,6 +4,8 @@ description: "Monitor the status of your VPN integrations." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; @@ -36,6 +38,8 @@ import singleIntegrationImage from "./img/single.png"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 05b918785..f36dc68ea 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -4,6 +4,8 @@ description: "Displays the current weather information of a set 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"; @@ -12,6 +14,9 @@ import { weatherWidget } from "."; 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 advanced view always combines current conditions with the full seven-day forecast, including humidity, wind, +gusts, sunrise and sunset. Both views reuse the same cached Open Meteo response. ### Screenshots @@ -23,6 +28,8 @@ import currentWeather from "./img/current.png"; weather current + + ### Adding the widget diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx new file mode 100644 index 000000000..80ba47b46 --- /dev/null +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -0,0 +1,47 @@ +import { IconArrowsMaximize, IconMouse2, IconPlus } from "@tabler/icons-react"; + +const interactions = [ + { + icon: IconArrowsMaximize, + title: "Advanced view", + description: + "Hold Shift while hovering (then pin it), press Shift + Enter, or choose Open advanced view from the widget menu.", + }, + { + icon: IconMouse2, + title: "Widget actions", + description: + "Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.", + }, + { + icon: IconPlus, + title: "Place it precisely", + description: + "With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.", + }, +] as const; + +export const WidgetInteractionGuide = () => ( +
+ + Power-user controls + Show shortcuts + + Hide shortcuts + + +
+ {interactions.map(({ icon: Icon, title, description }) => ( +
+ +
{title}
+

{description}

+
+ ))} +
+

+ Advanced view reuses the widget's live data and cache. Touch devices expose a maximize control; board actions + respect your permissions and right-click preference. +

+
+); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 19bf1d0fa..026c6d5c7 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -6,6 +6,7 @@ import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@ho import { useEditMode } from "@homarr/boards/edit-mode"; import { useScopedI18n } from "@homarr/translation/client"; +import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { getBoardLaneColumnCount, getInitialBoardLogicalHeight, @@ -56,66 +57,68 @@ export const ClientBoard = () => { .join(" "); return ( - - - - - - - -
- {leftColumnCount > 0 && leftSection && ( - - )} + + + + + + + + +
+ {leftColumnCount > 0 && leftSection && ( + + )} -
- -
- - {rightColumnCount > 0 && rightSection && ( - - )} -
-
-
-
-
-
+ + + {rightColumnCount > 0 && rightSection && ( + + )} +
+
+
+
+
+
+ ); }; diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css new file mode 100644 index 000000000..d1b045802 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -0,0 +1,132 @@ +.focusScope { + height: 100%; +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 198; + background: rgba(0, 0, 0, 0.64); + backdrop-filter: blur(6px) saturate(0.75); + touch-action: none; + animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.surface { + position: fixed; + z-index: 199; + pointer-events: auto; + overflow: hidden; + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-lg); + overscroll-behavior: contain; + animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +.backdropClosing { + animation: focus-backdrop-out 180ms ease-in forwards; +} + +.surfaceClosing { + animation: focus-surface-out 180ms ease-in forwards; +} + +.sourcePlaceholder { + pointer-events: none; +} + +.surfaceHost { + width: 100%; + height: 100%; +} + +.surfaceHostAdvanced { + position: fixed; + inset: 0; + z-index: 199; + width: auto; + height: auto; + pointer-events: none; +} + +.surfaceControls { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + +.closeButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + pointer-events: auto; +} + +.pinButton { + position: absolute; + top: 8px; + right: 52px; + z-index: 2; + pointer-events: auto; +} + +.touchButton { + position: absolute; + right: 8px; + bottom: 8px; + z-index: 2; + display: none; + pointer-events: auto; +} + +@media (pointer: coarse) { + .touchButton { + display: inline-flex; + } +} + +@keyframes focus-backdrop-in { + from { + opacity: 0; + } +} + +@keyframes focus-surface-in { + from { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0.72; + } +} + +@keyframes focus-backdrop-out { + to { + opacity: 0; + } +} + +@keyframes focus-surface-out { + to { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .backdrop, + .surface { + animation-duration: 1ms; + } +} + +@media (forced-colors: active) { + .backdrop { + backdrop-filter: none; + background: Canvas; + opacity: 0.8; + } +} diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx new file mode 100644 index 000000000..79e4ff50d --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -0,0 +1,213 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Box, Portal, RemoveScroll } from "@mantine/core"; + +import { useEditMode } from "@homarr/boards/edit-mode"; + +import classes from "./advanced-focus.module.css"; +import { isEscapeOwnedByNestedOverlay } from "./escape"; +import type { FocusRect } from "./geometry"; + +interface ActiveFocus { + itemId: string; + source: HTMLElement; + sourceRect: FocusRect; + pinned: boolean; + autofocusClose: boolean; + phase: "visible" | "closing"; +} + +interface AdvancedFocusContextValue { + active: ActiveFocus | null; + open: (itemId: string, source: HTMLElement, pinned?: boolean) => void; + close: (restoreFocus?: boolean) => void; + dismiss: (itemId: string) => void; + pin: (autofocusClose?: boolean) => void; + hover: (itemId: string, source: HTMLElement) => void; + leave: (itemId: string) => void; +} + +const AdvancedFocusContext = createContext(null); +const HOVER_DELAY_MS = 150; +const CLOSE_DURATION_MS = 180; + +const isEditableTarget = (target: EventTarget | null) => + target instanceof HTMLElement && + (target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable); + +export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { + const [isEditMode] = useEditMode(); + const [active, setActive] = useState(null); + const activeRef = useRef(null); + const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null); + const shiftHeldRef = useRef(false); + const hoverTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + + const cancelHoverTimer = useCallback(() => { + if (hoverTimerRef.current === null) return; + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + }, []); + + const updateActive = useCallback((next: ActiveFocus | null) => { + activeRef.current = next; + setActive(next); + }, []); + + const cancelCloseTimer = useCallback(() => { + if (closeTimerRef.current === null) return; + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + }, []); + + const open = useCallback( + (itemId: string, source: HTMLElement, pinned = false) => { + if (isEditMode) return; + cancelHoverTimer(); + cancelCloseTimer(); + const rect = source.getBoundingClientRect(); + updateActive({ + itemId, + source, + sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + pinned, + autofocusClose: pinned, + phase: "visible", + }); + }, + [cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive], + ); + + const close = useCallback( + (restoreFocus = true) => { + cancelHoverTimer(); + const current = activeRef.current; + if (!current || current.phase === "closing") return; + updateActive({ ...current, phase: "closing" }); + const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS; + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + updateActive(null); + if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus()); + }, closeDuration); + }, + [cancelHoverTimer, updateActive], + ); + + const startHoverTimer = useCallback(() => { + cancelHoverTimer(); + const hovered = hoveredRef.current; + if (!hovered || !shiftHeldRef.current || activeRef.current) return; + hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS); + }, [cancelHoverTimer, open]); + + const hover = useCallback( + (itemId: string, source: HTMLElement) => { + hoveredRef.current = { itemId, source }; + startHoverTimer(); + }, + [startHoverTimer], + ); + + const leave = useCallback( + (itemId: string) => { + if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; + cancelHoverTimer(); + }, + [cancelHoverTimer], + ); + + const dismiss = useCallback( + (itemId: string) => { + if (activeRef.current?.itemId !== itemId) return; + cancelHoverTimer(); + cancelCloseTimer(); + updateActive(null); + }, + [cancelCloseTimer, cancelHoverTimer, updateActive], + ); + + const pin = useCallback( + (autofocusClose = false) => { + const current = activeRef.current; + if (!current || current.pinned || current.phase === "closing") return; + updateActive({ ...current, pinned: true, autofocusClose }); + }, + [updateActive], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && activeRef.current) { + const advancedSurface = document.querySelector(`.${classes.surface}`); + if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface)) + return; + event.preventDefault(); + close(); + return; + } + if (isEditMode) return; + if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return; + if (activeRef.current?.pinned) return; + shiftHeldRef.current = true; + startHoverTimer(); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key !== "Shift") return; + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current && !activeRef.current.pinned) close(false); + }; + const handleBlur = () => { + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current && !activeRef.current.pinned) close(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + cancelHoverTimer(); + cancelCloseTimer(); + }; + }, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]); + + useEffect(() => { + if (isEditMode && activeRef.current) close(false); + }, [close, isEditMode]); + + const value = useMemo( + () => ({ active, open, close, dismiss, pin, hover, leave }), + [active, close, dismiss, hover, leave, open, pin], + ); + + return ( + + + {children} + + {active && ( + + close()} + aria-hidden + /> + + )} + + ); +}; + +export const useAdvancedFocus = () => { + const context = useContext(AdvancedFocusContext); + if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider"); + return context; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts new file mode 100644 index 000000000..dc123eeff --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; + +import { isEscapeOwnedByNestedOverlay } from "./escape"; + +describe("isEscapeOwnedByNestedOverlay", () => { + test("allows Escape from the advanced surface itself", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const button = document.createElement("button"); + surface.append(button); + + expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false); + }); + + test("leaves Escape to a nested Mantine modal", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const modal = document.createElement("section"); + modal.setAttribute("role", "dialog"); + const input = document.createElement("input"); + modal.append(input); + + expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true); + }); + + test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const overlay = document.createElement("div"); + overlay.setAttribute("role", role); + const target = document.createElement("button"); + overlay.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("honors Mantine's stop-propagation marker for menus and comboboxes", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const target = document.createElement("button"); + target.setAttribute("data-mantine-stop-propagation", "true"); + surface.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("does not mistake the compact widget source for a nested overlay", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const source = document.createElement("button"); + + expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts new file mode 100644 index 000000000..47bd86dd1 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts @@ -0,0 +1,13 @@ +const escapeOwnerSelector = [ + '[data-mantine-stop-propagation="true"]', + '[role="dialog"]', + '[role="menu"]', + '[role="listbox"]', +].join(", "); + +export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => { + if (!(target instanceof Element)) return false; + + const escapeOwner = target.closest(escapeOwnerSelector); + return escapeOwner !== null && escapeOwner !== advancedSurface; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts new file mode 100644 index 000000000..4b5ee0b89 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { getAdvancedFocusRect } from "./geometry"; + +describe("getAdvancedFocusRect", () => { + it("expands a widget and keeps it inside the viewport", () => { + expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({ + left: 240, + top: 190, + width: 720, + height: 480, + }); + }); + + it("uses the available viewport on small screens", () => { + expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({ + left: 8, + top: 68, + width: 374, + height: 624, + }); + }); + + it("keeps controls below the fixed board header on short screens", () => { + const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 }); + + expect(rect.top).toBeGreaterThanOrEqual(84); + expect(rect.top + rect.height).toBeLessThanOrEqual(744); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.ts new file mode 100644 index 000000000..78bdda016 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.ts @@ -0,0 +1,26 @@ +import { appShellHeaderHeight } from "../../layout/constants"; + +export interface FocusRect { + left: number; + top: number; + width: number; + height: number; +} + +export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => { + const margin = viewport.width < 640 ? 8 : 24; + const availableWidth = Math.max(0, viewport.width - margin * 2); + const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2); + const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4))); + const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4))); + + return { + left: Math.max(margin, (viewport.width - width) / 2), + top: Math.max( + appShellHeaderHeight + margin, + appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2, + ), + width, + height, + }; +}; diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index fcc194618..cc505157b 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import type { Item } from "~/app/[locale]/boards/_types"; -import { getFirstEmptyPosition } from "../empty-position"; +import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position"; describe("get first empty position", () => { test.each([ @@ -106,6 +106,37 @@ describe("get first empty position", () => { expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); }); + + test("does not place a wide item beyond the right edge", () => { + const elements = createElementsFromLayout([["a", "a", "a", " "]]); + + expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({ + xOffset: 0, + yOffset: 1, + }); + }); +}); + +describe("get nearest empty position", () => { + test("keeps the preferred cell when it fits", () => { + expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({ + xOffset: 2, + yOffset: 1, + }); + }); + + test("chooses the nearest available cell when the preferred cell is occupied", () => { + const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }]; + + expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({ + xOffset: 2, + yOffset: 0, + }); + }); + + test("returns undefined when the item cannot fit within a bounded section", () => { + expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined(); + }); }); const createElementsFromLayout = (layout: string[][]) => { diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 29b5615e2..5344a2289 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useUpdateBoard } from "@homarr/boards/updater"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; +import type { Board } from "~/app/[locale]/boards/_types"; import type { CreateItemInput } from "./actions/create-item"; import { createItemCallback } from "./actions/create-item"; import type { DuplicateItemInput } from "./actions/duplicate-item"; @@ -34,7 +35,12 @@ export const useItemActions = () => { const createItem = useCallback( (input: CreateItemInput) => { - updateBoard(createItemCallback(input)); + let nextBoard: Board | undefined; + updateBoard((previous) => { + nextBoard = createItemCallback(input)(previous); + return nextBoard; + }); + return nextBoard; }, [updateBoard], ); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 08c4b93b1..614f9017e 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,9 +1,10 @@ -import type { ComponentType, MutableRefObject } from "react"; -import { Suspense, use, useRef } from "react"; +import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react"; +import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; import dynamic from "next/dynamic"; import type { CardProps } from "@mantine/core"; -import { Badge, Card, Center, Loader } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core"; +import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -13,12 +14,23 @@ import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; import { WidgetError } from "@homarr/widgets/errors"; -import { getWidgetQueryKeys } from "@homarr/widgets/definition"; -import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; +import { + createWidgetRuntimeState, + getWidgetQueryKeys, + supportsAdvancedFocus as definitionSupportsAdvancedFocus, +} from "@homarr/widgets/definition"; +import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { startAdvancedFocusEntrance } from "../advanced-focus/entrance"; +import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; +import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface"; +import { redirectShiftWheel } from "../advanced-focus/wheel"; import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; @@ -92,38 +104,305 @@ const WidgetDefinitionLoadError = ({ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); - const board = useRequiredBoard(); const widgetStateRef = useRef | null>(null); + const widgetRuntimeRef = useRef(createWidgetRuntimeState()); return ( - <> - } + } + > + +
+ +
+ + } > - -
- -
- - } + +
+
+ ); +}; + +interface LoadedBoardItemContentProps { + item: SectionItem; + width: number; + height: number; + widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + contentRef: (element: HTMLDivElement | null) => void; +} + +const LoadedBoardItemContent = ({ + item, + width, + height, + widgetStateRef, + widgetRuntimeRef, + contentRef, +}: LoadedBoardItemContentProps) => { + const { definition, Component } = use(loadWidgetResources(item.kind)); + const sourceRef = useRef(null); + const cardRef = useRef(null); + const previewEntranceFrameRef = useRef(null); + const { width: viewportWidth, height: viewportHeight } = useViewportSize(); + const board = useRequiredBoard(); + const t = useI18n(); + const [isEditMode] = useEditMode(); + const [manualSurface, setManualSurface] = useState(null); + const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); + const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); + const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition); + const widgetName = t(`widget.${item.kind}.name`); + const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); + const advancedViewId = `advanced-focus-${item.id}`; + const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; + const isAdvanced = activeFocus !== null; + const advancedRect = activeFocus + ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) + : null; + const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null; + const isPreview = activeFocus?.activation === "preview"; + const isManual = activeFocus?.activation === "manual"; + + useIsomorphicEffect(() => { + if (!supportsAdvancedFocus) return; + const target = document.createElement("div"); + target.className = advancedFocusClasses.surfacePortalTarget ?? ""; + setSurfacePortalTarget(target); + return () => target.remove(); + }, [supportsAdvancedFocus]); + + useEffect(() => () => dismiss(item.id), [dismiss, item.id]); + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + event.currentTarget !== event.target || + event.key !== "Enter" || + !event.shiftKey || + isEditMode || + !supportsAdvancedFocus + ) + return; + event.preventDefault(); + if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true }); + }; + + useEffect(() => { + const surface = isManual ? manualSurface : cardRef.current; + if (!isAdvanced || !surface) return; + + const handleWheel = (event: WheelEvent) => { + if (!event.shiftKey) return; + const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1; + const delta = (event.deltaY || event.deltaX) * multiplier; + if (!redirectShiftWheel(surface, event.target, delta)) return; + event.preventDefault(); + event.stopPropagation(); + }; + + surface.addEventListener("wheel", handleWheel, { capture: true, passive: false }); + return () => surface.removeEventListener("wheel", handleWheel, { capture: true }); + }, [isAdvanced, isManual, manualSurface]); + + const cancelPreviewEntrance = useCallback(() => { + if (previewEntranceFrameRef.current === null) return; + cancelAnimationFrame(previewEntranceFrameRef.current); + previewEntranceFrameRef.current = null; + }, []); + + const mountCompactHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || isAdvanced) return; + cancelPreviewEntrance(); + host.append(surfacePortalTarget); + }, + [cancelPreviewEntrance, isAdvanced, surfacePortalTarget], + ); + + const mountPreviewHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || !isPreview) { + if (!host) cancelPreviewEntrance(); + return; + } + + if (activeFocus?.phase !== "visible") { + host.append(surfacePortalTarget); + return; + } + const card = cardRef.current; + const readyClass = advancedFocusClasses.surfaceReady; + if (!card || !readyClass) return; + + cancelPreviewEntrance(); + previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass); + }, + [activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget], + ); + + const mountManualHost = useCallback( + (host: HTMLDivElement | null) => { + if (!host || !surfacePortalTarget || !isManual) return; + cancelPreviewEntrance(); + host.append(surfacePortalTarget); + }, + [cancelPreviewEntrance, isManual, surfacePortalTarget], + ); + + useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]); + + const previewStyle = + isPreview && advancedRect && activeFocus + ? ({ + left: advancedRect.left, + top: advancedRect.top, + width: advancedRect.width, + height: advancedRect.height, + "--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`, + "--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`, + "--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1, + "--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1, + } as CSSProperties) + : undefined; + + const widgetCard = ( + 0), + "--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined, + }, + }} + style={previewStyle} + > + + ( + <> + {isEditMode && ( + + )} +
+ +
+ + )} > - - -
- {item.advancedOptions.title?.trim() && ( +
+ + + ); + + return ( + <> + + { + if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current); + }} + onPointerLeave={() => { + if (supportsAdvancedFocus) leave(item.id); + }} + className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)} + > +
+ {!supportsAdvancedFocus && widgetCard} +
+ {surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)} + {isPreview && ( + +
+ + )} + {isManual && activeFocus && advancedRect && closePosition && ( + close()} + > +
+ + )} + + + {!isAdvanced && item.advancedOptions.title?.trim() && ( { ); }; -interface LoadedBoardItemContentProps extends Omit { - innerRef: (element: HTMLDivElement | null) => void; -} - -const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => { - const { definition, Component } = use(loadWidgetResources(item.kind)); - const [isEditMode] = useEditMode(); - - return ( - - - ( - <> - -
- -
- - )} - > - -
-
-
- ); -}; - interface InnerContentProps { item: SectionItem; width: number; height: number; widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + displayMode: "compact" | "advanced"; definition: WidgetDefinition; Component: ComponentType>; } -const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { +const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) { const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); @@ -260,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte )} ); -}; +}); const Throw = ({ when, error }: { when: boolean; error: Error }) => { if (when) throw error; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css index ef8c56d8d..1a53dd49b 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.module.css +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -2,7 +2,6 @@ opacity: 0; transition: opacity 150ms ease; } - .card:hover .action, .card:focus-within .action { opacity: 1; diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index b9fb82670..0bc6c296a 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -290,7 +290,6 @@ const ItemSelectModalContent = ({ ); }; - const ItemSelectModalFrame = ({ actions, innerProps, diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index ce4ece543..50d69da51 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,149 +1,154 @@ "use client"; -import type { MutableRefObject, ReactNode } from "react"; +import type { MutableRefObject, ReactNode, RefObject } from "react"; import { useCallback, useMemo, useState } from "react"; -import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; -import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react"; +import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core"; +import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; +import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; +import { usePersistBoard } from "@homarr/boards/updater"; import { useTimeAgo } from "@homarr/common"; import { useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; -import { getWidgetQueryKeys } from "@homarr/widgets/definition"; +import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition"; +import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { useBoardPermissions } from "../permissions/client"; import { useItemActions } from "./item-actions"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; +import { matchesWidgetItemQuery } from "./widget-query-scope"; interface WidgetContextMenuProps { item: SectionItem; definition: WidgetDefinition; widgetStateRef: MutableRefObject | null>; + widgetRuntimeRef: WidgetRuntimeRef; + sourceRef: RefObject; children: ReactNode; } -export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { - const { data: session } = useSession(); - const [isEditMode] = useEditMode(); - const settings = useSettings(); - - if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; - - return ( - - {children} - - ); -}; - -const WidgetContextMenuInner = ({ +export const WidgetContextMenu = ({ item, definition, - widgetStateRef, + widgetRuntimeRef, + sourceRef, children, - settings, -}: WidgetContextMenuProps & { settings: ReturnType }) => { - const [isMenuOpen, setIsMenuOpen] = useState(false); - - return ( - - {children} - - {isMenuOpen && ( - - )} - - - ); -}; - -type WidgetContextMenuDropdownProps = Omit & { - settings: ReturnType; -}; - -const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => { +}: WidgetContextMenuProps) => { + const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const { updateAndPersistBoard } = usePersistBoard(board); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); - const { data: session } = useSession(); - const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); - const hasSupportedIntegrations = - "supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0; + const settings = useSettings(); const { openModal } = useModalAction(LazyWidgetEditModal); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); + const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0; const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { enabled: hasSupportedIntegrations, }); - const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); + const canConfigureWidget = + hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false)); + const canOpenAdvancedFocus = supportsAdvancedFocus(definition); const queryClient = useQueryClient(); + const { open: openAdvancedFocus } = useAdvancedFocus(); + const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); + const [menuOpened, setMenuOpened] = useState(false); - const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); - const isWidgetFetching = - useIsFetching({ - predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), - }) > 0; - const handleRefetch = useCallback(() => { - void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); - }, [queryClient, widgetQueryKeys]); + const persistBoard = useCallback( + (updater: (previous: typeof board) => typeof board) => { + updateAndPersistBoard(updater, { + onError: () => { + showErrorNotification({ + title: t("item.menu.notification.saveError.title"), + message: t("item.menu.notification.saveError.message"), + }); + }, + }); + }, + [t, updateAndPersistBoard], + ); const options = useMemo( () => reduceWidgetOptionsWithDefinition(definition, settings, item.options), [definition, settings, item.options], ); + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); + const matchesWidgetQuery = useCallback( + (queryKey: QueryKey) => + matchesWidgetItemQuery( + queryKey, + widgetQueryKeys, + { + itemId: item.id, + boardId: board.id, + integrationIds: item.integrationIds, + options, + runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef), + }, + definition.queryMatcher, + ), + [board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef], + ); + const isWidgetFetching = + useIsFetching({ + type: "active", + predicate: (query) => matchesWidgetQuery(query.queryKey), + }) > 0; + const handleRefetch = useCallback(() => { + void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) }); + }, [matchesWidgetQuery, queryClient]); + + const canInteractWithSelectedIntegrations = useMemo(() => { + const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id)); + return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id)); + }, [integrationsWithInteractAccess, item.integrationIds]); + + const setItemOptions = useCallback( + (partial: Record) => { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem, + ), + })); + }, + [item.id, persistBoard], + ); type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { if (!canConfigureWidget) return []; const rawOptions = definition.createOptions(settings) as unknown as Record; - return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); + return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu); }, [canConfigureWidget, definition, settings]); - const widgetContextActions = useMemo(() => { - const def = definition as unknown as Record; - if (typeof def.contextActions !== "function") return []; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const actions = (def.contextActions as any)({ + const widgetContextActions = + definition.contextActions?.({ options, - setOptions: (partial: Record) => { - updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); - }, + setOptions: setItemOptions, integrationIds: item.integrationIds, - context: { isEditMode, boardId: board.id, itemId: item.id }, - widgetStateRef, - }); - return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); - - const persistBoard = useCallback( - (updatedItems: typeof board.items) => { - saveBoard({ ...board, items: updatedItems }); - }, - [board, saveBoard], - ); + context: { + isEditMode, + boardId: board.id, + itemId: item.id, + canInteractWithSelectedIntegrations, + }, + widgetRuntimeRef, + }) ?? []; const openEditModal = useCallback(() => { openModal( @@ -159,27 +164,24 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings updateItemOptions({ itemId: item.id, newOptions: editResult.options }); updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); - if (!isEditMode) { - persistBoard( - board.items.map((boardItem) => - boardItem.id !== item.id - ? boardItem - : { - ...boardItem, - options: editResult.options, - advancedOptions: editResult.advancedOptions, - integrationIds: editResult.integrationIds, - }, - ), - ); - } + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id !== item.id + ? boardItem + : { + ...boardItem, + options: editResult.options, + advancedOptions: editResult.advancedOptions, + integrationIds: editResult.integrationIds, + }, + ), + })); }, - integrationData: integrationData.filter( - (integration) => - "supportedIntegrations" in definition && - (definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind), + integrationData: integrationData.filter((integration) => + definition.supportedIntegrations?.some((kind) => kind === integration.kind), ), - integrationSupport: "supportedIntegrations" in definition, + integrationSupport: definition.supportedIntegrations !== undefined, settings, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, }, @@ -187,72 +189,87 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings { title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` }, ); }, [ - openModal, + definition, + integrationData, item, - updateItemOptions, + openModal, + persistBoard, + settings, updateItemAdvancedOptions, updateItemIntegrations, - integrationData, - definition, - settings, - isEditMode, - persistBoard, - board, + updateItemOptions, ]); const handleToggle = useCallback( - (key: string) => (checked: boolean) => { - const newOptions = { ...options, [key]: checked }; - updateItemOptions({ itemId: item.id, newOptions }); - persistBoard( - board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })), - ); - }, - [options, item.id, updateItemOptions, persistBoard, board], + (key: string) => (checked: boolean) => setItemOptions({ [key]: checked }), + [setItemOptions], ); - const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); + if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}; + + const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden); return ( - <> - {toggleOptions.length > 0 && ( - <> - {tMenu("options")} - {toggleOptions.map(([key]) => ( - handleToggle(key)(!options[key])}> - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - - {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} - - + + {children} + + {canOpenAdvancedFocus && ( + <> + } + onClick={() => { + if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current); + }} + > + {t("item.advancedFocus.open")} - ))} - - )} + + + )} - {visibleWidgetActions.length > 0 && ( - <> - {toggleOptions.length > 0 && } - {tMenu("actions")} - {visibleWidgetActions.map((action) => { - const Icon = action.icon; - return ( - : undefined} - onClick={action.onClick} - disabled={action.disabled} - color={action.color} - > - {String(translateIfNecessary(t, action.label))} - - ); - })} - - )} + {toggleOptions.length > 0 && ( + <> + {tMenu("options")} + {toggleOptions.map(([key]) => ( + + {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} + {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} + + ))} + + )} + + {visibleWidgetActions.length > 0 && ( + <> + {toggleOptions.length > 0 && } + {tMenu("actions")} + {visibleWidgetActions.map((action) => { + const Icon = action.icon; + return ( + : undefined} + onClick={action.onClick} + disabled={action.disabled} + color={action.color} + > + {translateIfNecessary(t, action.label)} + + ); + })} + + )} - <> {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && } : } @@ -263,7 +280,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings {tMenu("refresh")} @@ -279,23 +296,27 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings > {tMenu("settings")} - - + + ); }; interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKeys: QueryKey[]; + matchesQuery: (queryKey: QueryKey) => boolean; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { - const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; +const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => { + const queries = queryClient.getQueryCache().findAll({ + type: "active", + predicate: (query) => matchesQuery(query.queryKey), + }); const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; - const ageLabel = useTimeAgo(new Date(latest)); + const ageLabel = useTimeAgo(new Date(latest || Date.now())); + const ageSuffix = latest > 0 ? ` · ${ageLabel}` : ""; if (isFetching) { return ( @@ -316,22 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer ); } - const hasError = queries.some((q) => q.state.status === "error"); - + const hasError = queries.some((query) => query.state.status === "error"); if (hasError) { - const errorQuery = queries.find((q) => q.state.status === "error"); - const errorMessage = - errorQuery?.state.error instanceof Error - ? errorQuery.state.error.message - : String(errorQuery?.state.error ?? "Unknown error"); - return ( - + {t("item.menu.status.error")} - {latest > 0 && ` · ${ageLabel}`} + {ageSuffix} @@ -343,7 +357,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer {t("item.menu.status.success")} - {latest > 0 && ` · ${ageLabel}`} + {ageSuffix} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index c90d332b1..f8de2cf43 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -105,6 +105,15 @@ flex-shrink: 0; } +@media (forced-colors: active) { + .panel { + border-color: CanvasText; + background: Canvas; + box-shadow: none; + backdrop-filter: none; + } +} + .metadataValue { overflow: hidden; text-overflow: ellipsis; diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx new file mode 100644 index 000000000..606bc5ece --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx @@ -0,0 +1,177 @@ +"use client"; + +import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { BoxProps } from "@mantine/core"; +import { Box, Menu } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; + +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useModalAction } from "@homarr/modals"; +import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; + +import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; +import { ItemSelectModal } from "../../items/item-select-modal"; +import { useBoardPermissions } from "../../permissions/client"; +import type { BoardPlacementHint } from "../../items/actions/create-item"; +import type { UseGridstackRefs } from "./use-gridstack"; + +interface MenuState { + x: number; + y: number; + placement: BoardPlacementHint; +} + +interface Props extends BoxProps { + section: Exclude | DynamicSectionItem; + refs: UseGridstackRefs; +} + +export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren) => { + const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); + const settings = useSettings(); + const t = useI18n(); + const { hasChangeAccess } = useBoardPermissions(board); + const { openModal } = useModalAction(ItemSelectModal); + const [isEditMode] = useEditMode(); + const [menu, setMenu] = useState(null); + const longPressTimer = useRef | null>(null); + const returnFocusRef = useRef(null); + + const enabled = hasChangeAccess && settings.enableRightClickOnWidgets; + const clearLongPress = () => { + if (longPressTimer.current === null) return; + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + }; + + useEffect( + () => () => { + if (longPressTimer.current !== null) clearTimeout(longPressTimer.current); + }, + [], + ); + const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => { + const wrapper = refs.wrapperElement.current; + const gridstack = refs.gridstack.current; + if (!(target instanceof Element) || !wrapper || !gridstack) return null; + if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null; + + const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true); + const rowLimit = section.kind === "dynamic" ? section.height : undefined; + if ( + cell.x < 0 || + cell.y < 0 || + cell.x >= gridstack.getColumn() || + (rowLimit !== undefined && cell.y >= rowLimit) || + !gridstack.isAreaEmpty(cell.x, cell.y, 1, 1) + ) { + return null; + } + + return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y }; + }; + + const handleContextMenu = (event: MouseEvent) => { + if (!enabled) return; + const placement = resolvePlacement(event.target, event.pageX, event.pageY); + if (!placement) return; + event.preventDefault(); + event.stopPropagation(); + setMenu({ x: event.clientX, y: event.clientY, placement }); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (!enabled || event.pointerType !== "touch") return; + const placement = resolvePlacement(event.target, event.pageX, event.pageY); + if (!placement) return; + clearLongPress(); + longPressTimer.current = setTimeout(() => { + setMenu({ x: event.clientX, y: event.clientY, placement }); + navigator.vibrate?.(20); + }, 500); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !enabled || + event.currentTarget !== event.target || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + const wrapper = event.currentTarget; + const gridstack = refs.gridstack.current; + if (!gridstack) return; + + const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1); + for (let yOffset = 0; yOffset < rowCount; yOffset++) { + for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) { + if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue; + const rect = wrapper.getBoundingClientRect(); + event.preventDefault(); + returnFocusRef.current = wrapper; + setMenu({ + x: rect.left + 24, + y: rect.top + 24, + placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset }, + }); + return; + } + } + }; + + return ( + <> + + {children} + + { + if (!opened) { + setMenu(null); + const returnFocus = returnFocusRef.current; + returnFocusRef.current = null; + requestAnimationFrame(() => returnFocus?.focus()); + } + }} + withinPortal + position="bottom-start" + > + + + + + } + onClick={() => { + returnFocusRef.current = null; + if (menu) { + openModal({ placement: menu.placement, board, persistImmediately: !isEditMode }); + } + setMenu(null); + }} + > + {t("item.create.addAtPosition")} + + + + + ); +}; diff --git a/apps/nextjs/src/components/layout/constants.ts b/apps/nextjs/src/components/layout/constants.ts new file mode 100644 index 000000000..2799f8304 --- /dev/null +++ b/apps/nextjs/src/components/layout/constants.ts @@ -0,0 +1 @@ +export const appShellHeaderHeight = 60; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index b51cf3220..56001cafb 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core"; import { useAtomValue } from "jotai"; import { useOptionalBackgroundProps } from "./background"; +import { appShellHeaderHeight } from "./constants"; import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { @@ -23,7 +24,7 @@ export const ClientShell = ({ return ( { + let browser: Browser; + let baseUrl: string; + let stopContainerAsync: (() => Promise) | undefined; + + beforeAll(async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials); + await seedInteractionBoardAsync(db, ownerId); + await seedCredentialsUserAsync(db, { + ...disabledModifierCredentials, + enableRightClickOnWidgets: false, + boardPermission: "modify", + }); + await seedCredentialsUserAsync(db, { + ...viewerCredentials, + enableRightClickOnWidgets: true, + boardPermission: "view", + }); + + const homarrContainer = await createHomarrContainer({ + environment: { AUTH_PROVIDERS: "credentials" }, + mounts: { "/appdata": localMountPath }, + }).start(); + stopContainerAsync = async () => { + await homarrContainer.stop(); + }; + baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + browser = await chromium.launch(); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + await stopContainerAsync?.(); + }); + + test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const compactSurface = widget.locator(".clock-wrapper"); + const advancedDialog = page.getByRole("dialog", { name: "Date and time advanced view" }); + + await compactSurface.evaluate((element) => { + element.setAttribute("data-lifecycle-probe", "same-instance"); + }); + await widget.focus(); + await page.keyboard.down("Shift"); + await widget.hover(); + await expect(advancedDialog).toBeVisible({ timeout: 2_000 }); + await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(widget).toBeFocused(); + await page.keyboard.up("Shift"); + await expect(advancedDialog).toBeHidden(); + await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(widget).toBeFocused(); + + await page.keyboard.down("Shift"); + await widget.hover(); + await expect(advancedDialog).toBeVisible({ timeout: 2_000 }); + await page.getByRole("button", { name: "Keep advanced view open" }).click(); + const pinnedButton = page.getByRole("button", { name: "Advanced view is pinned" }); + await expect(pinnedButton).toBeFocused(); + await page.keyboard.up("Shift"); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Shift+Tab"); + await expect(advancedDialog).toBeVisible(); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + await expect(widget).toBeFocused(); + + await page.keyboard.press("Shift+Enter"); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + await expect(widget).toBeFocused(); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + } finally { + await context.close(); + } + }, 60_000); + + test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first(); + const iframe = iframeSlot.locator("iframe"); + await iframe.evaluate((element) => { + element.srcdoc = ''; + }); + const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe"); + await frame.locator("#state").fill("changed"); + + await iframeSlot.focus(); + await page.keyboard.press("Shift+Enter"); + await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeVisible(); + await expect(frame.locator("#state")).toHaveValue("changed"); + await page.keyboard.press("Escape"); + await expect(frame.locator("#state")).toHaveValue("changed"); + + await iframeSlot.focus(); + await page.keyboard.press("Shift+Enter"); + await page.setViewportSize({ width: 800, height: 900 }); + await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeHidden(); + await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible(); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps advanced controls below the fixed header on short screens", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 }); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + await widget.focus(); + await page.keyboard.press("Shift+Enter"); + const closeButton = page.getByRole("button", { name: "Close advanced view" }); + const bounds = await closeButton.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(60); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(768); + await closeButton.click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden(); + } finally { + await context.close(); + } + }, 60_000); + + test("opens the add-at-position chooser only when right click and change access are enabled", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const grid = page.locator(".grid-stack[data-kind='empty']"); + const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" }); + await expect(grid).toHaveAttribute("aria-label", "Add item here"); + + await grid.focus(); + await page.keyboard.press("Shift+F10"); + await expect(addHereMenuItem).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(addHereMenuItem).toBeHidden(); + await expect(grid).toBeFocused(); + + await touchAndHoldEmptyGridSpaceAsync(grid); + await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 }); + await page.keyboard.press("Escape"); + + await rightClickEmptyGridSpaceAsync(grid); + await addHereMenuItem.click(); + await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); + } finally { + await context.close(); + } + }, 60_000); + + test("honors the user setting gate even when the user can modify the board", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const grid = page.locator(".grid-stack[data-kind='empty']"); + + await widget.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await expect(grid).not.toHaveAttribute("aria-label", "Add item here"); + await rightClickEmptyGridSpaceAsync(grid); + await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps read-only advanced viewing but blocks add-at-position without change access", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const grid = page.locator(".grid-stack[data-kind='empty']"); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect(grid).not.toHaveAttribute("aria-label", "Add item here"); + await rightClickEmptyGridSpaceAsync(grid); + await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0); + } finally { + await context.close(); + } + }, 60_000); +}); + +const openBoardAsync = async ( + browser: Browser, + baseUrl: string, + credentials: { username: string; password: string }, + viewport = { width: 1280, height: 900 }, +): Promise<{ context: BrowserContext; page: Page }> => { + const context = await browser.newContext({ reducedMotion: "reduce", viewport }); + const page = await context.newPage(); + + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(credentials.username); + await page.locator("#password").fill(credentials.password); + await page.locator("css=button[type='submit']").click(); + await page.waitForURL(baseUrl, { timeout: 15_000 }); + await page.goto(`${baseUrl}/boards/${boardName}`); + await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({ + timeout: 15_000, + }); + + return { context, page }; +}; + +const rightClickEmptyGridSpaceAsync = async (grid: Locator) => { + const bounds = await grid.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + + await grid.click({ + button: "right", + position: { x: bounds.width - 16, y: Math.min(24, bounds.height / 2) }, + }); +}; + +const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => { + const bounds = await grid.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + + const clientX = bounds.x + bounds.width - 16; + const clientY = bounds.y + Math.min(24, bounds.height / 2); + await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY }); + await new Promise((resolve) => setTimeout(resolve, 550)); + await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY }); +}; + +const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => { + const boardId = createId(); + const baseLayoutId = createId(); + const desktopLayoutId = createId(); + const sectionId = createId(); + const clockItemId = createId(); + const iframeItemId = createId(); + + await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false }); + await db.insert(layouts).values([ + { id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId }, + { id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId }, + ]); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(items).values([ + { + id: clockItemId, + kind: "clock", + boardId, + options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: iframeItemId, + kind: "iframe", + boardId, + options: stringify({ embedUrl: "https://example.com", allowScrolling: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + ]); + await db.insert(itemLayouts).values( + [baseLayoutId, desktopLayoutId].flatMap((layoutId) => [ + { + itemId: clockItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 3, + }, + { + itemId: iframeItemId, + sectionId, + layoutId, + xOffset: 4, + yOffset: 0, + width: 4, + height: 3, + }, + ]), + ); + await db + .update(users) + .set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true }) + .where(eq(users.id, ownerId)); +}; + +const seedCredentialsUserAsync = async ( + db: SqliteDatabase, + input: { + username: string; + password: string; + enableRightClickOnWidgets: boolean; + boardPermission: "view" | "modify"; + }, +) => { + const userId = createId(); + const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) }); + if (!board) throw new Error("Interaction board was not seeded"); + + await db.insert(users).values({ + id: userId, + name: input.username, + provider: "credentials", + password: await hashPasswordAsync(input.password), + enableRightClickOnWidgets: input.enableRightClickOnWidgets, + homeBoardId: board.id, + completedBoardTour: true, + completedManageTour: true, + }); + await db.insert(boardUserPermissions).values({ + boardId: board.id, + userId, + permission: input.boardPermission, + }); +}; diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts new file mode 100644 index 000000000..7933b0d16 --- /dev/null +++ b/packages/api/src/router/test/widgets/timetable.spec.ts @@ -0,0 +1,114 @@ +import { stringify } from "superjson"; +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { boards, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { timetableRouter } from "../../widgets/timetable"; + +const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() })); + +vi.mock("@homarr/request-handler/timetable", () => ({ + timetableGetTimetableRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.getTimetable(input); + return { data: { stationId: "station", timestamp: new Date(0), entries: [] } }; + }, + }), + }, + timetableSearchStationsRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.searchStations(input); + return { data: [] }; + }, + }), + }, +})); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCaller = (db: Database, session: Session | null) => + timetableRouter.createCaller({ db, deviceType: undefined, session }); + +const createTimetableItemAsync = async ( + db: Database, + { baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean }, +) => { + const ownerId = createId(); + const boardId = createId(); + const itemId = createId(); + await db.insert(users).values({ id: ownerId }); + await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic }); + await db.insert(items).values({ + id: itemId, + boardId, + kind: "timetable", + options: stringify({ baseUrl }), + }); + return { boardId, itemId, ownerId }; +}; + +describe("timetableRouter source authorization", () => { + it("allows itemless setup only for the fixed default provider", async () => { + const caller = createCaller(createDb(), null); + + await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]); + await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("loads a persisted private endpoint only through a viewable timetable item", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl }); + const caller = createCaller(db, null); + + await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }); + expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl })); + await expect( + caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects a persisted endpoint when its board is not viewable", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + + await expect( + createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("allows board modifiers to search an unsaved self-hosted endpoint", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await caller.searchStations({ baseUrl, boardId, query: "Bern" }); + expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl })); + }); + + it("does not treat public board view access as configuration access", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl }); + + await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts new file mode 100644 index 000000000..905ac00a4 --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { everyBeszelIntegrationFailed } from "../beszel-failures"; + +describe("everyBeszelIntegrationFailed", () => { + it("keeps partial successes", () => { + expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false); + }); + + it("detects when every integration failed", () => { + expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true); + }); + + it("allows an empty integration selection", () => { + expect(everyBeszelIntegrationFailed([])).toBe(false); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts new file mode 100644 index 000000000..12fbe5778 --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security"; + +describe("custom API request hardening", () => { + it("accepts HTTP endpoints without embedded credentials", () => { + expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => { + expect(() => validateCustomApiUrl(url)).toThrow(); + }); + + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/api/src/router/widgets/beszel-failures.ts b/packages/api/src/router/widgets/beszel-failures.ts new file mode 100644 index 000000000..e7d75f87d --- /dev/null +++ b/packages/api/src/router/widgets/beszel-failures.ts @@ -0,0 +1,5 @@ +export const everyBeszelIntegrationFailed = (results: readonly unknown[]) => + results.length > 0 && + results.every( + (result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined, + ); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 738adecf7..1380bc405 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -13,6 +13,7 @@ import { import { settleIntegrationQueries } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { everyBeszelIntegrationFailed } from "./beszel-failures"; import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); @@ -20,6 +21,20 @@ const MAX_PENDING_LIVE_EVENTS = 4; const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); +function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) { + if (everyBeszelIntegrationFailed(results)) { + const firstError = results[0]; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Every Beszel integration failed while loading ${operation}: ${ + typeof firstError === "object" && firstError !== null && "error" in firstError + ? String(firstError.error) + : "unknown error" + }`, + }); + } +} + export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); + throwIfEveryBeszelIntegrationFailed(results, "systems"); return results; }), @@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); + throwIfEveryBeszelIntegrationFailed(results, "alerts"); return results; }), diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 729e82634..68d968412 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,16 +1,13 @@ import { z } from "zod/v4"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const logger = createLogger({ module: "calendarRouter" }); - export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure .meta({ @@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - const settled = await Promise.allSettled( - ctx.integrations.map(async (integration) => { - const { integrationIds: _integrationIds, ...handlerInput } = input; - const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); - const { data } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { integrationIds: _integrationIds, ...handlerInput } = input; + const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); + const { data } = await innerHandler.getDataAsync(); - return { - events: data, - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - }; - }), - ); - - return settled.flatMap((result, index) => { - if (result.status === "fulfilled") { - return [result.value]; - } - const integration = ctx.integrations[index]; - logger.warn( - new ErrorWithMetadata( - "Calendar integration request failed; skipping events for this integration", - { - integrationId: integration?.id, - integrationKind: integration?.kind, - }, - { cause: result.reason }, - ), - ); - return []; + return { + events: data, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + }; }); }), }); diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts new file mode 100644 index 000000000..ec2d5c81f --- /dev/null +++ b/packages/api/src/router/widgets/custom-api-security.ts @@ -0,0 +1,21 @@ +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024; + +export function validateCustomApiUrl(urlString: string): URL { + if (urlString.length > 2_048) throw new Error("Custom API URL is too long"); + + const url = new URL(urlString); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Custom API URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Custom API URL must not contain credentials"); + return url; +} + +export async function readBoundedJsonResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Custom API response"); +} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index c73194ad8..7f3994fbd 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -33,7 +33,6 @@ const namedRequestInputSchema = itemInputSchema.extend({ requestId: z.string().min(1).max(64), params: runtimeParamsSchema.default({}), }); - interface CustomWidgetItemOptions { definitionId: string; configuration: Record; diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 08951da7c..fdeb77831 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -18,20 +18,33 @@ const queryFirewall = < }, >( handler: THandler, + createFallback: () => Awaited["getDataAsync"]>>["data"], ) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => - settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - summary: data as Awaited["getDataAsync"]>>["data"], - }; - }), + settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data as Awaited["getDataAsync"]>>["data"], + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, + summary: createFallback(), + error: error instanceof Error ? error.message : "Integration request failed", + }), + throwOnAllFailures: true, + }, + ), ); export const firewallRouter = createTRPCRouter({ - getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), - getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), - getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), - getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), + getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })), + getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []), + getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })), + getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 54a949dab..fe411d9be 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure .concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = indexerManagerRequestHandler.handler(integration, {}); - const { data: indexers } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = indexerManagerRequestHandler.handler(integration, {}); + const { data: indexers } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - indexers, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + indexers, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + indexers: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), testAllIndexers: protectedProcedure .concat(createIndexerManagerIntegrationMiddleware("interact")) diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index dd0d687cb..2f38423f1 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaOrganizerRouter = createTRPCRouter({ @@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize }); const { data } = await innerHandler.getDataAsync(); return { integrationId: integration.id, + integrationName: integration.name, ...data, + error: undefined as string | undefined, }; - }), + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + missing: [], + missingCount: 0, + queued: [], + queuedCount: 0, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, ); - return results; }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index fbd192499..eb13b25d1 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({ data.map((request) => ({ ...request, integrationId: integration.id, + integration, })), ) .toSorted((dataA, dataB) => { @@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + return { success: true }; }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 591d58d2b..80ce9018d 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({ const { data } = await mediaServerRequestHandler .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) .getDataAsync(); - return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; + return { + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: data, + }; }); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index bae8ba9fd..86124164c 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = notificationsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + data: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 6cb198395..2a37f3517 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -37,6 +37,11 @@ export const rssFeedRouter = createTRPCRouter({ }), ); + const failures = settled.filter((result) => result.status === "rejected"); + if (settled.length > 0 && failures.length === settled.length) { + throw failures[0]?.reason; + } + return settled .flatMap((result, index) => { if (result.status === "fulfilled") return result.value.data.entries; diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index c37468752..a0f38c7fa 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,8 +1,12 @@ import { z } from "zod/v4"; +import { TRPCError } from "@trpc/server"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; +import { + smartHomeEntityStateRequestHandler, + toSafeEntityDetails, +} from "@homarr/request-handler/smart-home-entity-state"; import type { IntegrationAction } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; @@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({ .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const { data } = await innerHandler.getDataAsync(); - return data; + return data.state; + }), + entityDetails: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')", + }, + }) + .input(z.object({ entityId: z.string() })) + .concat(createSmartHomeIntegrationMiddleware("query")) + .query(async ({ ctx: { integration }, input }) => { + const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); + const { data } = await innerHandler.getDataAsync(); + return toSafeEntityDetails(data); }), switchEntity: protectedProcedure .meta({ @@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); const success = await client.triggerToggleAsync(input.entityId); - - return success; + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); + } + return true; }), executeAutomation: protectedProcedure .meta({ @@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({ .input(z.object({ automationId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); - await client.triggerAutomationAsync(input.automationId); + const success = await client.triggerAutomationAsync(input.automationId); + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); + } + return true; }), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index ef60ce320..84f680382 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,36 +1,90 @@ +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; import z from "zod"; +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { boards, items } from "@homarr/db/schema"; import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; -const baseUrlSchema = z.string().url(); +const baseUrlSchema = z.string().url().max(2_048); +const timetableSourceSchema = z.object({ + baseUrl: baseUrlSchema, + itemId: z.string().max(100).optional(), + boardId: z.string().max(100).optional(), +}); + +const resolveTimetableBaseUrlAsync = async ( + ctx: { db: Database; session: Session | null }, + input: z.infer, + allowBoardConfiguration = false, +) => { + const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl); + if (allowBoardConfiguration) { + if (input.boardId) { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + return requestedBaseUrl; + } + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" }); + } + return DEFAULT_TIMETABLE_BASE_URL; + } + if (!input.itemId) { + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" }); + } + return DEFAULT_TIMETABLE_BASE_URL; + } + + const item = await ctx.db.query.items.findFirst({ + where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")), + }); + if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" }); + + await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view"); + const options = parse>(item.options); + const savedBaseUrl = normalizeTimetableBaseUrl( + typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL, + ); + if (requestedBaseUrl !== savedBaseUrl) { + throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" }); + } + return savedBaseUrl; +}; export const timetableRouter = createTRPCRouter({ getTimetable: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - stationId: z.string(), + timetableSourceSchema.extend({ + stationId: z.string().min(1).max(200), limit: z.number().int().min(1).max(100), }), ) - .query(async ({ input }) => { - const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync(); return data; }), searchStations: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - query: z.string(), + timetableSourceSchema.extend({ + query: z.string().max(200), }), ) - .query(async ({ input }) => { - const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync(); return data; }), }); diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 407e4986f..79ee5308b 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data as typeof data | null, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + summary: null, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index a9dbff5ba..773117877 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -11,6 +11,7 @@ interface IntegrationLike { interface Options { fallback?: (integration: TIntegration, error: unknown) => TResult; + throwOnAllFailures?: boolean; } export async function settleIntegrationQueries( @@ -37,15 +38,18 @@ export async function settleIntegrationQueries 0) { + if ( + errors.length > 0 && + (results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length)) + ) { throw errors[0]; } diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts index 09f80bc31..70024da7f 100644 --- a/packages/api/src/test/settle-integrations.spec.ts +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -47,4 +47,21 @@ describe("settleIntegrationQueries", () => { }), ).resolves.toEqual(["First", "Second"]); }); + + test("can retain partial fallbacks while rejecting total failure", async () => { + const error = new Error("Integration unavailable"); + const options = { + fallback: (integration: (typeof integrations)[number]) => integration.name, + throwOnAllFailures: true, + }; + + await expect( + settleIntegrationQueries( + integrations, + (integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)), + options, + ), + ).resolves.toEqual(["First", "Second"]); + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error); + }); }); diff --git a/packages/boards/src/updater.spec.ts b/packages/boards/src/updater.spec.ts new file mode 100644 index 000000000..1f78f7e97 --- /dev/null +++ b/packages/boards/src/updater.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { getBoardSaveScopeId, shouldRefetchAfterSaveError } from "./updater"; + +describe("board persistence coordination", () => { + it("uses one mutation scope for every save of the same board", () => { + expect(getBoardSaveScopeId("board-1")).toBe(getBoardSaveScopeId("board-1")); + expect(getBoardSaveScopeId("board-1")).not.toBe(getBoardSaveScopeId("board-2")); + }); + + it("only refetches after a failed save when no newer local board exists", () => { + const failedBoard = { id: "board-1", items: ["first"] }; + const newerBoard = { id: "board-1", items: ["first", "second"] }; + + expect(shouldRefetchAfterSaveError(failedBoard, failedBoard)).toBe(true); + expect(shouldRefetchAfterSaveError(newerBoard, failedBoard)).toBe(false); + }); +}); diff --git a/packages/boards/src/updater.ts b/packages/boards/src/updater.ts index 49a301a8c..6763c48ce 100644 --- a/packages/boards/src/updater.ts +++ b/packages/boards/src/updater.ts @@ -5,13 +5,19 @@ import { useCallback } from "react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +type Board = RouterOutputs["board"]["getHomeBoard"]; +type UpdateCallback = (previous: Board) => Board; + let boardName: string | null = null; export const updateBoardName = (name: string | null) => { boardName = name; }; -type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; +export const getBoardSaveScopeId = (boardId: string) => `board-save:${boardId}`; + +export const shouldRefetchAfterSaveError = (currentBoard: T | undefined, failedBoard: T) => + currentBoard === failedBoard; export const useUpdateBoard = () => { const utils = clientApi.useUtils(); @@ -21,9 +27,13 @@ export const useUpdateBoard = () => { if (!boardName) { throw new Error("Board name is not set"); } - utils.board.getBoardByName.setData({ name: boardName }, (previous) => - previous ? updaterWithoutUndefined(previous) : previous, - ); + let updatedBoard: Board | undefined; + utils.board.getBoardByName.setData({ name: boardName }, (previous) => { + if (!previous) return previous; + updatedBoard = updaterWithoutUndefined(previous); + return updatedBoard; + }); + return updatedBoard; }, [utils], ); @@ -32,3 +42,37 @@ export const useUpdateBoard = () => { updateBoard, }; }; + +interface PersistBoardOptions { + onError?: (error: unknown) => void; +} + +export const usePersistBoard = ({ id, name }: Pick) => { + const utils = clientApi.useUtils(); + const { updateBoard } = useUpdateBoard(); + const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({ + scope: { id: getBoardSaveScopeId(id) }, + }); + + const updateAndPersistBoard = useCallback( + (updater: UpdateCallback, options?: PersistBoardOptions) => { + const updatedBoard = updateBoard(updater); + if (!updatedBoard) return undefined; + const persistedBoard = utils.board.getBoardByName.getData({ name }); + if (!persistedBoard) return undefined; + + void saveBoardAsync(persistedBoard).catch((error: unknown) => { + const currentBoard = utils.board.getBoardByName.getData({ name }); + if (shouldRefetchAfterSaveError(currentBoard, persistedBoard)) { + void utils.board.getBoardByName.invalidate({ name }); + } + options?.onError?.(error); + }); + + return persistedBoard; + }, + [name, saveBoardAsync, updateBoard, utils], + ); + + return { updateAndPersistBoard }; +}; diff --git a/packages/common/src/bounded-response.ts b/packages/common/src/bounded-response.ts new file mode 100644 index 000000000..d1432fb39 --- /dev/null +++ b/packages/common/src/bounded-response.ts @@ -0,0 +1,45 @@ +interface ReadableResponse { + headers: { get: (name: string) => string | null }; + body: { + getReader: () => { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + cancel: () => Promise; + }; + } | null; +} + +export async function readBoundedJsonResponseAsync( + response: ReadableResponse, + maxBytes: number, + label = "Response", +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + chunks.push(value); + } + + const body = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)) as unknown; +} diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index d6e62d5b9..502ae5f6b 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -2,3 +2,4 @@ export * from "./security"; export * from "./encryption"; export * from "./request"; export * from "./errors"; +export * from "./bounded-response"; diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts index 441d7fe3a..2dee921d0 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration { const statsByLibrary = await Promise.all( libraries.map(async (library) => ({ + library, mediaType: library.mediaType, stats: await this.getLibraryStatsAsync(library.id), })), @@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration { totalPodcasts: counts.totalPodcasts, totalListeningTimeSeconds: listeningStats.totalTime, activeSessions, + libraries: statsByLibrary.map(({ library, stats }) => ({ + id: library.id, + name: library.name, + mediaType: library.mediaType, + totalItems: stats.totalItems, + totalDurationSeconds: stats.totalDuration ?? null, + totalSizeBytes: stats.totalSize ?? null, + })), }; } diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts index 495a24c08..4c853c139 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts @@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData { totalPodcasts: number; totalListeningTimeSeconds: number; activeSessions: number; + libraries?: { + id: string; + name: string; + mediaType: "book" | "podcast"; + totalItems: number; + totalDurationSeconds: number | null; + totalSizeBytes: number | null; + }[]; } export type AudiobookshelfLibrary = z.infer; diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index 9496482d6..ce4892d10 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -36,7 +36,6 @@ export interface ImmichAlbum { export interface ImmichAsset { id: string; - originalPath: string; thumbhash: string | null; fileModifiedAt: string; fileCreatedAt: string; @@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration { thumbhash: asset.thumbhash, fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, - originalPath: asset.originalPath, updatedAt: asset.updatedAt, publicLink, }; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 96482c908..313ba23fd 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -62,6 +62,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; export type { StreamSession } from "./interfaces/media-server/media-server-types"; +export type { EntityState } from "./interfaces/smart-home/smart-home-types"; export type { TdarrQueue, TdarrPieSegment, diff --git a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts index efc113dc7..2208c4953 100644 --- a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts +++ b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts @@ -1,4 +1,4 @@ -interface EntityState { +export interface EntityState { attributes: Record; entity_id: string; last_changed: Date; diff --git a/packages/integrations/src/overseerr/overseerr-integration.spec.ts b/packages/integrations/src/overseerr/overseerr-integration.spec.ts new file mode 100644 index 000000000..1fc4484d0 --- /dev/null +++ b/packages/integrations/src/overseerr/overseerr-integration.spec.ts @@ -0,0 +1,30 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { OverseerrIntegration } from "./overseerr-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new OverseerrIntegration({ + id: "overseerr-1", + name: "Overseerr", + url: "https://overseerr.example.com", + externalUrl: null, + decryptedSecrets: [{ kind: "apiKey", value: "test-key" }], +}); + +describe("Overseerr media request actions", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["approve", () => integration.approveRequestAsync(42)], + ["decline", () => integration.declineRequestAsync(42)], + ])("rejects %s when Overseerr returns a non-success response", async (_action, run) => { + mockFetch.mockResolvedValue(new Response(null, { status: 500 })); + + await expect(run()).rejects.toThrow("Response from integration did not indicate success"); + }); +}); diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 10aa73114..f36e46745 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -1,8 +1,8 @@ import { z } from "zod/v4"; +import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -259,49 +259,27 @@ export class OverseerrIntegration public async approveRequestAsync(requestId: number): Promise { logger.info("Approving media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to approve media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); } public async declineRequestAsync(requestId: number): Promise { logger.info("Declining media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to decline media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); } private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { diff --git a/packages/integrations/src/traefik/traefik-bounds.spec.ts b/packages/integrations/src/traefik/traefik-bounds.spec.ts new file mode 100644 index 000000000..1f14aff89 --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.spec.ts @@ -0,0 +1,12 @@ +import { Response } from "undici"; +import { describe, expect, it } from "vitest"; + +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; + +describe("readBoundedTraefikJsonAsync", () => { + it("rejects a response that exceeds its byte budget", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + + await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/integrations/src/traefik/traefik-bounds.ts b/packages/integrations/src/traefik/traefik-bounds.ts new file mode 100644 index 000000000..99a6c8b1b --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.ts @@ -0,0 +1,12 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024; + +export async function readBoundedTraefikJsonAsync( + response: Response, + maxBytes = MAX_TRAEFIK_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Traefik response"); +} diff --git a/packages/integrations/src/traefik/traefik-integration.spec.ts b/packages/integrations/src/traefik/traefik-integration.spec.ts new file mode 100644 index 000000000..ced92e33a --- /dev/null +++ b/packages/integrations/src/traefik/traefik-integration.spec.ts @@ -0,0 +1,43 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { TraefikIntegration } from "./traefik-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new TraefikIntegration({ + id: "traefik-1", + name: "Traefik", + url: "https://traefik.example.com", + externalUrl: null, + decryptedSecrets: [], +}); + +describe("TraefikIntegration response bounds", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockImplementation((url) => { + const path = new URL(url.toString()).pathname; + if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" })); + if (path === "/api/http/routers") { + return Promise.resolve( + jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))), + ); + } + return Promise.resolve(jsonResponse([])); + }); + }); + + it("caps detailed resource allocation while retaining bounded summary counts", async () => { + const result = await integration.getDashboardDataAsync(); + + expect(result.resources).toHaveLength(200); + expect(result.resources.at(-1)?.name).toBe("router-199"); + expect(result.http.routers.total).toBe(250); + }); +}); + +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts index 0dc8cf57f..2e0e72b4c 100644 --- a/packages/integrations/src/traefik/traefik-integration.ts +++ b/packages/integrations/src/traefik/traefik-integration.ts @@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResource, + TraefikResourceDetail, TraefikResourceStatus, TraefikResourceSummary, } from "./traefik-types"; @@ -24,6 +26,8 @@ type ResourcePath = | "/api/udp/services" | "/api/entrypoints"; +const MAX_RESOURCE_DETAILS = 200; + export class TraefikIntegration extends Integration { protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() }); @@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration { throw new ResponseError(response); } - traefikVersionSchema.parse(await response.json()); + traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return { success: true }; } @@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration { this.getResourcesAsync("/api/udp/routers"), this.getResourcesAsync("/api/udp/services"), ]); + + const allResults = [ + versionResult, + entryPointsResult, + httpRoutersResult, + httpServicesResult, + httpMiddlewaresResult, + tcpRoutersResult, + tcpServicesResult, + tcpMiddlewaresResult, + udpRoutersResult, + udpServicesResult, + ]; + if (allResults.every((result) => result.status === "rejected")) { + throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed"); + } + + const endpointResults = [ + ["version", versionResult], + ["entrypoints", entryPointsResult], + ["http/routers", httpRoutersResult], + ["http/services", httpServicesResult], + ["http/middlewares", httpMiddlewaresResult], + ["tcp/routers", tcpRoutersResult], + ["tcp/services", tcpServicesResult], + ["tcp/middlewares", tcpMiddlewaresResult], + ["udp/routers", udpRoutersResult], + ["udp/services", udpServicesResult], + ] as const; + const failedEndpoints = endpointResults + .filter(([, result]) => result.status === "rejected") + .map(([endpoint]) => endpoint); const version = versionResult.status === "fulfilled" ? versionResult.value : null; const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : []; const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : []; @@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration { const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : []; const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : []; + const resources: TraefikResourceDetail[] = []; + const resourceGroups = [ + ["http", "router", httpRouters], + ["http", "service", httpServices], + ["http", "middleware", httpMiddlewares], + ["tcp", "router", tcpRouters], + ["tcp", "service", tcpServices], + ["tcp", "middleware", tcpMiddlewares], + ["udp", "router", udpRouters], + ["udp", "service", udpServices], + ] as const; + for (const [protocol, type, entries] of resourceGroups) { + const remaining = MAX_RESOURCE_DETAILS - resources.length; + if (remaining === 0) break; + resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining))); + } + return { version, - entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + entryPoints: entryPoints + .slice(0, MAX_RESOURCE_DETAILS) + .map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + resources, + failedEndpoints, http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares), tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares), udp: { @@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration { }; } + private createResourceDetails( + protocol: TraefikResourceDetail["protocol"], + type: TraefikResourceDetail["type"], + resources: TraefikResource[], + ): TraefikResourceDetail[] { + return resources.map((resource, index) => ({ + protocol, + type, + name: resource.name ?? `${type}-${index + 1}`, + provider: resource.provider ?? null, + status: this.normalizeStatus(resource.status), + })); + } + private async getVersionAsync() { const response = await this.fetchOkAsync("/api/version"); - const version = traefikVersionSchema.parse(await response.json()); + const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return version.Version ?? null; } private async getResourcesAsync(path: ResourcePath) { const response = await this.fetchOkAsync(path); - return traefikResourcesSchema.parse(await response.json()); + return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response)); } private createProtocolSummary( @@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration { const response = await fetchWithTrustedCertificatesAsync(this.url(path), { headers: this.getAuthHeaders(), timeout: 10_000, + bodyTimeout: 10_000, }); if (!response.ok) { diff --git a/packages/integrations/src/traefik/traefik-types.ts b/packages/integrations/src/traefik/traefik-types.ts index 5874ef41b..188941465 100644 --- a/packages/integrations/src/traefik/traefik-types.ts +++ b/packages/integrations/src/traefik/traefik-types.ts @@ -31,9 +31,19 @@ export interface TraefikProtocolSummary { middlewares: TraefikResourceSummary; } +export interface TraefikResourceDetail { + protocol: "http" | "tcp" | "udp"; + type: "router" | "service" | "middleware"; + name: string; + provider: string | null; + status: TraefikResourceStatus; +} + export interface TraefikDashboardData { version: string | null; entryPoints: string[]; + resources: TraefikResourceDetail[]; + failedEndpoints: string[]; http: TraefikProtocolSummary; tcp: TraefikProtocolSummary; udp: Omit; diff --git a/packages/request-handler/src/smart-home-entity-state.spec.ts b/packages/request-handler/src/smart-home-entity-state.spec.ts new file mode 100644 index 000000000..bee81d2de --- /dev/null +++ b/packages/request-handler/src/smart-home-entity-state.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { toSafeEntityDetails } from "./smart-home-entity-state"; + +describe("toSafeEntityDetails", () => { + it("keeps display attributes and removes sensitive Home Assistant metadata", () => { + const details = toSafeEntityDetails({ + entity_id: "camera.front_door", + state: "streaming", + last_changed: new Date("2026-01-01T00:00:00Z"), + last_updated: new Date("2026-01-01T00:00:01Z"), + attributes: { + friendly_name: "Front door", + icon: "mdi:camera", + access_token: "secret-camera-token", + latitude: 48.85, + }, + }); + + expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" }); + expect(details.attributes).not.toHaveProperty("access_token"); + expect(details.attributes).not.toHaveProperty("latitude"); + }); +}); diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts index 3c8851522..1d0d2471c 100644 --- a/packages/request-handler/src/smart-home-entity-state.ts +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -1,10 +1,29 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { EntityState } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; +const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const; + +export const toSafeEntityDetails = (entity: EntityState) => { + const attributes: Partial> = {}; + for (const key of safeAttributeKeys) { + const value = entity.attributes[key]; + if (typeof value === "string") attributes[key] = value; + } + + return { + attributes, + entity_id: entity.entity_id, + last_changed: entity.last_changed, + last_updated: entity.last_updated, + state: entity.state, + }; +}; + export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler< - string, + EntityState, IntegrationKindByCategory<"smartHomeServer">, { entityId: string } >({ @@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`); } - return result.data.state; + return result.data; }, }); diff --git a/packages/request-handler/src/timetable-url.ts b/packages/request-handler/src/timetable-url.ts new file mode 100644 index 000000000..f7241a542 --- /dev/null +++ b/packages/request-handler/src/timetable-url.ts @@ -0,0 +1,26 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch"; +export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024; + +export const normalizeTimetableBaseUrl = (baseUrl: string) => { + if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long"); + + const url = new URL(baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Timetable base URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials"); + if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment"); + + return url.toString().replace(/\/$/, ""); +}; + +export async function readBoundedTimetableJsonAsync( + response: Response, + maxBytes = MAX_TIMETABLE_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Timetable response"); +} diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts new file mode 100644 index 000000000..cb08cb313 --- /dev/null +++ b/packages/request-handler/src/timetable.spec.ts @@ -0,0 +1,79 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +beforeEach(() => vi.clearAllMocks()); + +describe("normalizeTimetableBaseUrl", () => { + it("normalizes an HTTP endpoint", () => { + expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])( + "rejects unsafe base URL %s", + (url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(), + ); +}); + +describe("timetable response bounds", () => { + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); + + it("caps and caches station results and rejects redirects", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify( + Array.from({ length: 125 }, (_, index) => ({ + id: String(index), + label: `Station ${index}`, + iconclass: "train", + })), + ), + ), + ); + const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" }; + + const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + + expect(first.data).toHaveLength(100); + expect(second.data).toHaveLength(100); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }), + ); + }); + + it("never caches more departures than requested", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + connections: Array.from({ length: 5 }, (_, index) => ({ + time: `2026-01-01T00:0${index}:00Z`, + line: "1", + color: "ffffff", + terminal: { name: `Stop ${index}` }, + dep_delay: "0", + })), + }), + ), + ); + + const result = await timetableGetTimetableRequestHandler + .handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 }) + .getDataAsync(); + + expect(result.data.entries).toHaveLength(2); + }); +}); diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 2be7355d8..b55a688f7 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -4,6 +4,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createRequestHandler } from "./lib/request-handler"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; export interface Station { id: string; @@ -31,6 +32,9 @@ export interface Timetable { } const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; +const MAX_STATION_RESULTS = 100; +const MAX_TIMETABLE_ENTRIES = 100; +const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const; export const timetableSearchStationsRequestHandler = createRequestHandler< Station[], @@ -53,7 +57,7 @@ export const timetableGetTimetableRequestHandler = createRequestHandler< }); const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { - const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); + const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`); for (const [key, value] of Object.entries(queryParams)) { url.searchParams.set(key, value.toString()); } @@ -63,10 +67,12 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record => { const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), + timetableFetchOptions, ); if (!response.ok) throw new ResponseError(response); - const data = await searchSchema.parseAsync(await response.json()); + const body = await readBoundedTimetableJsonAsync(response); + const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body); return data .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) @@ -99,10 +105,15 @@ const getTimetableAsync = async ( date, time, }), + timetableFetchOptions, ); if (!response.ok) throw new ResponseError(response); - const data = await timetableSchema.parseAsync(await response.json()); + const body = await readBoundedTimetableJsonAsync(response); + const envelope = timetableEnvelopeSchema.parse(body); + const data = await timetableSchema.parseAsync({ + connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)), + }); return { stationId: options.stationId, timestamp: now, @@ -145,6 +156,8 @@ const timetableSchema = z.object({ ), }); +const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) }); + const searchSchema = z.array( z.object({ id: z.string().optional(), diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts index ebca0fbeb..97182ab11 100644 --- a/packages/request-handler/src/vpn.ts +++ b/packages/request-handler/src/vpn.ts @@ -1,28 +1,16 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { VpnSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -const logger = createLogger({ module: "vpnRequestHandler" }); - export const vpnSummaryHandler = createIntegrationRequestHandler< - VpnSummary | null, + VpnSummary, IntegrationKindByCategory<"vpn">, Record >({ async requestAsync(integration, _input) { - try { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getSummaryAsync(); - } catch (error) { - logger.warn("Failed to fetch VPN summary, marking as unavailable", { - integrationId: integration.id, - integrationKind: integration.kind, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getSummaryAsync(); }, }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 8a9443d2f..7c0ed58ce 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -741,7 +741,7 @@ }, "create": { "title": "Create Custom Widget", - "subtitle": "Build a safe JSX widget with inline API sources, options, queries, and actions." + "subtitle": "Define an API URL, authentication, and how to display the response data." }, "edit": { "title": "Edit Custom Widget" @@ -766,12 +766,62 @@ "migrate": "Migrate Custom Widget", "copyMigrationPrompt": "Copy LLM migration prompt", "pasteMigration": "Paste migrated widget", - "importMigration": "Import migrated JSON file" + "importMigration": "Import migrated JSON file", + "addMapping": "Add Mapping", + "addColumn": "Add Column", + "removeMapping": "Remove mapping", + "removeColumn": "Remove column", + "addItem": "Add Item", + "removeItem": "Remove item", + "addBar": "Add Bar" }, "field": { "name": "Name", "description": "Description", - "iconUrl": "Icon URL" + "iconUrl": "Icon URL", + "url": "URL", + "authType": "Auth Type", + "headerName": "Header/Query Parameter Name", + "method": "Method", + "requestBody": "Request Body", + "displayType": "Display Type", + "jsonPath": "JSON Path", + "jsonPathHint": "Use JSONPath syntax to extract values, e.g. $.data.temperature or $.items[0].name", + "label": "Label", + "unit": "Unit", + "mappings": "Mappings", + "tablePath": "Array JSON Path", + "tablePathHint": "Path to the array in the response, e.g. $.data.items", + "columns": "Columns", + "header": "Header", + "items": "Items", + "gridColumns": "Grid Columns", + "cardStyle": "Card Style", + "bars": "Bars", + "valuePath": "Value JSON Path", + "maxPath": "Max Value JSON Path", + "showPercentage": "Show Percentage", + "barSize": "Bar Size", + "goodValues": "Good Values (comma-separated)", + "dotSize": "Dot Size", + "valueSize": "Value Size", + "labelPosition": "Label Position", + "kvLayout": "Layout", + "striped": "Striped Rows", + "compact": "Compact Mode", + "maxHeight": "Max Height (px)", + "color": "Color", + "buttonLabel": "Button Label", + "buttonColor": "Button Color", + "confirmText": "Confirmation Text", + "confirmTextHint": "If set, a confirmation dialog will appear before executing the request", + "successMessage": "Success Message", + "successMessageHint": "Message shown after successful execution", + "enabled": "Enabled", + "template": { + "label": "JSX Template", + "description": "Mantine JSX template. Access API response fields via data bindings (e.g. data.name). Use whitelisted components only. Forbidden: constructor, __proto__, eval, Function, window, document." + } }, "editor": { "errorBoundary": { @@ -1026,9 +1076,9 @@ "journal": "Redacted request journal", "actionParameters": "Action parameters (JSON)", "invalidParameters": "Use the declared string, number, and boolean parameter types.", - "runDestructive": "Run destructive action", - "runAction": "Run action", - "deleteConfirmation": "This preview action sends a DELETE request to the configured API.", + "runDestructive": "Run destructive action", + "runAction": "Run action", + "deleteConfirmation": "This preview action sends a DELETE request to the configured API.", "simulated": "Action simulated", "httpStatus": "HTTP {status, number}", "tab": { @@ -1059,7 +1109,11 @@ "modify": "Modify", "full": "Full" } - } + }, + "title": "Preview", + "test": "Test", + "hint": "Click Test to preview the API response with your current configuration.", + "rawResponse": "Raw JSON response" }, "notification": { "created": "Widget \"{name}\" created successfully.", @@ -1072,15 +1126,21 @@ "migrationPromptCopyError": "Failed to copy the migration prompt.", "aiPromptCopied": "AI prompt copied to clipboard", "aiPromptCopiedWithCount": "AI prompt copied to clipboard ({count, number} characters).", - "aiPromptCopyError": "Failed to copy the prompt to the clipboard.", + "aiPromptCopyError": "Failed to copy prompt to clipboard. Your browser may not support this feature.", "updateError": "Failed to update widget. Your changes were not saved.", - "importError": "Failed to import widget. Ensure the file uses the homarr-custom-widget-v2 schema.", + "importError": "Failed to import widget. Ensure the file is a valid custom widget JSON export.", "exportError": "Failed to export widget.", "duplicated": "Widget \"{name}\" duplicated.", "duplicateError": "Failed to duplicate widget.", "toggleError": "Failed to toggle widget status.", "deleteError": "Failed to delete widget.", - "previewError": "Failed to create the preview. Check the API source and try again." + "previewError": "Failed to fetch preview. Check the URL and try again.", + "aiPromptDescription": "Copy a pre-built prompt to paste into any AI chatbot (ChatGPT, Claude, Gemini...). It includes the full widget schema and your API response so the AI can generate a ready-to-import JSON config for you.", + "aiPromptCopy": "Copy to clipboard", + "aiPromptNoResponse": "Tip: Run a test first to include your API response in the prompt for better results.", + "createError": "Failed to create widget. Check your API endpoint is reachable.", + "executed": "Action executed successfully.", + "executeError": "Action failed: {error}" }, "importReview": { "title": "Review custom widget import", @@ -1122,6 +1182,91 @@ "cancel": "Cancel import", "confirm": "Import widget", "confirmMigration": "Confirm v2 replacement" + }, + "placeholder": { + "exampleLabel": "CPU Temperature", + "exampleLabelShort": "CPU Temp", + "exampleHeader": "Name", + "exampleUnit": "°C", + "iconUrl": "https://...", + "url": "https://api.example.com/api/v1/status", + "headerName": "X-API-Key", + "jsonPath": "$.data.value", + "tablePath": "$.data.items", + "columnJsonPath": "$.name", + "valuePath": "$.data.used", + "maxPath": "$.data.total", + "goodValues": "online, true, up", + "color": "blue" + }, + "cardStyleOption": { + "filled": "Filled", + "outline": "Outline", + "subtle": "Subtle" + }, + "layoutOption": { + "list": "List", + "grid": "Grid" + }, + "sizeOption": { + "sm": "Small", + "md": "Medium", + "lg": "Large", + "xl": "Extra Large" + }, + "labelPositionOption": { + "above": "Above", + "below": "Below" + }, + "fieldset": { + "general": "General", + "connection": "API Connection", + "display": "Display Configuration" + }, + "displayTypeDescription": { + "singleValue": "Show one extracted value with a label and unit", + "keyValue": "Show multiple labeled values side by side", + "table": "Show rows of data in a table format", + "statGrid": "Colored stat cards in a grid — like Pi-Hole or Speedtest widgets", + "progressBars": "Progress bars showing usage or completion — for disk, memory, quotas", + "statusIndicator": "Colored status dots showing online/offline or health states", + "countGrid": "Compact number + label counters in a tight grid", + "raw": "Pretty-printed JSON output for debugging", + "actionButton": "A button that triggers a request when clicked", + "customJsx": "Custom JSX layout using whitelisted Mantine components with full API data binding" + }, + "authType": { + "none": "None", + "bearer": "Bearer Token", + "basic": "Basic Auth", + "apiKeyHeader": "API Key (Header)", + "apiKeyQuery": "API Key (Query)" + }, + "displayType": { + "singleValue": "Single Value", + "keyValue": "Key-Value", + "table": "Table", + "statGrid": "Stat Grid", + "progressBars": "Progress Bars", + "statusIndicator": "Status Indicator", + "countGrid": "Count Grid", + "raw": "Raw JSON", + "actionButton": "Action Button", + "customJsx": "Custom JSX" + }, + "secret": { + "apiKey": "API Key / Token", + "username": "Username", + "password": "Password", + "savedPlaceholder": "••••••• (saved, leave empty to keep)" + }, + "table": { + "name": "Name", + "url": "URL", + "display": "Display" + }, + "widget": { + "disabled": "This widget is disabled" } }, "workshop": { @@ -1343,7 +1488,8 @@ "analytics": "Analytics", "vpn": "VPN", "mediaLibrary": "Media Library", - "uptimeMonitoring": "Uptime Monitoring" + "uptimeMonitoring": "Uptime Monitoring", + "beszel": "Beszel" }, "testConnection": { "action": { @@ -1876,7 +2022,13 @@ "kilometersPerHour": "km/h", "milesPerHour": "mph" } - } + }, + "widgetQuery": { + "loading": "Loading widget data", + "stale": "Could not refresh {widget}; showing saved data" + }, + "total": "Total", + "filesystemUsage": "{count, plural, one {Show usage for # filesystem} other {Show usage for # filesystems}}" }, "section": { "option": { @@ -1924,6 +2076,67 @@ "title": "Remove container", "message": "Remove this container? Its contents will move into the surrounding area." } + }, + "dynamic": { + "action": { + "create": "New dynamic section", + "remove": "Remove dynamic section" + }, + "option": { + "title": { + "label": "Title" + }, + "customCssClasses": { + "label": "Custom css classes" + }, + "borderColor": { + "label": "Border color" + } + }, + "remove": { + "title": "Remove dynamic section", + "message": "Are you sure you want to remove this dynamic section? Items will be moved at the same location in the parent section." + } + }, + "category": { + "field": { + "name": { + "label": "Name" + } + }, + "action": { + "create": "New category", + "edit": "Rename category", + "remove": "Remove category", + "moveUp": "Move up", + "moveDown": "Move down", + "createAbove": "New category above", + "createBelow": "New category below", + "openAllInNewTabs": "Open all in tabs" + }, + "create": { + "title": "New category", + "submit": "Add category" + }, + "remove": { + "title": "Remove category", + "message": "Are you sure you want to remove the category {name}?" + }, + "edit": { + "title": "Rename category", + "submit": "Rename category" + }, + "menu": { + "label": { + "create": "New category", + "changePosition": "Change position", + "settings": "Settings" + } + }, + "openAllInNewTabs": { + "title": "Open all in tabs", + "text": "Some browsers may block the bulk-opening of tabs for security reasons. Homarr was unable to open all windows, because your browser blocked this action. Please allow \"Open pop-up windows\" and re-try." + } } }, "item": { @@ -1948,12 +2161,25 @@ "error": "Error", "loading": "Loading", "idle": "Idle" + }, + "notification": { + "saveError": { + "title": "Unable to save widget", + "message": "The widget changes were rolled back" + } } }, "create": { "title": "Choose item to add", "search": "Filter items", - "addToBoard": "Add to board" + "addToBoard": "Add to board", + "addAtPosition": "Add item here", + "notification": { + "error": { + "title": "Unable to add item", + "message": "The item could not be added to this board" + } + } }, "moveResize": { "title": "Move / resize item", @@ -2017,6 +2243,11 @@ "remove": { "title": "Remove item", "message": "Are you sure you want to remove this item?" + }, + "advancedFocus": { + "open": "Open advanced view", + "close": "Close advanced view", + "label": "{widget} advanced view" } }, "widget": { @@ -2121,6 +2352,32 @@ "showBroadcastMessage": { "label": "Show broadcast messages" } + }, + "noProjectSelected": "No project selected", + "runnerStatus": "Runner: {status}", + "updatedAt": "Updated {date}", + "projectId": "Project: {id}", + "sessionReceived": "Session received: {value}", + "sessionSent": "Session sent: {value}", + "sessionId": "Session: {id}", + "startedAt": "Started {date}", + "emptyItems": "No active or recent jobs in this snapshot", + "metric": { + "running": "Running", + "completed": "Done", + "failed": "Failed", + "canceled": "Canceled", + "download": "Download", + "upload": "Upload" + }, + "status": { + "running": "Running", + "completed": "Completed", + "failed": "Failed", + "canceled": "Canceled", + "stopped": "Stopped", + "idle": "Idle", + "unknown": "Unknown" } }, "wud": { @@ -2187,7 +2444,12 @@ "dnsQueriesToday": "Queries today", "domainsBeingBlocked": "Domains on blocklist" }, - "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked" + "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked", + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "unknown": "Unknown" + } }, "dnsHoleControls": { "name": "DNS Hole Controls", @@ -2212,7 +2474,8 @@ } }, "error": { - "internalServerError": "Failed to control DNS Hole" + "internalServerError": "Failed to control DNS Hole", + "bulkActionsFailed": "{count, plural, one {# action failed} other {# actions failed}}" }, "controls": { "enableAll": "Enable All", @@ -2306,11 +2569,20 @@ "noStation": "No station selected", "option": { "baseUrl": { - "label": "Search.ch URL" + "label": "Search.ch URL", + "description": "Custom URLs must resolve to a public IP address. Private and local hosts are rejected." }, "station": { "label": "Station" } + }, + "error": { + "customSourceUnavailable": "The timetable source is unavailable. Check that its URL resolves to a public IP address.", + "stationSearch": "Unable to search stations. Check the custom URL and your board access." + }, + "warning": { + "stale": "The timetable could not be refreshed. Showing departures last updated at {time}.", + "staleCompact": "Stale data" } }, "notebook": { @@ -2383,7 +2655,9 @@ "discard": "Discard changes", "keepEditing": "Keep editing" } - } + }, + "saveFailed": "Unable to save note", + "documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}" }, "anchorNote": { "name": "Anchor Note", @@ -2411,7 +2685,17 @@ "cancel": "Cancel", "readOnlyViewer": "You have viewer access to this note. Editing is disabled.", "saveForbidden": "You do not have permission to edit this note.", - "saveFailed": "Failed to save this note. Please try again." + "saveFailed": "Failed to save this note. Please try again.", + "createdAt": "Created {date}", + "permission": { + "owner": "Owner", + "editor": "Editor", + "viewer": "Viewer" + }, + "status": { + "pinned": "Pinned", + "archived": "Archived" + } }, "iframe": { "name": "iFrame", @@ -2470,6 +2754,26 @@ "clickable": { "label": "Clickable" } + }, + "state": { + "on": "On", + "off": "Off", + "unavailable": "Unavailable", + "unknown": "Unknown" + }, + "advanced": { + "entityId": "Entity ID: {id}", + "lastChanged": "Changed {date}", + "lastUpdated": "Updated {date}", + "attribute": { + "unit": "Unit", + "deviceClass": "Device class", + "icon": "Icon" + } + }, + "error": { + "loadFailed": "Unable to load entity", + "toggleFailed": "Unable to update entity" } }, "smartHome-executeAutomation": { @@ -2485,11 +2789,32 @@ }, "spotlightAction": { "run": "Run {name}" + }, + "advanced": { + "lastExecuted": "Last run at {time}", + "automationId": "Automation ID: {id}", + "ready": "Ready to run", + "noPermission": "No permission to run this automation" + }, + "status": { + "running": "Running automation", + "success": "Automation completed" + }, + "error": { + "executeFailed": "Unable to run automation" } }, "stockPrice": { "name": "Stock Price", "description": "Displays the current stock price of a company", + "advanced": { + "currentPrice": "Current price", + "previousClose": "Previous close", + "change": "Change", + "changePercentage": "Change (%)", + "minimum": "Minimum", + "maximum": "Maximum" + }, "option": { "stock": { "label": "Stock symbol" @@ -2587,6 +2912,13 @@ "showUnmonitored": { "label": "Show unmonitored" } + }, + "advanced": { + "previousMonth": "Show previous month", + "nextMonth": "Show next month", + "eventCount": "{count, plural, one {# event} other {# events}}", + "loading": "Loading calendar events...", + "noEvents": "No events in this month" } }, "weather": { @@ -2647,6 +2979,18 @@ "thunderstorm": "Thunderstorm", "thunderstormWithHail": "Thunderstorm with hail", "unknown": "Unknown" + }, + "details": "Show weather details", + "detailsFor": "Show weather details for {date}", + "advanced": { + "forecast": "7-day forecast", + "day": "Day", + "conditions": "Conditions", + "temperature": "High / low", + "humidity": "Humidity", + "wind": "Wind / gusts ({unit})", + "sun": "Sunrise / sunset", + "noForecast": "No forecast data available" } }, "indexerManager": { @@ -2659,6 +3003,11 @@ }, "title": "Indexer manager", "testAll": "Test all", + "status": { + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "disabled": "Disabled" + }, "error": { "internalServerError": "Failed to fetch indexers status" } @@ -2716,7 +3065,10 @@ "minutes": "{count} minutes", "used": "Used", "available": "Available", - "lastSeen": "Last status update: {lastSeen}" + "lastSeen": "Last status update: {lastSeen}", + "updatesAvailable": "{count, plural, one {# update available} other {# updates available}}", + "rebootRequired": "Reboot required", + "rebootNotRequired": "No reboot required" }, "memory": {}, "error": { @@ -2834,17 +3186,50 @@ "internalServerError": "Failed to fetch Coolify data" }, "server": { - "buildServer": "Build" + "buildServer": "Build", + "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}", + "reachability": "Reachability", + "usability": "Usability" + }, + "source": { + "url": "Source: {url}", + "unavailable": "{source}: unavailable" }, "instances": { "title": "Instances" + }, + "action": { + "openResource": "Open {name}", + "openLogs": "Open {name} logs", + "showIp": "Show IP address", + "hideIp": "Hide IP address" + }, + "status": { + "running": "Running", + "stopped": "Stopped", + "exited": "Exited", + "starting": "Starting", + "restarting": "Restarting", + "unknown": "Unknown", + "online": "Online", + "offline": "Offline", + "reachable": "Reachable", + "unreachable": "Unreachable", + "usable": "Usable", + "unusable": "Unusable" } }, "vpn": { "name": "VPN", "description": "Overview of your VPN connections", "serviceUnavailable": "VPN unavailable", - "option": {} + "option": {}, + "status": { + "running": "Running", + "notRunning": "Not running", + "unavailable": "Unavailable", + "vpn": "VPN: {status}" + } }, "ups": { "name": "UPS", @@ -2871,7 +3256,13 @@ "runtime": "Runtime", "load": "Load", "input": "Input", - "output": "Output" + "output": "Output", + "batteryVoltage": "Battery voltage", + "power": "Power", + "temperature": "Temperature", + "manufacturer": "Manufacturer", + "model": "Model", + "serial": "Serial" }, "error": { "internalServerError": "Failed to fetch UPS data" @@ -3142,7 +3533,13 @@ "resolution": "Resolution", "target": "Target codec" } - } + }, + "unknownUser": "Unknown user" + }, + "summary": { + "sessions": "{count, plural, one {# session} other {# sessions}}", + "playing": "{count} playing", + "transcoding": "{count} transcoding" } }, "paperlessNgx": { @@ -3355,6 +3752,9 @@ "movies": "Missing Movies", "providers": "Provider Issues", "status": "Health Issues", + "sonarrSignalr": "Sonarr SignalR", + "radarrSignalr": "Radarr SignalR", + "announcements": "Announcements", "option": { "showMissingEpisodes": { "label": "Show missing episodes", @@ -3401,7 +3801,8 @@ "users": "Users", "photos": "Photos", "videos": "Videos", - "storage": "Used Storage" + "storage": "Used Storage", + "albumLimit": "Largest albums (up to {count} shown)" }, "immich-albumCarousel": { "name": "Immich Album", @@ -3425,7 +3826,16 @@ } }, "description": "Slideshow of pictures from an Immich album or your full library", - "noPhotos": "No photos found" + "noPhotos": "No photos found", + "albumPhoto": "Album photo", + "actions": { + "previousPhoto": "Previous photo", + "nextPhoto": "Next photo", + "pauseSlideshow": "Pause slideshow", + "resumeSlideshow": "Resume slideshow", + "toggleSlideshow": "Pause or resume slideshow", + "photo": "Photo {number}" + } }, "downloads": { "name": "Download Client", @@ -3800,6 +4210,13 @@ "showPosterImage": { "label": "Show poster image" } + }, + "error": { + "allFeedsFailed": "Unable to load RSS feeds. Check the feed URLs and try again." + }, + "warning": { + "partial": "{count, plural, one {# RSS feed could not be loaded} other {# RSS feeds could not be loaded}}", + "stale": "RSS feeds could not be refreshed. Showing previously loaded entries." } }, "releases": { @@ -3930,14 +4347,31 @@ "noProviderResponse": "No response from provider", "unexpected": "Unexpected provider error" } - } + }, + "expandDetails": "Show details for {name}", + "collapseDetails": "Hide details for {name}" }, "networkControllerSummary": { "option": {}, + "advanced": { + "updated": "Updated", + "metric": { + "latency": "Latency", + "ping": "Ping", + "uptime": "Uptime", + "users": "Users", + "guests": "Guests" + } + }, "card": { "vpn": { - "countConnected": "{count} connected" - } + "countConnected": "{count, plural, one {# connected} other {# connected}}", + "label": "VPN" + }, + "wan": "WAN", + "web": "Web", + "wifi": "Wi-Fi", + "lan": "LAN" }, "error": { "integrationsDisconnected": "No data available, all integrations disconnected", @@ -3947,6 +4381,9 @@ "description": "Displays the summary of a Network Controller (like UniFi Controller)" }, "networkControllerStatus": { + "advanced": { + "updatedAt": "Updated {date}" + }, "card": { "variants": { "wired": { @@ -4015,6 +4452,16 @@ "recv": "Received", "title": "Network Interfaces" } + }, + "empty": { + "noInterfaces": "No network interface data yet" + }, + "versionUnknown": "Unknown version", + "metricAccessible": "{metric}: {value}%, {status}", + "status": { + "normal": "Normal", + "warning": "Warning", + "critical": "Critical" } }, "notifications": { @@ -4064,6 +4511,12 @@ "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" + }, + "range": { + "twelveHours": "12h", + "oneDay": "24h", + "all": "All", + "lastThirty": "Last 30" } }, "uptimeKuma": { @@ -4104,6 +4557,11 @@ }, "error": { "internalServerError": "Failed to fetch uptime data" + }, + "status": { + "up": "Up", + "down": "Down", + "paused": "Paused" } }, "traefik": { @@ -4138,7 +4596,20 @@ }, "error": { "internalServerError": "Failed to fetch Traefik data" - } + }, + "resourceType": { + "router": "Router", + "service": "Service", + "middleware": "Middleware" + }, + "resourceStatus": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown" + }, + "failedEndpoint": "Failed: {endpoint}" }, "audioStats": { "name": "Audio stats", @@ -4260,7 +4731,9 @@ } }, "status": { - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "smart": "SMART", + "free": "{percentage}% free" } }, "customApi": { @@ -4381,6 +4854,12 @@ }, "column": { "system": "System", + "source": "Source", + "hostname": "Hostname", + "os": "OS", + "cpuModel": "CPU model", + "cores": "Cores", + "memoryTotal": "Memory total", "cpu": "CPU", "memory": "Memory", "disk": "Disk", @@ -4441,6 +4920,14 @@ "empty": { "noSystems": "No systems found" }, + "metadata": { + "hostname": "Hostname", + "os": "OS", + "cpuModel": "CPU model", + "cores": "Cores", + "memoryTotal": "Memory total", + "source": "Source" + }, "metric": { "cpu": "CPU", "memory": "Memory", @@ -4521,7 +5008,9 @@ "internalServerError": "Unable to connect to Beszel. Check your integration settings.", "liveConnectionFailed": "Live connection failed. Your Beszel instance may not support real-time streaming.", "switchToHistorical": "Switch to historical view", - "systemNotFound": "Selected system was not found. Choose a different system." + "systemNotFound": "Selected system was not found. Choose a different system.", + "selectionSaveTitle": "Unable to save selection", + "selectionSaveMessage": "Your system or time period selection could not be saved. Please try again." }, "chart": { "cpu": { @@ -4569,6 +5058,14 @@ "title": "Docker Network I/O", "subtitle": "Network traffic of docker containers" } + }, + "period": { + "live": "Live", + "oneHour": "1h", + "twelveHours": "12h", + "oneDay": "24h", + "oneWeek": "1w", + "thirtyDays": "30d" } } }, diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 3d575bfb0..abeb7e350 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -9,6 +9,7 @@ export interface CommonWidgetInputProps { options: Omit, "defaultValue" | "type">; initialOptions: Record; itemId?: string; + boardId?: string; } type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx index 3b5b636aa..532fd04f8 100644 --- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -17,7 +17,13 @@ export interface DynamicSelectOption { label: string; } -export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => { +export const WidgetDynamicSelectInput = ({ + property, + kind, + options, + itemId, + boardId, +}: CommonWidgetInputProps<"dynamicSelect">) => { const t = useI18n(); const tWidget = useWidgetInputTranslation(kind, property); const form = useFormContext(); @@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg debouncedSearch, form.values.integrationIds, form.values.options, + itemId, + boardId, ); const currentOption = inputProps.value as DynamicSelectOption | null; const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void; diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 90a1c998c..781a09930 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -3,13 +3,14 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; import type { ComponentProps } from "react"; import dynamic from "next/dynamic"; -import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core"; +import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core"; import { Quill } from "react-quill-new"; import type { DeltaStatic } from "react-quill-new"; import type ReactQuillComponent from "react-quill-new"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; +import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useTimeAgo } from "@homarr/common"; import type { AnchorNotePermission } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; @@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => { return JSON.stringify(emptyDelta()); }; +export const storedContentToPlainText = (content?: string | null): string => { + return parseStoredContent(content) + .ops.map(({ insert }) => (typeof insert === "string" ? insert : "")) + .join("") + .trim(); +}; + const canEditPermission = (permission: AnchorNotePermission) => { return permission === "owner" || permission === "editor"; }; @@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => { return (data as { code?: unknown }).code === "FORBIDDEN"; }; -export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) { +export default function AnchorNoteWidget({ + options, + integrationIds, + width, + height, + displayMode, + widgetStateRef, +}: WidgetComponentProps<"anchorNote">) { const t = useScopedI18n("widget.anchorNote"); const noteId = options.noteId.trim(); if (!noteId) { @@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const integrationId = integrationIds[0]!; - return ; + return ( + + ); +} + +interface AnchorNotePersistedState { + integrationId: string; + noteId: string; + isEditing: boolean; + draftTitle: string; + draftContent: string; + saveError: string | null; } interface AnchorNoteWidgetContentProps { options: WidgetComponentProps<"anchorNote">["options"]; integrationId: string; noteId: string; + width: number; + height: number; + isAdvanced: boolean; + widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"]; } -const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => { +const AnchorNoteWidgetContent = ({ + options, + integrationId, + noteId, + width, + height, + isAdvanced, + widgetStateRef, +}: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); + const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined; + const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId; const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, }); const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); - const [isEditing, setIsEditing] = useState(false); - const [draftTitle, setDraftTitle] = useState(note?.title ?? ""); - const [draftContent, setDraftContent] = useState(note?.content ?? ""); - const [saveError, setSaveError] = useState(null); + const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false); + const [draftTitle, setDraftTitle] = useState( + canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""), + ); + const [draftContent, setDraftContent] = useState( + canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""), + ); + const [saveError, setSaveError] = useState( + canHydratePersistedState ? (persistedState?.saveError ?? null) : null, + ); + + useEffect(() => { + if (!widgetStateRef) return; + widgetStateRef.current = { + ...widgetStateRef.current, + anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError }, + }; + }, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]); useEffect(() => { if (isEditing || !note) return; @@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW }); }, [isEditing, note]); - const canEdit = canEditPermission(note?.permission ?? "viewer"); + const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId); + const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess; const isViewer = !note || note.permission === "viewer"; const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]); const updatedAtRelative = useTimeAgo(updatedAt, 30000); @@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]); const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]); + const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]); const handleEditorChange = useCallback( (_html, _delta, source, editor) => { if (!isEditing || source !== "user") return; @@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW if (!note) return ; return ( - + {isEditing ? ( @@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW {t("readOnlyViewer")} )} + {!isEditing && isAdvanced && ( + + + {t(`permission.${note.permission}`)} + + {note.isPinned && {t("status.pinned")}} + {note.isArchived && ( + + {t("status.archived")} + + )} + + {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })} + + + )} {saveError && ( {saveError} @@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW )} -
- -
+ {isEditing || isAdvanced ? ( +
+ +
+ ) : ( + + {plainText || t("emptyContent")} + + )}
); }; diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index 896570b65..4eac1a441 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -5,7 +5,9 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, + supportsAdvancedFocus: true, queryKey: [["widget", "anchorNotes"]], + maxIntegrations: 1, supportedIntegrations: ["anchor"], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 56e079a8c..aa3dad356 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -19,7 +19,7 @@ import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; import { PingIndicator } from "./ping/ping-indicator"; -export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { +export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) { const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); @@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget if (!app) return ; const isTiny = height < 100 || width < 100; + const isAdvanced = displayMode === "advanced"; const isColumnLayout = options.layout.startsWith("column"); return ( @@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined} > - {options.showTitle && ( + {(options.showTitle || isAdvanced) && ( )} - {options.descriptionDisplayMode === "normal" && ( + {(options.descriptionDisplayMode === "normal" || isAdvanced) && ( {app.description?.split("\n").map((line, index) => ( @@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget ))} )} + {isAdvanced && app.href && ( + + {getDisplayHost(app.href)} + + )} @@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren { + try { + return new URL(href).host; + } catch { + return href; + } +}; diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 5ac03eadc..50b213257 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -1,16 +1,20 @@ "use client"; -import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { formatByteRate } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; export default function ArchiveTeamWarriorWidget({ integrationIds, options, + width, + height, + displayMode, }: WidgetComponentProps<"archiveTeamWarrior">) { const integrationId = integrationIds[0]; @@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({ return null; } - return ; + return ( + + ); } const ArchiveTeamWarriorWidgetContent = ({ integrationId, options, + width, + height, + isAdvanced, }: { integrationId: string; options: WidgetComponentProps<"archiveTeamWarrior">["options"]; + width: number; + height: number; + isAdvanced: boolean; }) => { + const t = useScopedI18n("widget.archiveTeamWarrior"); const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); const status = data.status; - const projectName = status.project?.title ?? status.selectedProject ?? "No project selected"; + const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected"); + const statusKey = getStatusKey(status.status); return ( - + @@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({ - - {status.status} + + {t(`status.${statusKey}`)} - {options.showBroadcastMessage && status.broadcastMessage && ( + {options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && ( {status.broadcastMessage} @@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({ )} - - - - - + + + + + {status.bandwidth && ( - - + + )} + {isAdvanced && ( + <> + + + {t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })} + + + {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })} + + + + + {status.items.map((item) => ( + + + + + {item.name} + + + {item.project ?? projectName} + + + + {t(`status.${item.status}`)} + + + + ))} + + + + )} ); }; @@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) => ); const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0)); + +type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown"; + +const getStatusKey = (status?: string | null): WarriorStatusKey => { + switch (status?.trim().toLowerCase()) { + case "running": + return "running"; + case "done": + case "completed": + return "completed"; + case "failed": + return "failed"; + case "canceled": + case "cancelled": + return "canceled"; + case "stopped": + return "stopped"; + case "idle": + return "idle"; + default: + return "unknown"; + } +}; + +const getStatusColor = (status: WarriorStatusKey): string => { + if (status === "running") return "green"; + if (status === "failed") return "red"; + if (status === "canceled" || status === "stopped") return "yellow"; + return "gray"; +}; diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 5e0998ec2..e639c7e9f 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -5,6 +5,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, + queryKey: [["widget", "archiveTeamWarrior"]], + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 9e410f527..d31aca8fe 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -1,17 +1,91 @@ "use client"; +import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; + import { clientApi } from "@homarr/api/client"; +import { formatBytes, formatDuration } from "@homarr/common"; +import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { AudioStatsContent } from "./audio-stats-content"; -export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { +export default function AudioStatsWidget({ + integrationIds, + options, + width, + height, + displayMode = "compact", +}: WidgetComponentProps<"audioStats">) { const { data: response } = clientApi.widget.audioStats.getStats.useQuery({ integrationId: integrationIds[0] ?? "", }); + const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( + { integrationIds, showOnlyPlaying: false }, + { enabled: displayMode === "advanced" && response?.kind === "navidrome" }, + ); if (!response) return ; - return ; + const summary = ( + + ); + if (displayMode === "compact") return summary; + + const sessions = streamResults.flatMap((result) => + result.sessions.map((session) => ({ integrationId: result.integrationId, session })), + ); + const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; + return ( + +
{summary}
+ + {response.kind === "navidrome" ? ( + = 760 ? 2 : 1} spacing="xs"> + {sessions.map(({ integrationId, session }) => { + const currentlyPlaying = session.currentlyPlaying; + if (!currentlyPlaying) return null; + return ( + + + + {currentlyPlaying.name} + + + {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username} + + + {session.sessionName} + + ); + })} + + ) : ( + = 760 ? 2 : 1} spacing="xs"> + {(audiobookStats?.libraries ?? []).map((library) => ( + + + + {library.name} + + + {library.totalItems} + + + + {library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "} + {library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)} + + + ))} + + )} + +
+ ); } diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index ffcd2dd93..c5783fb89 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, + queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]], supportedIntegrations: ["navidrome", "audiobookshelf"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 094c32ee1..5e4d32ffd 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -36,7 +36,13 @@ const iconSizeByWidth = [ { minWidth: 0, size: 16 }, ] as const; -export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) { +export default function BazarrWidget({ + integrationIds, + options, + width, + height, + displayMode, +}: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, @@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC } as const; const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const gridCols = getGridCols(width); - const iconSize = getIconSize(width); + const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced"); + const iconSize = getIconSize(Math.min(width, height)); if (visibleStatKeys.length === 0) { return ( @@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC ); } -function getGridCols(width: number): number { - const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); - return match?.cols ?? 1; +export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number { + if (itemCount <= 1) return 1; + if (isAdvanced && width >= 640) return Math.min(itemCount, 4); + + const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; + const rowsAtWidth = Math.ceil(itemCount / widthMatch); + const rowHeight = height / rowsAtWidth; + return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2)); } -function getIconSize(width: number): number { +export function getIconSize(width: number): number { const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); return match?.size ?? 16; } diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index f2e79f570..7ac60e0de 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -5,8 +5,10 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { icon: IconBadgeCc, + queryKey: [["widget", "bazarr"]], supportedIntegrations: ["bazarr"], integrationsRequired: true, + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showMissingEpisodes: factory.switch({ diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts new file mode 100644 index 000000000..2401ce4e6 --- /dev/null +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; + +import { getGridCols, getIconSize } from "./component"; + +describe("Bazarr responsive layout", () => { + test("uses two columns for a short widget so all stats remain visible", () => { + expect(getGridCols(220, 100, 4)).toBe(2); + }); + + test("uses four columns in a wide advanced surface", () => { + expect(getGridCols(800, 400, 4, true)).toBe(4); + }); + + test("sizes icons from the constrained dimension", () => { + expect(getIconSize(180)).toBe(16); + expect(getIconSize(340)).toBe(22); + }); +}); diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 3f934423d..f81835739 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, + height, + displayMode = "compact", }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( @@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); + const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260; + const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360); if (alertsError) throw alertsError; @@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
)} - {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && } - {okAlerts.length > 0 && ( + {showOkAlerts && okAlerts.length > 0 && ( @@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({ )} - {options.showHistory && history.length > 0 && ( + {showHistory && history.length > 0 && ( <> diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index dd2f56c54..8359b2353 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -330,7 +330,9 @@ const metricRenderers = [ ] as const; const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { - const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); + const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options)); + const visibleMetrics = enabledMetrics.slice(0, maxMetrics); + const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; return ( {visibleMetrics.map((m) => m.render(system, t, size))} + {hiddenMetricCount > 0 && ( + + +{hiddenMetricCount} + + )} ); }; @@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({ isEditMode, width, height, + displayMode = "compact", }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); @@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({ ); } - const cols = getColCount(width, height, filteredSystems.length); + const advanced = displayMode === "advanced"; + const cols = advanced + ? Math.min(filteredSystems.length, width >= 900 ? 2 : 1) + : getColCount(width, height, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT; - const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight; + const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT; + const scrollEnabled = rawCellHeight < minimumCellHeight; + const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size); return ( @@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({ style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, - gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`, + gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`, gap: size.gap, overflow: scrollEnabled ? "auto" : "hidden", }} diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 91dcd7292..1b964c409 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core"; +import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; +import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; export default function BeszelSystemStatsWidget({ options, integrationIds, isEditMode, width, + height, + displayMode = "compact", boardId, itemId, setOptions, @@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({ error: systemsError, } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systems = useMemo( - () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))), - [systemsResult], - ); - const selectedSystem = options.systemId || systems[0]?.value || ""; - const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label; - const systemExists = systems.some((system) => system.value === selectedSystem); + const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); + const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId); + const selectedValue = selectedSystem?.value ?? ""; + const selectedLabel = selectedSystem?.label; + const systemExists = selectedSystem !== undefined; const handleSelectSystem = useCallback( (value: string) => { @@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({ [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], ); + const handleTimePeriod = useCallback( + (value: string) => { + setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } }); + if (hasChangeAccess && boardId && itemId) { + saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } }); + } + }, + [boardId, hasChangeAccess, itemId, saveItemOptions, setOptions], + ); + if (systemsError) throw systemsError; if (systemsPending) return ( @@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({