From d421046d1550afd449f31d19d7f135939c85bbc7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 1 Aug 2026 21:14:33 +0200 Subject: [PATCH 01/75] feat: modernize dashboard widgets --- PRODUCT.md | 36 ++ .../advanced/keyboard-shortcuts/index.mdx | 17 +- .../after-the-installation.mdx | 49 +- apps/docs/docs/management/boards/index.mdx | 111 +++-- apps/docs/docs/widgets/anchor-note/index.mdx | 36 +- apps/docs/docs/widgets/app/index.mdx | 29 +- .../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 | 40 +- .../docs/docs/widgets/beszel-alerts/index.mdx | 40 +- .../docs/widgets/beszel-system-grid/index.mdx | 40 +- .../widgets/beszel-system-stats/index.mdx | 4 + .../widgets/beszel-system-table/index.mdx | 37 +- apps/docs/docs/widgets/bookmarks/index.mdx | 29 +- apps/docs/docs/widgets/calendar/index.mdx | 101 ++-- apps/docs/docs/widgets/clock/index.mdx | 34 +- apps/docs/docs/widgets/coolify/index.mdx | 43 +- apps/docs/docs/widgets/custom-api/index.mdx | 18 +- .../docs/widgets/dns-hole-controls/index.mdx | 67 +-- .../docs/widgets/dns-hole-summary/index.mdx | 65 +-- .../docs/widgets/docker-containers/index.mdx | 43 +- apps/docs/docs/widgets/downloads/index.mdx | 99 ++-- apps/docs/docs/widgets/firewall/index.mdx | 36 +- .../docs/widgets/health-monitoring/index.mdx | 4 + apps/docs/docs/widgets/iframe/index.mdx | 35 +- .../widgets/immich-album-carousel/index.mdx | 39 +- .../widgets/immich-server-stats/index.mdx | 40 +- .../docs/widgets/indexer-manager/index.mdx | 43 +- .../docs/docs/widgets/media-missing/index.mdx | 54 +- .../docs/widgets/media-releases/index.mdx | 53 +- .../docs/widgets/media-request-list/index.mdx | 4 + .../widgets/media-request-stats/index.mdx | 49 +- apps/docs/docs/widgets/media-server/index.mdx | 72 +-- .../docs/widgets/media-transcoding/index.mdx | 51 +- .../widgets/minecraft-server-status/index.mdx | 37 +- .../network-controller-status/index.mdx | 45 +- .../network-controller-summary/index.mdx | 42 +- apps/docs/docs/widgets/notebook/index.mdx | 4 + .../docs/docs/widgets/notifications/index.mdx | 43 +- .../docs/docs/widgets/paperless-ngx/index.mdx | 37 +- apps/docs/docs/widgets/patchmon/index.mdx | 63 +-- apps/docs/docs/widgets/releases/index.mdx | 4 + apps/docs/docs/widgets/rss-feed/index.mdx | 27 +- .../widgets/smart-home-entity-state/index.mdx | 54 +- .../smart-home-execute-automation/index.mdx | 52 +- .../docs/widgets/speedtest-tracker/index.mdx | 38 +- apps/docs/docs/widgets/stock-price/index.mdx | 34 +- apps/docs/docs/widgets/system-disks/index.mdx | 4 + apps/docs/docs/widgets/system-disks/index.ts | 6 +- .../docs/widgets/system-resources/index.mdx | 4 + apps/docs/docs/widgets/timetable/index.mdx | 16 +- apps/docs/docs/widgets/tracearr/index.mdx | 51 +- apps/docs/docs/widgets/traefik/index.mdx | 4 + apps/docs/docs/widgets/umami/index.mdx | 47 +- apps/docs/docs/widgets/ups/index.mdx | 52 +- apps/docs/docs/widgets/uptime-kuma/index.mdx | 37 +- apps/docs/docs/widgets/video/index.mdx | 22 +- apps/docs/docs/widgets/video/index.tsx | 2 +- apps/docs/docs/widgets/vpn/index.mdx | 35 +- apps/docs/docs/widgets/weather/index.mdx | 27 +- .../src/components/widgets/interactions.tsx | 47 ++ .../app/[locale]/_client-providers/trpc.tsx | 8 +- .../app/[locale]/boards/(content)/_client.tsx | 27 +- .../boards/(content)/_header-actions.tsx | 5 +- .../custom-widgets/_custom-widget-preview.tsx | 4 +- .../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 + .../board/items/actions/create-item.ts | 46 +- .../board/items/actions/empty-position.ts | 57 ++- .../items/actions/test/create-item.spec.ts | 60 ++- .../items/actions/test/empty-position.spec.ts | 33 +- .../components/board/items/item-actions.tsx | 8 +- .../components/board/items/item-content.tsx | 183 ++++++- .../board/items/item-select-modal.module.css | 25 + .../board/items/item-select-modal.tsx | 95 +++- .../board/items/widget-context-menu.tsx | 169 +++++-- .../items/widget-hover-overlay.module.css | 6 +- .../board-empty-space-context-menu.tsx | 177 +++++++ .../board/sections/gridstack/gridstack.tsx | 9 +- .../board/sections/gridstack/use-gridstack.ts | 2 + .../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/patchmon.spec.ts | 4 +- .../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 | 9 +- 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/patchmon/patchmon-types.ts | 10 +- .../src/plex/test/plex-integration.spec.ts | 2 +- .../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 | 167 ++++++- 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 +- packages/widgets/src/app/index.ts | 1 + .../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 +- packages/widgets/src/beszel-alerts/index.ts | 1 + .../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 | 103 +++- packages/widgets/src/bookmarks/component.tsx | 96 +++- packages/widgets/src/bookmarks/index.tsx | 1 + 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 | 27 +- packages/widgets/src/clock/index.ts | 1 + 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 | 171 ++++++- packages/widgets/src/custom-api/index.ts | 18 +- packages/widgets/src/definition.spec.ts | 19 + packages/widgets/src/definition.ts | 12 + .../src/dns-hole/controls/component.tsx | 82 +++- .../src/dns-hole/summary/component.tsx | 74 ++- packages/widgets/src/docker/component.tsx | 53 +- packages/widgets/src/docker/layout.spec.ts | 37 ++ packages/widgets/src/docker/layout.ts | 19 + packages/widgets/src/downloads/component.tsx | 185 ++++--- .../widgets/src/downloads/helpers.spec.ts | 28 ++ packages/widgets/src/downloads/helpers.ts | 27 + packages/widgets/src/downloads/index.ts | 1 + .../src/filter-storage-volumes.spec.ts | 6 +- .../widgets/src/filter-storage-volumes.ts | 6 +- .../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 | 2 + .../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 | 144 +++++- packages/widgets/src/media-missing/index.ts | 1 + .../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 +- .../widgets/src/media-requests/list/index.ts | 1 + .../src/media-requests/stats/component.tsx | 102 ++-- .../widgets/src/media-requests/stats/index.ts | 1 + .../widgets/src/media-server/component.tsx | 224 +++++---- 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 | 1 + 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 | 50 +- .../src/patchmon/os-distribution-section.tsx | 26 +- packages/widgets/src/patchmon/stat-colors.ts | 6 +- .../src/patchmon/test/stat-colors.spec.ts | 15 +- .../test/threshold-validation.spec.ts | 40 +- 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 | 2 + .../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 + 286 files changed, 9664 insertions(+), 3282 deletions(-) create mode 100644 PRODUCT.md 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/items/item-select-modal.module.css 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 new file mode 100644 index 000000000..5bf336fa7 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + +## Register + +product + +## Users + +Homarr is for experienced self-hosters, homelab operators, and administrators who run several services and want one dashboard for monitoring and operating them. They understand technical data, value density, and spend time tuning dashboards to match their own workflows. + +## Product Purpose + +Homarr brings status, data, and safe day-to-day controls from self-hosted services into one customizable dashboard. Success means users can inspect problems, act on services, and reshape their workspace without repeatedly leaving the board. + +## Brand Personality + +Capable, precise, configurable. Homarr should feel calm under load, direct about system state, and rewarding to experts who refine details. + +## Anti-references + +- Consumer dashboards that hide useful detail behind simplified scores. +- Decorative glass, oversized metric cards, and motion without state meaning. +- Rigid layouts that force one workflow or require leaving the dashboard for routine actions. +- Clever custom controls where familiar platform or Mantine interactions already work. + +## Design Principles + +- Preserve expert depth while prioritizing the most urgent information at small sizes. +- Make routine inspection, filtering, configuration, and safe actions available from the board. +- Reveal dense workspaces progressively through a consistent advanced focus mode. +- Treat personalization as a core workflow, not a settings afterthought. +- Show loading, stale, partial, empty, and failed states truthfully without discarding valid data. + +## Accessibility & Inclusion + +Target WCAG AA. Every pointer interaction needs a keyboard and coarse-pointer path. Focus must be visible and restored after overlays close. Motion must respect reduced-motion preferences, and status must never rely on color alone. diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 8dfe0f285..5d7f01cde 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,16 +6,23 @@ 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` while hovering a widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a focused widget | Opens and pins its advanced view | +| `Esc` | Closes an open advanced view or board menu | +| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. +Advanced view and precise placement are available outside edit mode. Precise placement still requires permission to +change the board and the right-click interaction preference to be enabled. + ::: :::note diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 7ac904bf4..f8fe31ed2 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed). We enforce strict password requirements when you enter your password to protect your instance if you accidentally expose it to the internet. -{"Screenshot +{"Screenshot If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. This group will be used to synchronize users from your provider to Homarr. @@ -116,22 +112,14 @@ The onboarding can no longer be started again. From here you can click on one of the provided links to continue your journey. We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). -{"Screenshot +{"Screenshot ### Importing a ZIP from version before 1.0.0 This is recommended when you used Homarr before and want to re-import your data. It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). -Export from old Homarr +Export from old Homarr **How to export and import data** @@ -197,38 +185,29 @@ Here you have the following options in the header: When entering the edit mode, you'll get some additional buttons to work with: -{"Screenshot +{"Screenshot - **Add item**: Click this button to add new elements to your page. -- **Exit edit mode**: Click this btuton to save your changes and exit edit mode. +- **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 for a temporary advanced view; use its pin control to keep it open. You can also +focus the widget and press `Shift` + `Enter` to open it pinned. If you can change the board, right-click empty grid +space to add an item at that position. +On a touch device, touch and hold empty grid space for the same precise-placement menu. To continue, click the option "New Item": -{"Screenshot +{"Screenshot Next, you can choose what item you'd like to add. We will discuss the other options in the following sections - for now choose "App" to continue. -{"Screenshot +{"Screenshot As soon as you chose the app, the modal will close and you'll get the following item on your board: -Screenshot of the No app element on the board +Screenshot of the No app element on the board This is expected, because you haven't told Homarr what app it should show in this item. To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..75c8c6e6f 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +9,11 @@ tags: - Layout --- -import iconColorImage from './img/icon-color.png' -import itemRadiusImage from './img/item-radius.png' -import layoutLargeImage from './img/layout-large.png' -import layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.png' +import iconColorImage from "./img/icon-color.png"; +import itemRadiusImage from "./img/item-radius.png"; +import layoutLargeImage from "./img/layout-large.png"; +import layoutMediumImage from "./img/layout-medium.png"; +import layoutSmallImage from "./img/layout-small.png"; Each dashboard is called a "board". You can create as many boards as you want. @@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count + Homarr has a complex drag and drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. This can be really useful if you want to have public boards in your company or for family and fiends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -51,7 +56,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) ### General + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +66,7 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title + This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -67,10 +75,12 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL + This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL + This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. @@ -80,9 +90,10 @@ In this section you can configure the different responsive layouts. Depending on ![](./img/layout-settings.png) -On each element the position and size of items can be changed seperately. +On each element the position and size of items can be changed separately. When adding a new layout the position and sizes will automatically be generated depending on the column count. -When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position. +New widgets use their default size and the first available position on each layout. Adding from an empty grid cell anchors +the widget near that cell on the active layout; Homarr still chooses safe free positions for the other responsive layouts. #### Name @@ -94,51 +105,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row #### Breakpoint -Minimum width of the screen where the layout will be used. +Minimum width of the screen where the layout will be used. Once the minmum of the next layout is reached the layout will change. #### Preview - - - + + + ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +172,29 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status + 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 for a temporary advanced view, then use its pin control to keep it open. Focus + the widget and press `Shift` + `Enter` to open it pinned, or use the maximize control on touch devices. +- 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. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -|----------------------|------------|-----------|----------|--------------------------| +| -------------------- | ---------- | --------- | -------- | ------------------------ | | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -173,18 +213,23 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone + #### Rename + Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility + By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete + This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards + The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: @@ -196,7 +241,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -206,47 +251,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +304,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/docs/docs/widgets/anchor-note/index.mdx b/apps/docs/docs/widgets/anchor-note/index.mdx index 453a5b51c..bfc520c6a 100644 --- a/apps/docs/docs/widgets/anchor-note/index.mdx +++ b/apps/docs/docs/widgets/anchor-note/index.mdx @@ -1,35 +1,37 @@ --- -title: 'Anchor Note' -description: 'Display and edit a selected note from Anchor' +title: "Anchor Note" +description: "Display and edit a selected note from Anchor" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { anchorNoteWidget } from '.'; -import { anchorIntegration } from '@site/docs/integrations/anchor'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { anchorNoteWidget } from "."; +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. ### Screenshots -import anchorNoteWidgetScreenshot from './img/widget.png'; +import anchorNoteWidgetScreenshot from "./img/widget.png"; anchor-note-widget ### Supported Integrations - + ### Interactions @@ -37,6 +39,8 @@ import anchorNoteWidgetScreenshot from './img/widget.png'; - 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 ec62d862e..4fdc3ff5f 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -1,37 +1,38 @@ --- -title: 'App' -description: 'Embeds an app into the board.' +title: "App" +description: "Embeds an app into the board." hide_title: true --- -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 { appWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { appWidget } from "."; - + The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. It also supports a status check to indicate whether Homarr can reach the application or website. ### Screenshots -import githubApp from './img/github.png'; -import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; -import piHoleApp from './img/pi-hole-ping-icon.png'; +import githubApp from "./img/github.png"; +import jellyseerrApp from "./img/jellyseerr-description-tooltip.png"; +import piHoleApp from "./img/pi-hole-ping-icon.png"; -
+
app github with-status-check app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip
+ + ### Adding the widget ### Configuration + 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 e262cfa75..5cd0c1532 100644 --- a/apps/docs/docs/widgets/bazarr/index.mdx +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -1,26 +1,25 @@ --- -title: 'Bazarr' -description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.' +title: "Bazarr" +description: "Displays missing subtitle counts and health indicators from your Bazarr instance." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { bazarrWidget } from '.'; -import { bazarrIntegration } from '@site/docs/integrations/bazarr'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { bazarrWidget } from "."; +import { bazarrIntegration } from "@site/docs/integrations/bazarr"; + + This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings. ### Screenshots -import widgetOverview from './img/widget-overview.png'; +import widgetOverview from "./img/widget-overview.png";
bazarr-widget-overview @@ -28,14 +27,21 @@ import widgetOverview from './img/widget-overview.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/beszel-alerts/index.mdx b/apps/docs/docs/widgets/beszel-alerts/index.mdx index 3a4899d17..d1abe2d2e 100644 --- a/apps/docs/docs/widgets/beszel-alerts/index.mdx +++ b/apps/docs/docs/widgets/beszel-alerts/index.mdx @@ -1,22 +1,21 @@ --- -title: 'Beszel Alerts' -description: 'View Beszel alert configurations and history with triggered/ok status indicators.' +title: "Beszel Alerts" +description: "View Beszel alert configurations and history with triggered/ok status indicators." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelAlertsWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; -import beszelAlerts from './img/beszel-alerts.png'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelAlertsWidget } from "."; +import { beszelIntegration } from "@site/docs/integrations/beszel"; - +import beszelAlerts from "./img/beszel-alerts.png"; + + This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions. @@ -26,14 +25,21 @@ This widget displays your Beszel alert configurations separated by status (trigg ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx index e3b17623b..fd4232524 100644 --- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx @@ -1,22 +1,21 @@ --- -title: 'Beszel Systems (Grid)' -description: 'Card grid view of all Beszel-monitored systems with real-time metrics.' +title: "Beszel Systems (Grid)" +description: "Card grid view of all Beszel-monitored systems with real-time metrics." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelSystemGridWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; -import beszelSystemGrid from './img/beszel-system-grid.png'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelSystemGridWidget } from "."; +import { beszelIntegration } from "@site/docs/integrations/beszel"; - +import beszelSystemGrid from "./img/beszel-system-grid.png"; + + This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount. @@ -28,14 +27,21 @@ Select a system card to open the complete statistics view, including system, fil ### Supported Integrations - + + + ### Adding the widget ### Configuration + 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 df68fcc1b..781a6abad 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -1,21 +1,19 @@ --- -title: 'Beszel Systems (Table)' -description: 'Table view of all Beszel-monitored systems with sortable columns and status indicators.' +title: "Beszel Systems (Table)" +description: "Table view of all Beszel-monitored systems with sortable columns and status indicators." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelSystemTableWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelSystemTableWidget } from "."; +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, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. @@ -23,14 +21,21 @@ Select a system row to open the complete statistics view, including system, file ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index c3104ad5b..5ef772034 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -1,29 +1,27 @@ --- -title: 'Bookmarks' -description: 'Displays multiple app links' +title: "Bookmarks" +description: "Displays multiple app links" hide_title: true --- -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 { bookmarksWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { 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. ### 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 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";
@@ -33,12 +31,15 @@ import vericalListSimple from './img/vertical-list-simple.png'; bookmarks grid with-title bookmarks vertical-list all-elements +
+ ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index 5db4e9b30..3e1e3f180 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -1,65 +1,73 @@ --- -title: 'Calendar' -description: 'Display events from your integrations in a calendar view within a certain relative time period' +title: "Calendar" +description: "Display events from your integrations in a calendar view within a certain relative time period" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { calendarWidget } from '.'; -import { lidarrIntegration } from '@site/docs/integrations/lidarr'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; -import { radarrIntegration } from '@site/docs/integrations/radarr'; -import { readarrIntegration } from '@site/docs/integrations/readarr'; -import { sonarrIntegration } from '@site/docs/integrations/sonarr'; -import { icalIntegration } from '@site/docs/integrations/ical'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { calendarWidget } from "."; +import { lidarrIntegration } from "@site/docs/integrations/lidarr"; +import { nextcloudIntegration } from "@site/docs/integrations/nextcloud"; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; +import { radarrIntegration } from "@site/docs/integrations/radarr"; +import { readarrIntegration } from "@site/docs/integrations/readarr"; +import { sonarrIntegration } from "@site/docs/integrations/sonarr"; +import { icalIntegration } from "@site/docs/integrations/ical"; - - + The calendar widget shows a month view of events from different integrations. It allows you to view calendar events or upcoming releases from your media managers. ### Screenshots -import popover from './img/popover.png'; -import withIntegrations from './img/with-integrations.png'; -import withoutIntegrations from './img/without-integrations.png'; +import popover from "./img/popover.png"; +import withIntegrations from "./img/with-integrations.png"; +import withoutIntegrations from "./img/without-integrations.png"; -
- calendar with-integrations big-size -
- calendar popover-details the-mandalorian - calendar without-integrations small-size -
+
+ calendar with-integrations big-size +
+ calendar popover-details the-mandalorian + calendar without-integrations small-size +
### Supported Integrations - + ### Interactions @@ -67,9 +75,12 @@ import withoutIntegrations from './img/without-integrations.png'; - Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away). - Click the date again or click outside to close the pinned popover. + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index efdab31d4..157e79d0b 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -1,38 +1,38 @@ --- -title: 'Date and time' -description: 'Displays the current date and time.' +title: "Date and time" +description: "Displays the current date and time." hide_title: true --- -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 { clockWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { clockWidget } from "."; - + The clock widget displays the current date and time in a customizable format. ### Screenshots -import localClock from './img/local.png'; -import londonClock from './img/london-with-seconds.png'; -import tokyoClock from './img/tokyo-12-hour.png'; +import localClock from "./img/local.png"; +import londonClock from "./img/london-with-seconds.png"; +import tokyoClock from "./img/tokyo-12-hour.png"; -
- clock format-24-hours timezone-local - clock format-24-hours with-seconds timezone-london - clock format-12-hours timezone-tokyo +
+ clock format-24-hours timezone-local + clock format-24-hours with-seconds timezone-london + clock format-12-hours timezone-tokyo
+ ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index 23eb6ba03..ff26c0a8d 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -1,28 +1,33 @@ --- -title: 'Coolify' -description: 'Overview of your Coolify instance with servers, applications, and services.' +title: "Coolify" +description: "Overview of your Coolify instance with servers, applications, and services." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { coolifyWidget } from '.'; -import { coolifyIntegration } from '@site/docs/integrations/coolify'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { coolifyWidget } from "."; +import { coolifyIntegration } from "@site/docs/integrations/coolify"; + + The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. ### Supported Integrations - + + + ### Adding the widget @@ -34,11 +39,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can ### Sections -| Section | Description | -|---|---| -| **Servers** | Lists Coolify servers, reachability status, and application/service counts per server. | +| Section | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Servers** | Lists Coolify servers, reachability status, and application/service counts per server. | | **Applications** | Lists applications with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | -| **Services** | Lists services with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | +| **Services** | Lists services with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | ### Notes diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 78d0bcbba..94eb62116 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -1,18 +1,16 @@ --- -title: 'Custom API' -description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.' +title: "Custom API" +description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { customApiWidget } from '.'; +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 "."; - + This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types. @@ -20,6 +18,8 @@ Custom API widgets are created and configured through the admin management page. See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index cea517545..e6fd43e15 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -1,57 +1,64 @@ --- -title: 'DNS Hole Controls' +title: "DNS Hole Controls" description: "Control PiHole or AdGuard from your dashboard." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dnsHoleControlsWidget } from '.'; -import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; -import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; -import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dnsHoleControlsWidget } from "."; +import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home"; +import { piHoleIntegration } from "@site/docs/integrations/pi-hole"; +import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns"; + - - -This widget allows you to enable or disable your DNS Hole. +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. Advanced view exposes every selected source, while compact layouts reduce controls according to both widget width and height. ### Screenshots -import bigSize from './img/big.png'; -import smallSize from './img/small.png'; - +import bigSize from "./img/big.png"; +import smallSize from "./img/small.png"; dns-hole-controls big-size with-toggle-all-buttons dns-hole-controls small-size no-toggle-all-buttons ### Supported Integrations - + ### 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 ### Configuration + diff --git a/apps/docs/docs/widgets/dns-hole-summary/index.mdx b/apps/docs/docs/widgets/dns-hole-summary/index.mdx index 1dcb123da..f4d7ca418 100644 --- a/apps/docs/docs/widgets/dns-hole-summary/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-summary/index.mdx @@ -1,38 +1,36 @@ --- -title: 'DNS Hole Summary' +title: "DNS Hole Summary" description: "Displays the summary of your DNS Hole." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dnsHoleSummaryWidget } from '.'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dnsHoleSummaryWidget } from "."; + - - -This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more. +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. Advanced view keeps the aggregate metrics visible and adds a named status chip for each source. ### Screenshots -import gridLayout from './img/grid.png'; -import verticalLayout from './img/vertical.png'; -import horizontalLayout from './img/horizontal.png'; -import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; -import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; -import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; +import gridLayout from "./img/grid.png"; +import verticalLayout from "./img/vertical.png"; +import horizontalLayout from "./img/horizontal.png"; +import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home"; +import { piHoleIntegration } from "@site/docs/integrations/pi-hole"; +import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns"; -
+
grid-layout pi-hole-colors -
+
vertical-layout no-colors horizontal-layout pi-hole-colors
@@ -40,17 +38,26 @@ import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index fe509f86a..bbd2e104f 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -1,24 +1,22 @@ --- -title: 'Docker stats' -description: 'Stats of your containers' +title: "Docker stats" +description: "Stats of your containers" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dockerContainersWidget } from '.'; -import { dockerIntegration } from '@site/docs/integrations/docker'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dockerContainersWidget } from "."; +import { dockerIntegration } from "@site/docs/integrations/docker"; - - + This widget displays the resource usage of your Docker containers and allows you to manage them. @@ -28,24 +26,31 @@ This widget can only be used with administrator privileges. ### Screenshots -import dockerTable from './img/table.png'; +import dockerTable from "./img/table.png"; docker-stats table docker-containers ### Supported Integrations - + ### Interactions Each container row has an action menu (accessible via the three-dot icon) with the following options: + - **View logs** - opens a dedicated terminal page streaming the container's logs - **Start** / **Stop** / **Restart** - manage the container's state - **Remove** - remove the container - **Add to Homarr** - create a bookmark app from the container + + ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 000fd066b..1db269895 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -1,43 +1,41 @@ --- -title: 'Download Client' -description: 'Allows you to view and manage your Downloads from both Torrent and Usenet clients.' +title: "Download Client" +description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients." hide_title: true --- -import { aria2Integration } from '@site/docs/integrations/aria2'; -import { delugeIntegration } from '@site/docs/integrations/deluge'; -import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; -import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; -import { sabnzbdIntegration } from '@site/docs/integrations/sabnzbd'; -import { transmissionIntegration } from '@site/docs/integrations/transmission'; -import { slskdIntegration } from '@site/docs/integrations/slskd'; -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 { downloadsWidget } from '.'; +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"; +import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent"; +import { sabnzbdIntegration } from "@site/docs/integrations/sabnzbd"; +import { transmissionIntegration } from "@site/docs/integrations/transmission"; +import { slskdIntegration } from "@site/docs/integrations/slskd"; +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 { downloadsWidget } from "."; - + This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. ### Screenshots -import downloadsCard from './img/screenshot.png'; -import contextMenuImg from './img/context-menu.png'; +import downloadsCard from "./img/screenshot.png"; +import contextMenuImg from "./img/context-menu.png";
- Downloads widget showing torrent and usenet items + Downloads widget showing torrent and usenet items
#### Right-click context menu
- Downloads widget context menu with pause, delete, and copy actions + Downloads widget context menu with pause, delete, and copy actions
### Features @@ -56,28 +54,38 @@ import contextMenuImg from './img/context-menu.png'; ### Supported Integrations - + ### Interactions @@ -89,9 +97,12 @@ 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 ### Configuration + diff --git a/apps/docs/docs/widgets/firewall/index.mdx b/apps/docs/docs/widgets/firewall/index.mdx index 95aaa56d6..aec2d89d9 100644 --- a/apps/docs/docs/widgets/firewall/index.mdx +++ b/apps/docs/docs/widgets/firewall/index.mdx @@ -1,22 +1,20 @@ --- -title: 'Firewall Monitoring' -description: 'Displays a summary of firewalls.' +title: "Firewall Monitoring" +description: "Displays a summary of firewalls." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { firewallWidget } from '.'; -import { opnsenseIntegration } from '../../integrations/opnsense'; +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"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { firewallWidget } from "."; +import { opnsenseIntegration } from "../../integrations/opnsense"; - + ### Screenshots @@ -24,9 +22,15 @@ import { opnsenseIntegration } from '../../integrations/opnsense'; ### Supported Integrations - + + + ### 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 5be26f3b2..7d983342a 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -1,19 +1,17 @@ --- -title: 'iFrame' -description: 'Embed any content from the internet. Some websites may restrict access.' +title: "iFrame" +description: "Embed any content from the internet. Some websites may restrict access." hide_title: true --- -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 { iframeWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { iframeWidget } from "."; - + If your application is not supported natively by Homarr, you can integrate it using the iframe widget. It uses the [iframe element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe), @@ -22,14 +20,16 @@ This means, no traffic is routed through Homarr (and the URL must be reachable f ### Screenshots -import wikipediaHomepage from './img/wikipedia.png'; -import youtubeStream from './img/nasa-iss-stream.png'; +import wikipediaHomepage from "./img/wikipedia.png"; +import youtubeStream from "./img/nasa-iss-stream.png";
iframe wikipedia-homepage iframe youtube-iss-stream
+ + ### Adding the widget @@ -50,12 +50,14 @@ the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) For example, to allow your site to be embedded in iframes from specific domains: **Apache (.htaccess or httpd.conf):** + ```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"; @@ -67,6 +69,7 @@ add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni ### Configuration + ### Transparent iframes @@ -75,15 +78,16 @@ To create a transparent iframe like below you can use the following html code an ```html - + - + ``` ![transparent iframe](./img/transparent-iframe.png) ### 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. @@ -92,11 +96,12 @@ We cannot guarantee the functionality nor security of these tools. ::: ### homarr-iframes from Diogo Valentim + The project [homarr-iframes](https://github.com/diogovalentte/homarr-iframes) has iframes designed to be used with Homarr, these iframes offer a minimalist view of other systems: - [Vikunja](https://github.com/go-vikunja/vikunja): overview of your tasks with links to them. - [Linkwarden](https://github.com/linkwarden/linkwarden): overview of your bookmarks with links to them. -- [Cinemark Brazil](https://www.cinemark.com.br): overview of the movies in Cinemark theaters in your city (*only in Brazil*). +- [Cinemark Brazil](https://www.cinemark.com.br): overview of the movies in Cinemark theaters in your city (_only in Brazil_). ![Image of the Vikunja (left), Linkwarden (middle), and Cinemark Brazil (right) iframes on a Homarr dashboard](./img/homarr-iframes-example.webp "Image of the Vikunja (left), Linkwarden (middle), and Cinemark Brazil (right) iframes on a Homarr dashboard") diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 703bd5a57..2975d53fe 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -1,39 +1,42 @@ --- -title: 'Immich Album Carousel' -description: 'Shows a slideshow of pictures from an Immich album or your full library' +title: "Immich Album Carousel" +description: "Shows a slideshow of pictures from an Immich album or your full library" hide_title: true --- -import { immichIntegration } from '../../integrations/immich';import { - AddingWidget -} from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { immichAlbumCarouselWidget } from '.'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { immichAlbumCarouselWidget } from "."; - + ### Screenshots -import screen from './img/screen.png'; +import screen from "./img/screen.png"; immich album size-big ### Supported Integrations - + + + ### Adding the widget - ### Configuration + diff --git a/apps/docs/docs/widgets/immich-server-stats/index.mdx b/apps/docs/docs/widgets/immich-server-stats/index.mdx index 73d278a91..64f4d8d02 100644 --- a/apps/docs/docs/widgets/immich-server-stats/index.mdx +++ b/apps/docs/docs/widgets/immich-server-stats/index.mdx @@ -1,40 +1,42 @@ --- -title: 'Immich Server Stats' -description: 'Information about your Immich instance' +title: "Immich Server Stats" +description: "Information about your Immich instance" hide_title: true --- -import { immichIntegration } from '../../integrations/immich'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { immichServerStatsWidget } from '.'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { immichServerStatsWidget } from "."; - + ### Screenshots -import screen from './img/screen.png'; - +import screen from "./img/screen.png"; immich server stats size-big ### Supported Integrations - + + + ### Adding the widget - ### Configuration - + diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index fad8c341b..3b81c0023 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -1,47 +1,50 @@ --- -title: 'Indexer manager status' -description: 'View the status of your indexers.' +title: "Indexer manager status" +description: "View the status of your indexers." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { indexerManagerWidget } from '.'; +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"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { indexerManagerWidget } from "."; - + ### Screenshots -import bigItem from './img/big.png'; -import smallItem from './img/small.png'; -import { prowlarrIntegration } from '@site/docs/integrations/prowlarr'; +import bigItem from "./img/big.png"; +import smallItem from "./img/small.png"; +import { prowlarrIntegration } from "@site/docs/integrations/prowlarr"; -
+
indexer-manager size-big indexer-manager size-small
### Supported Integrations - + ### Interactions - Test all indexers by clicking on the test button. + + ### Adding the widget - ### Configuration - + diff --git a/apps/docs/docs/widgets/media-missing/index.mdx b/apps/docs/docs/widgets/media-missing/index.mdx index 648bd4190..643da6a3c 100644 --- a/apps/docs/docs/widgets/media-missing/index.mdx +++ b/apps/docs/docs/widgets/media-missing/index.mdx @@ -1,29 +1,28 @@ --- -title: 'Media Missing & Queue' -description: 'Track missing media and active downloads from Radarr and Sonarr' +title: "Media Missing & Queue" +description: "Track missing media and active downloads from Radarr and Sonarr" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { mediaMissingWidget } from '.'; -import { radarrIntegration } from '@site/docs/integrations/radarr'; -import { sonarrIntegration } from '@site/docs/integrations/sonarr'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { mediaMissingWidget } from "."; +import { radarrIntegration } from "@site/docs/integrations/radarr"; +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. ### Screenshots -import widgetMissing from './img/missing.png'; -import widgetQueued from './img/queued.png'; -import widgetCompact from './img/compact.png'; +import widgetMissing from "./img/missing.png"; +import widgetQueued from "./img/queued.png"; +import widgetCompact from "./img/compact.png";
media-missing widget showing missing movies and episodes in a grid @@ -33,18 +32,23 @@ import widgetCompact from './img/compact.png'; ### Supported Integrations - + ### Tabs -| Tab | Description | -|---|---| +| Tab | Description | +| ----------- | ----------------------------------------------------------------------------------------------------- | | **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. | -| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. | +| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. | ### Sizing @@ -56,6 +60,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 fece199eb..7142ccfc5 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -1,30 +1,28 @@ --- -title: 'Media releases' -description: 'Display newly added medias or upcoming releases from different integrations' +title: "Media releases" +description: "Display newly added medias or upcoming releases from different integrations" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { mediaReleasesWidget } from '.'; -import { embyIntegration } from '@site/docs/integrations/emby'; -import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; -import { plexIntegration } from '@site/docs/integrations/plex'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { mediaReleasesWidget } from "."; +import { embyIntegration } from "@site/docs/integrations/emby"; +import { jellyfinIntegration } from "@site/docs/integrations/jellyfin"; +import { plexIntegration } from "@site/docs/integrations/plex"; - + Provides a list of newly added media or upcoming releases from your media server. ### Screenshots -import backdrop from './img/backdrop.png'; -import poster from './img/poster.png'; +import backdrop from "./img/backdrop.png"; +import poster from "./img/poster.png";
media-releases layout-backdrop @@ -33,17 +31,26 @@ import poster from './img/poster.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + 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 a7b40f624..b381f177b 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -1,39 +1,42 @@ --- -title: 'Media Requests Stats' -description: 'Statistics about your media requests' +title: "Media Requests Stats" +description: "Statistics about your media requests" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaRequestStatsWidget } from '.'; -import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; -import { overseerrIntegration } from '@site/docs/integrations/overseerr'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaRequestStatsWidget } from "."; +import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; +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. ### Supported Integrations - + ### Screenshots -import mediaRequestStats from './img/stats.png'; +import mediaRequestStats from "./img/stats.png"; media-request-stats @@ -41,6 +44,8 @@ import mediaRequestStats from './img/stats.png'; - 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-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index 8fb0c857c..f76ce050b 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -1,56 +1,62 @@ --- -title: 'Media server streams' -description: 'Show the current streams on your media servers' +title: "Media server streams" +description: "Show the current streams on your media servers" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaServerWidget } from '.'; -import { embyIntegration } from '@site/docs/integrations/emby'; -import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; -import { navidromeIntegration } from '@site/docs/integrations/navidrome'; -import { plexIntegration } from '@site/docs/integrations/plex'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaServerWidget } from "."; +import { embyIntegration } from "@site/docs/integrations/emby"; +import { jellyfinIntegration } from "@site/docs/integrations/jellyfin"; +import { navidromeIntegration } from "@site/docs/integrations/navidrome"; +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. +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. - - ### Screenshots -import mediaTable from './img/table.png'; +import mediaTable from "./img/table.png"; media-server-streams ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/media-transcoding/index.mdx b/apps/docs/docs/widgets/media-transcoding/index.mdx index 0fc280021..256b8edd6 100644 --- a/apps/docs/docs/widgets/media-transcoding/index.mdx +++ b/apps/docs/docs/widgets/media-transcoding/index.mdx @@ -1,35 +1,31 @@ --- -title: 'Media transcoding' -description: 'Statistics, current queue and worker status of your media transcoding' +title: "Media transcoding" +description: "Statistics, current queue and worker status of your media transcoding" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaTranscodingWidget } from '.'; -import { tdarrIntegration } from '@site/docs/integrations/tdarr'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaTranscodingWidget } from "."; +import { tdarrIntegration } from "@site/docs/integrations/tdarr"; + - - -The media transcoding widget provides you with an overview of your media transcoding status. +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. - - ### Screenshots -import queueCard from './img/queue.png'; -import workersCard from './img/workers.png'; -import statisticsCard from './img/statistics.png'; +import queueCard from "./img/queue.png"; +import workersCard from "./img/workers.png"; +import statisticsCard from "./img/statistics.png";
media-transcoding workers-tab @@ -39,13 +35,20 @@ import statisticsCard from './img/statistics.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index 51101246b..8a99cf8cf 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -1,44 +1,45 @@ --- -title: 'Minecraft Server Status' -description: 'Displays the status of a Minecraft server.' +title: "Minecraft Server Status" +description: "Displays the status of a Minecraft server." hide_title: true --- -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 { 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 { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { minecraftServerStatusWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { minecraftServerStatusWidget } from "."; - - + Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us). ### Screenshots -import javaItem from './img/java.png'; -import bedrockItem from './img/bedrock.png'; +import javaItem from "./img/java.png"; +import bedrockItem from "./img/bedrock.png"; -
+
minecraft-server-status java-edition minecraft-server-status bedrock-editon
+ + ### Adding the widget ### Configuration + ### 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` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `hhttps://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 a01496234..ad0feaf9d 100644 --- a/apps/docs/docs/widgets/network-controller-status/index.mdx +++ b/apps/docs/docs/widgets/network-controller-status/index.mdx @@ -1,44 +1,49 @@ --- -title: 'Network Status' -description: 'Display connected devices on a network' +title: "Network Status" +description: "Display connected devices on a network" hide_title: true --- -import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; -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 { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; +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 { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { networkControllerStatusWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { networkControllerStatusWidget } from "."; - - + Shows connected users and connected guest devices on wired or wireless networks. ### Screenshots -import wifiCard from './img/wifi.png'; -import wiredCard from './img/wired.png'; +import wifiCard from "./img/wifi.png"; +import wiredCard from "./img/wired.png"; network-controller-status wifi network-controller-status wired ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx index fe4cd3357..1386fc069 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -1,38 +1,42 @@ --- -title: 'Network Controller Summary' -description: 'Displays the summary of a Network Controller' +title: "Network Controller Summary" +description: "Displays the summary of a Network Controller" hide_title: true --- -import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; -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 { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; +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 { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { networkControllerSummaryWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { networkControllerSummaryWidget } from "."; - - + Shows information whether your WAN is connected, Wi-Fi is active and several other information. ### Screenshots -import summaryCard from './img/summary.png'; +import summaryCard from "./img/summary.png"; network-controller-summary ### Supported Integrations - + + + ### 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 0a715ab3c..41638ac33 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -1,44 +1,41 @@ --- -title: 'Notifications' -description: 'Display notification history from an integration' +title: "Notifications" +description: "Display notification history from an integration" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { notificationsWidget } from '.'; -import { ntfyIntegration } from '@site/docs/integrations/ntfy'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; -import { gotifyIntegration } from '@site/docs/integrations/gotify'; +import { 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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { IconAd } from "@tabler/icons-react"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { notificationsWidget } from "."; +import { ntfyIntegration } from "@site/docs/integrations/ntfy"; +import { nextcloudIntegration } from "@site/docs/integrations/nextcloud"; +import { gotifyIntegration } from "@site/docs/integrations/gotify"; + + The notifications widget allows you to display notifications from various integrations in a unified view. ### Screenshots -import notificationsScreenshot from './img/notifications.png'; +import notificationsScreenshot from "./img/notifications.png"; notifications ### Supported Integrations + + ### Adding the widget diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index 3dd684efd..4479165e2 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -1,34 +1,39 @@ --- -title: 'Paperless-ngx' -description: 'Displays document management statistics including inbox ratio, document counts, and metadata.' +title: "Paperless-ngx" +description: "Displays document management statistics including inbox ratio, document counts, and metadata." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { paperlessNgxWidget } from '.'; -import { paperlessNgxIntegration } from '@site/docs/integrations/paperless-ngx'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { paperlessNgxWidget } from "."; +import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx"; - + This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index d16ab8d68..ccbcd7dab 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -1,21 +1,19 @@ --- -title: 'PatchMon' -description: 'Displays host patch statistics from PatchMon including update and security counts.' +title: "PatchMon" +description: "Displays host patch statistics from PatchMon including update and security counts." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { patchmonWidget } from '.'; -import { patchmonIntegration } from '@site/docs/integrations/patchmon'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { patchmonWidget } from "."; +import { patchmonIntegration } from "@site/docs/integrations/patchmon"; - + This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning. @@ -39,13 +37,13 @@ In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a r When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity: -| Stat | Green | Yellow | Red | -|------|-------|--------|-----| -| Needs Updates | 0 | 1–4 | 5+ | -| Security Updates | 0 | 1–9 | 10+ | -| Hosts with Security Updates | 0 | — | 1+ | -| Up-to-Date Hosts | 100% of total | 90–99% | below 90% | -| Outdated Packages | 0 | 1–49 | 50+ | +| Stat | Green | Yellow | Red | +| --------------------------- | ------------- | ------ | --------- | +| Needs Updates | 0 | 1–4 | 5+ | +| Security Updates | 0 | 1–9 | 10+ | +| Hosts with Security Updates | 0 | — | 1+ | +| Up-to-Date Hosts | 100% of total | 90–99% | below 90% | +| Outdated Packages | 0 | 1–49 | 50+ | Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts. @@ -62,13 +60,13 @@ The OS distribution limit defaults to **All**. A widget size of **2×3** or larg The widget adapts its layout based on the available space: -| Widget size | Layout | -|-------------|--------| -| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats | +| Widget size | Layout | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats | | **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero | -| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards | -| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer | -| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend | +| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards | +| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer | +| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend | ### Last updated @@ -76,14 +74,21 @@ When the widget has enough space, a "last updated" timestamp appears at the bott ### Supported Integrations - + + + ### Adding the widget ### Configuration + 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 68bbb1071..d663554d6 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -1,37 +1,40 @@ --- -title: 'RSS Feed' -description: 'Monitor and display one or more generic RSS, ATOM or JSON feeds.' +title: "RSS Feed" +description: "Monitor and display one or more generic RSS, ATOM or JSON feeds." hide_title: true --- -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 { rssFeedWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { rssFeedWidget } from "."; - + Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. ### Screenshots -import screenshot from './img/screenshot.png'; +import screenshot from "./img/screenshot.png"; rss-feed noted-lol + + ### Adding the widget ### Configuration + ### Examples + Here's a small collection of examples, that you can use for testing or actual use (in no particular order): + - https://noted.lol/rss/ - https://blog.google/products/chrome/rss/ - https://www.w3.org/blog/feed/ @@ -45,4 +48,4 @@ Here's a small collection of examples, that you can use for testing or actual us - http://feeds.foxnews.com/foxnews/latest There is a good list of feeds here: -https://github.com/greysonp/rss-feeds/blob/master/feeds.json \ No newline at end of file +https://github.com/greysonp/rss-feeds/blob/master/feeds.json 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 d101d6376..28c83d3aa 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -1,34 +1,32 @@ --- -title: 'Entity State' -description: 'Display the state of an entity and toggle it optionally' +title: "Entity State" +description: "Display the state of an entity and toggle it optionally" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { smartHomeEntityStateWidget } from '.'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { smartHomeEntityStateWidget } from "."; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; - - + 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. +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. ### Screenshots -import sun from './img/sun.png'; -import lightSwitch from './img/light-switch.png'; -import temperature from './img/temperature.png'; +import sun from "./img/sun.png"; +import lightSwitch from "./img/light-switch.png"; +import temperature from "./img/temperature.png"; smart-home-entity-state sun smart-home-entity-state light-switch @@ -36,32 +34,40 @@ import temperature from './img/temperature.png'; ### Supported Integrations - + ### Interactions - Toggle entity state by clicking on the entity (only when clickable enabled). + + ### Adding the widget ### Configuration + ### How to find the entity ID To find the entity ID in Home Assistant, follow these steps: + 1. Open Home Assistant and go to the **Developer Tools** section (usually found in the sidebar). 2. Click on the **States** tab. 3. In the list of entities, find the entity you want to use. 4. The entity ID will be displayed in the format `domain.object_id` (e.g., `light.living_room_light`). 5. Copy the entity ID and paste it into the widget configuration in Homarr. It will then show the State that is visible in Home Assistant. -import retrieveEntityId from './img/retrieve-entity-id.png'; +import retrieveEntityId from "./img/retrieve-entity-id.png";
homeassistant retrieve-entity-id -
\ No newline at end of file +
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 522c113ca..f0118799a 100644 --- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx +++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx @@ -1,50 +1,60 @@ --- -title: 'Execute Automation' -description: 'Trigger an automation with one click' +title: "Execute Automation" +description: "Trigger an automation with one click" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { smartHomeExecuteAutomationWidget } from '.'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { smartHomeExecuteAutomationWidget } from "."; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; - - + The execute automation widget allows you to trigger an automation with one click. ### Screenshots -import empty from './img/empty.png'; -import withDisplayname from './img/with-displayname.png'; +import empty from "./img/empty.png"; +import withDisplayname from "./img/with-displayname.png";
smart-home-execute-automation empty - smart-home-execute-automation with-displayname + smart-home-execute-automation with-displayname
### Supported Integrations - + ### Interactions - Trigger the automation by clicking on the widget. + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 94be18dd7..c6c0bd992 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -1,39 +1,45 @@ --- -title: 'Speedtest Tracker' -description: 'Displays speed test results from your Speedtest Tracker instance.' +title: "Speedtest Tracker" +description: "Displays speed test results from your Speedtest Tracker instance." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { speedtestTrackerWidget } from '.'; -import { speedtestTrackerIntegration } from '@site/docs/integrations/speedtest-tracker'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { speedtestTrackerWidget } from "."; +import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-tracker"; + + 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. ### Screenshots -import screenshot from './img/screenshot.png'; +import screenshot from "./img/screenshot.png"; speedtest-tracker image ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index 5d9a24c64..2a6119240 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -1,28 +1,26 @@ --- -title: 'Stock Price' -description: 'Displays the current stock price of a company.' +title: "Stock Price" +description: "Displays the current stock price of a company." hide_title: true --- -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 { stockPriceWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { stockPriceWidget } from "."; - + Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. ### Screenshots -import apple from './img/apple.png'; -import google from './img/google.png'; -import ethereum from './img/ethereum.png'; -import nvidia from './img/nvidia.png'; +import apple from "./img/apple.png"; +import google from "./img/google.png"; +import ethereum from "./img/ethereum.png"; +import nvidia from "./img/nvidia.png";
@@ -34,17 +32,21 @@ import nvidia from './img/nvidia.png';
stock-price nvidia
- + + ### Adding the widget ### Configuration + ### Examples + Here's a small collection of examples, that you can use for testing or actual use (in no particular order): + - AAPL - GOOGL - AMZN @@ -59,4 +61,4 @@ Here's a small collection of examples, that you can use for testing or actual us ### Data source We use the Yahoo Finance API to fetch the stock prices. -If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com` 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-disks/index.ts b/apps/docs/docs/widgets/system-disks/index.ts index 8dd68f057..ade53efc0 100644 --- a/apps/docs/docs/widgets/system-disks/index.ts +++ b/apps/docs/docs/widgets/system-disks/index.ts @@ -19,7 +19,11 @@ export const systemDisksWidget: WidgetDefinition = { description: "How disk usage values are shown", values: { type: "select", - options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"], + options: [ + "Percentage (e.g., 76%)", + "Absolute values (e.g., 800GB / 1TB)", + "Free space percentage (e.g., 24% free)", + ], }, defaultValue: "Percentage (e.g., 76%)", }, 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 e9bdfd326..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"; @@ -19,18 +21,12 @@ import trainDepartures from "./img/train-departures.png"; import streetDepartures from "./img/street-departures.png";
- timetable-widget-train-departures - timetable-widget-bus-and-tram-departures + timetable-widget-train-departures + 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 a579d8af3..ff02699ca 100644 --- a/apps/docs/docs/widgets/tracearr/index.mdx +++ b/apps/docs/docs/widgets/tracearr/index.mdx @@ -1,52 +1,57 @@ --- -title: 'Tracearr' -description: 'Monitor media server streams, user activity, and policy violations.' +title: "Tracearr" +description: "Monitor media server streams, user activity, and policy violations." hide_title: true --- -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 { tracearrWidget } from '.'; -import { tracearrIntegration } from '@site/docs/integrations/tracearr'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { tracearrWidget } from "."; +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. ### Sections -| Section | Description | -|---|---| -| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. | -| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). | -| **Recent Activity** | A paginated list of watching history with user avatars and watched status. | -| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. | +| Section | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. | +| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). | +| **Recent Activity** | A paginated list of watching history with user avatars and watched status. | +| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. | ### Screenshots -import widgetOverview from './img/widget-overview.png'; -import streamsAndStats from './img/streams-and-stats.png'; +import widgetOverview from "./img/widget-overview.png"; +import streamsAndStats from "./img/streams-and-stats.png";
tracearr-widget-overview tracearr-streams-and-stats
+ + ### Adding the widget ### Supported Integrations - + ### Configuration + 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 0d0006054..6f2c2bb7f 100644 --- a/apps/docs/docs/widgets/umami/index.mdx +++ b/apps/docs/docs/widgets/umami/index.mdx @@ -1,28 +1,33 @@ --- -title: 'Umami Analytics' -description: 'Display visitor stats from your Umami analytics instance' +title: "Umami Analytics" +description: "Display visitor stats from your Umami analytics instance" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { umamiWidget } from '.'; -import { umamiIntegration } from '@site/docs/integrations/umami'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { umamiWidget } from "."; +import { umamiIntegration } from "@site/docs/integrations/umami"; + + The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. ### Supported Integrations - + + + ### Adding the widget @@ -34,12 +39,12 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b ### View modes -| View | Description | -|---|---| -| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. | -| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. | -| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. | -| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". | +| View | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. | +| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. | +| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. | +| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". | ### Notes diff --git a/apps/docs/docs/widgets/ups/index.mdx b/apps/docs/docs/widgets/ups/index.mdx index bf512990d..8a3d7037e 100644 --- a/apps/docs/docs/widgets/ups/index.mdx +++ b/apps/docs/docs/widgets/ups/index.mdx @@ -1,20 +1,19 @@ --- -title: 'UPS' -description: 'Monitor the status of your UPS devices through a NUT server.' +title: "UPS" +description: "Monitor the status of your UPS devices through a NUT server." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { upsWidget } from '.'; -import { peaNutIntegration } from '@site/docs/integrations/peanut'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { upsWidget } from "."; +import { peaNutIntegration } from "@site/docs/integrations/peanut"; + + This widget displays the status of the UPS devices reported by a Network UPS Tools (NUT) server. Each device shows its status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and @@ -22,27 +21,34 @@ output voltage. ### Layouts -import screenshotFull from './img/screenshot.png'; -import screenshotCompact from './img/screenshot-compact.png'; -import screenshotMini from './img/screenshot-mini.png'; +import screenshotFull from "./img/screenshot.png"; +import screenshotCompact from "./img/screenshot-compact.png"; +import screenshotMini from "./img/screenshot-mini.png"; The widget adapts to the size of the tile, so it stays readable from a full dashboard panel down to a single cell. Multiple UPS devices scroll within the widget at any height. -| Layout | Size | Preview | -| --- | --- | --- | -| Full | 3+ columns wide | UPS widget full layout | -| Compact | ~2 columns | UPS widget compact layout | -| Mini | 1 column | UPS widget mini layout | +| Layout | Size | Preview | +| ------- | --------------- | --------------------------------------------------------------------------- | +| Full | 3+ columns wide | UPS widget full layout | +| Compact | ~2 columns | UPS widget compact layout | +| Mini | 1 column | UPS widget mini layout | ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx index 3a6592b2e..6c5eefa16 100644 --- a/apps/docs/docs/widgets/uptime-kuma/index.mdx +++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx @@ -1,34 +1,39 @@ --- -title: 'Uptime Kuma' -description: 'Displays monitor uptime statistics, average uptime percentage, and service status counts.' +title: "Uptime Kuma" +description: "Displays monitor uptime statistics, average uptime percentage, and service status counts." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { uptimeKumaWidget } from '.'; -import { uptimeKumaIntegration } from '@site/docs/integrations/uptime-kuma'; +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"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { uptimeKumaWidget } from "."; +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. ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx index 3ccf3760a..27fef9fb9 100644 --- a/apps/docs/docs/widgets/video/index.mdx +++ b/apps/docs/docs/widgets/video/index.mdx @@ -1,19 +1,17 @@ --- -title: 'Video Stream' -description: 'Embed a video stream or video from a camera or a website.' +title: "Video Stream" +description: "Embed a video stream or video from a camera or a website." hide_title: true --- -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 { videoWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { videoWidget } from "."; - + Do you run surveilance cameras? Or maybe you have a favourite video? The video widget enables you to integrate both seamlessly into your dashboard. @@ -22,7 +20,6 @@ This widget uses [video.js](https://videojs.com/) to play HLS or DASH video nati DRM content is not yet supported. More info here: https://videojs.com/guides/faqs/#q-what-is-videojs - You can use the following URL to test: http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 @@ -30,9 +27,12 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp ![video big-buck-bunny](./img/bunny.png) + + ### Adding the widget ### Configuration + 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 0aa6a1a16..fb6223cbe 100644 --- a/apps/docs/docs/widgets/vpn/index.mdx +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -1,19 +1,18 @@ --- -title: 'VPN' -description: 'Monitor the status of your VPN integrations.' +title: "VPN" +description: "Monitor the status of your VPN integrations." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { vpnWidget } from '.'; -import { gluetunIntegration } from '@site/docs/integrations/gluetun'; +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"; +import { vpnWidget } from "."; +import { gluetunIntegration } from "@site/docs/integrations/gluetun"; + + The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS services are up, the currently exposed public IP address, and the VPN provider and protocol being used. @@ -23,7 +22,7 @@ current status at a glance. ### Screenshots -import singleIntegrationImage from './img/single.png'; +import singleIntegrationImage from "./img/single.png";
VPN widget showing a single integration @@ -31,9 +30,15 @@ import singleIntegrationImage from './img/single.png'; ### Supported Integrations - + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 0ff4081d7..3ca81acaa 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -1,40 +1,41 @@ --- -title: 'Weather' -description: 'Displays the current weather information of a set location.' +title: "Weather" +description: "Displays the current weather information of a set location." hide_title: true --- -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 { weatherWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { weatherWidget } from "."; - + The Weather Widget uses your location to display the current, highest and lowest temperature. ### Screenshots -import forecastWeather from './img/forecast.png'; -import currentWeather from './img/current.png'; +import forecastWeather from "./img/forecast.png"; +import currentWeather from "./img/current.png";
weather forecast-5-days weather current
+ + ### Adding the widget ### Configuration + ### Data source We use the Open Meteo API to fetch the weather and locations. -If you need to whitelist the api, please use the following URL: `https://open-meteo.com` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `https://open-meteo.com` 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]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..9f2c2b3d8 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -2,7 +2,6 @@ import type { PropsWithChildren } from "react"; import { useState } from "react"; -import type { QueryKey } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; @@ -34,6 +33,7 @@ import { import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; +import type { WidgetDefinition } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets"; import { createWidgetQueryPersister } from "./query-cache-persister"; @@ -93,11 +93,11 @@ export function TRPCReactProvider(props: PropsWithChildren) { }); client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); for (const { definition } of Object.values(widgetImports)) { - const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string }; + const def = definition as WidgetDefinition & { kind: string }; if (def.refetchInterval === undefined) continue; - const key = def.queryKey ?? [["widget", def.kind]]; const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - client.setQueryDefaults(key, { refetchInterval: interval }); + const primaryQueryKey = def.queryKey ?? [["widget", def.kind]]; + client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval }); } return client; }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..292a2ed05 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -4,6 +4,7 @@ import { Box, Stack } from "@mantine/core"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardBackgroundVideo } from "~/components/layout/background"; @@ -17,17 +18,19 @@ export const ClientBoard = () => { .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); return ( - - - - {fullWidthSortedSections.map((section) => - section.kind === "empty" ? ( - - ) : ( - - ), - )} - - + + + + + {fullWidthSortedSections.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 30c91de40..5e12a259b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -70,6 +70,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool }; const AddMenu = () => { + const board = useRequiredBoard(); const { data: session } = useSession(); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); @@ -101,8 +102,8 @@ const AddMenu = () => { ); const handleSelectItem = useCallback(() => { - openItemSelectModal(); - }, [openItemSelectModal]); + openItemSelectModal({ board, persistImmediately: false }); + }, [board, openItemSelectModal]); const handleSelectApp = useCallback(() => { openAppSelectModal({ diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx index 25a53ffd2..383d74795 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx @@ -123,7 +123,7 @@ export function CustomWidgetPreview({ type: "application/json", }); const blobUrl = URL.createObjectURL(blob); - window.open(blobUrl); + window.open(blobUrl, "_blank", "noopener,noreferrer"); setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); }} > @@ -167,7 +167,7 @@ function PreviewDisplay({ data }: { data: unknown }) { const Component = dataType ? displayComponents[dataType] : undefined; if (Component) { - return ; + return ; } return ( 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/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index d4b2cb24d..0778bf864 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -3,35 +3,48 @@ import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; import type { WidgetKind } from "@homarr/definitions"; import { widgetDefaultSizes } from "@homarr/definitions"; +import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; -import { getFirstEmptyPosition } from "./empty-position"; +import { getFirstEmptyPosition, getNearestEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; +export interface BoardPlacementHint { + sectionId: string; + layoutId: string; + xOffset: number; + yOffset: number; +} + export interface CreateItemInput { id?: string; kind: WidgetKind; options?: Record; integrationIds?: string[]; + advancedOptions?: BoardItemAdvancedOptions; + placement?: BoardPlacementHint; } export const createItemCallback = - ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => + ({ id, kind, options = {}, integrationIds = [], advancedOptions, placement }: CreateItemInput) => (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); - if (!firstSection) return previous; + if (!firstSection && !placement) return previous; + + const defaultSectionId = firstSection?.id ?? placement?.sectionId; + if (!defaultSectionId) return previous; const widget = { id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection, kind), + layouts: createItemLayouts(previous, defaultSectionId, kind, placement), integrationIds, - advancedOptions: { + advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "", @@ -53,16 +66,31 @@ const defaultItemSize = { width: 1, height: 1 }; const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; -const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { +const createItemLayouts = ( + board: Board, + defaultSectionId: string, + kind: WidgetKind, + placement?: BoardPlacementHint, +): ItemLayout[] => { const layouts = getBoardLayouts(board); const itemSize = getWidgetItemSize(kind); return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); - const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); + const targetSectionId = placement?.layoutId === layoutId ? placement.sectionId : defaultSectionId; + const elements = getSectionElements(board, { sectionId: targetSectionId, layoutId }); + const targetSection = board.sections.find((section) => section.id === targetSectionId); + const dynamicLayout = + targetSection?.kind === "dynamic" + ? targetSection.layouts.find((layout) => layout.layoutId === layoutId) + : undefined; + const columnCount = dynamicLayout?.width ?? boardLayout?.columnCount ?? 1; + const rowCount = dynamicLayout?.height ?? 9999; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) + ? placement?.layoutId === layoutId + ? getNearestEmptyPosition(elements, columnCount, rowCount, placement, itemSize) + : getFirstEmptyPosition(elements, columnCount, rowCount, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -73,7 +101,7 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid width: itemSize.width, height: itemSize.height, ...emptyPosition, - sectionId: currentSection.id, + sectionId: targetSectionId, layoutId, }; }); diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..ec1803545 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -7,19 +7,56 @@ export const getFirstEmptyPosition = ( size: { width: number; height: number } = { width: 1, height: 1 }, ) => { for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { - for (let xOffset = 0; xOffset < columnCount; xOffset++) { - const isOccupied = elements.some( - (element) => - element.yOffset < yOffset + size.height && - element.yOffset + element.height > yOffset && - element.xOffset < xOffset + size.width && - element.xOffset + element.width > xOffset, - ); - - if (!isOccupied) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { + if (isPositionEmpty(elements, { xOffset, yOffset }, size)) { return { xOffset, yOffset }; } } } return undefined; }; + +export const getNearestEmptyPosition = ( + elements: Pick[], + columnCount: number, + rowCount: number, + preferred: { xOffset: number; yOffset: number }, + size: { width: number; height: number }, +) => { + const maxX = columnCount - size.width; + const maxY = rowCount - size.height; + if (maxX < 0 || maxY < 0) return undefined; + + const clamped = { + xOffset: Math.min(Math.max(0, preferred.xOffset), maxX), + yOffset: Math.min(Math.max(0, preferred.yOffset), maxY), + }; + if (isPositionEmpty(elements, clamped, size)) return clamped; + + const lastOccupiedRow = elements.reduce((maximum, element) => Math.max(maximum, element.yOffset + element.height), 0); + const searchMaxY = Math.min(maxY, Math.max(clamped.yOffset + 20, lastOccupiedRow + size.height)); + let best: { xOffset: number; yOffset: number; distance: number } | undefined; + + for (let yOffset = 0; yOffset <= searchMaxY; yOffset++) { + for (let xOffset = 0; xOffset <= maxX; xOffset++) { + if (!isPositionEmpty(elements, { xOffset, yOffset }, size)) continue; + const distance = Math.abs(xOffset - clamped.xOffset) + Math.abs(yOffset - clamped.yOffset); + if (!best || distance < best.distance) best = { xOffset, yOffset, distance }; + } + } + + return best && { xOffset: best.xOffset, yOffset: best.yOffset }; +}; + +const isPositionEmpty = ( + elements: Pick[], + position: { xOffset: number; yOffset: number }, + size: { width: number; height: number }, +) => + !elements.some( + (element) => + element.yOffset < position.yOffset + size.height && + element.yOffset + element.height > position.yOffset && + element.xOffset < position.xOffset + size.width && + element.xOffset + element.width > position.xOffset, + ); diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e2a0ff93c..f09383171 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as boardContext from "@homarr/boards/context"; @@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; @@ -108,4 +112,58 @@ describe("item actions create-item", () => { { height: 1, width: 1 }, ); }); + + test("uses the clicked section and nearest cell for the active layout", () => { + const activeLayoutId = "desktop"; + const secondaryLayoutId = "mobile"; + const rootSectionId = "root"; + const targetSectionId = "target"; + const activeLayout = new LayoutMockBuilder({ id: activeLayoutId, columnCount: 12 }).build(); + const secondaryLayout = new LayoutMockBuilder({ id: secondaryLayoutId, columnCount: 4 }).build(); + const targetSection = new DynamicSectionMockBuilder({ id: targetSectionId }) + .addLayout({ + layoutId: activeLayoutId, + parentSectionId: rootSectionId, + width: 4, + height: 3, + }) + .build(); + const board = new BoardMockBuilder() + .addLayout(activeLayout) + .addLayout(secondaryLayout) + .addEmptySection({ id: rootSectionId, yOffset: 0 }) + .addSection(targetSection) + .build(); + + vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([activeLayoutId, secondaryLayoutId]); + + const result = createItemCallback({ + kind: "clock", + placement: { + sectionId: targetSectionId, + layoutId: activeLayoutId, + xOffset: 2, + yOffset: 1, + }, + })(board); + + expect(result.items.at(0)?.layouts).toEqual([ + { + layoutId: activeLayoutId, + sectionId: targetSectionId, + xOffset: 2, + yOffset: 1, + width: 1, + height: 1, + }, + { + layoutId: secondaryLayoutId, + sectionId: rootSectionId, + xOffset: 0, + yOffset: 0, + width: 1, + height: 1, + }, + ]); + }); }); 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 ad0f3a305..5ddd9b613 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,7 +1,8 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react"; +import { useEffect, useRef } from "react"; +import { ActionIcon, Badge, Box, Card, FocusTrap } from "@mantine/core"; +import { useElementSize, useMergedRef, useViewportSize } from "@mantine/hooks"; +import { IconMaximize, IconPin, IconX } from "@tabler/icons-react"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -10,10 +11,14 @@ 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 { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { getAdvancedFocusRect } from "../advanced-focus/geometry"; import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; @@ -33,35 +38,160 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); + const sourceRef = useRef(null); + const mergedRef = useMergedRef(ref, sourceRef); + const { width: viewportWidth, height: viewportHeight } = useViewportSize(); const board = useRequiredBoard(); + const t = useI18n(); + const [isEditMode] = useEditMode(); const widgetStateRef = useRef | null>(null); + const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus(); + const activeFocus = active?.itemId === item.id ? active : null; + const isAdvanced = activeFocus !== null; + const advancedRect = activeFocus + ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) + : null; + + // Responsive layout changes remount grid items. Never leave their focus backdrop orphaned. + useEffect(() => () => dismiss(item.id), [dismiss, item.id]); + + const openPinned = () => { + if (sourceRef.current) open(item.id, sourceRef.current, true); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.currentTarget !== event.target || event.key !== "Enter" || !event.shiftKey || isEditMode) return; + event.preventDefault(); + openPinned(); + }; + const handleSurfacePointerDown = (event: PointerEvent) => { + if (!activeFocus || activeFocus.pinned) return; + + const target = + event.target instanceof Element + ? event.target.closest( + "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex='-1']), [contenteditable='true']", + ) + : null; + const alreadyAutofocused = target?.hasAttribute("data-autofocus") ?? false; + if (target && !alreadyAutofocused) target.setAttribute("data-autofocus", "true"); + pin(false); + if (target && !alreadyAutofocused) { + window.setTimeout(() => target.removeAttribute("data-autofocus"), 250); + } + }; return ( <> - - + sourceRef.current && hover(item.id, sourceRef.current)} + onPointerLeave={() => leave(item.id)} + className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)} > - - +
+ + +
+ {isAdvanced && ( + close()} + > + + + )} + {isAdvanced && ( + pin(event.detail === 0)} + > + + + )} + {!isEditMode && !isAdvanced && ( + + + + )} +
+ +
+
+
+
- {item.advancedOptions.title?.trim() && ( + {!isAdvanced && item.advancedOptions.title?.trim() && ( | null>; + displayMode: "compact" | "advanced"; } const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { 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 new file mode 100644 index 000000000..79675c882 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -0,0 +1,25 @@ +.selectionCard { + overflow: hidden; +} + +.selectionActions { + opacity: 1; + transition: opacity 150ms ease; +} + +@media (hover: hover) and (pointer: fine) { + .selectionActions { + opacity: 0; + } + + .selectionCard:hover .selectionActions, + .selectionCard:focus-within .selectionActions { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .selectionActions { + transition: none; + } +} 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 2ac349c6d..d7994d77b 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -3,10 +3,12 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, import { IconApi } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { usePersistBoard } from "@homarr/boards/updater"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; @@ -15,15 +17,47 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import { WidgetEditModal } from "@homarr/widgets/modals"; import { useItemActions } from "./item-actions"; +import type { BoardPlacementHint, CreateItemInput } from "./actions/create-item"; +import { createItemCallback } from "./actions/create-item"; +import classes from "./item-select-modal.module.css"; -export const ItemSelectModal = createModal(({ actions }) => { +interface ItemSelectModalProps { + placement?: BoardPlacementHint; + board: { id: string; name: string }; + persistImmediately: boolean; +} + +export const ItemSelectModal = createModal(({ actions, innerProps }) => { const [search, setSearch] = useState(""); const t = useI18n(); - const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); + const { createItem } = useItemActions(); const { openModal: openEditModal } = useModalAction(WidgetEditModal); const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); const settings = useSettings(); + const { updateAndPersistBoard } = usePersistBoard(innerProps.board); + + const createAndPersist = (input: CreateItemInput) => { + try { + if (!innerProps.persistImmediately) { + createItem(input); + return; + } + updateAndPersistBoard(createItemCallback(input), { + onError: () => { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + }, + }); + } catch { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + } + }; const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); @@ -69,8 +103,13 @@ export const ItemSelectModal = createModal(({ actions }) => { const handleAddCustomWidget = (definitionId: string) => { const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings); - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); + createAndPersist({ + id: itemId, + kind: "customApi", + integrationIds: [], + options: { ...defaultOptions, definitionId }, + placement: innerProps.placement, + }); actions.closeModal(); }; @@ -88,7 +127,6 @@ export const ItemSelectModal = createModal(({ actions }) => { const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); - createItem({ id: itemId, kind, integrationIds }); actions.closeModal(); openEditModal( @@ -100,13 +138,19 @@ export const ItemSelectModal = createModal(({ actions }) => { integrationIds, }, onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + createAndPersist({ + id: itemId, + kind, + options, + integrationIds: newIntegrationIds, + advancedOptions, + placement: innerProps.placement, + }); }, integrationData: matchingIntegrations, integrationSupport: hasIntegrationSupport, settings, + boardId: innerProps.board.id, }, { title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, @@ -144,19 +188,18 @@ export const ItemSelectModal = createModal(({ actions }) => { style={{ gridColumn: "1 / -1" }} /> {filteredCustomWidgets.map((def) => ( - e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} - > + {def.iconUrl ? ( - + ) : ( )} @@ -174,9 +217,8 @@ export const ItemSelectModal = createModal(({ actions }) => { left={0} right={0} p="xs" + className={classes.selectionActions} style={{ - opacity: "var(--_hover-opacity)", - transition: "opacity 150ms ease", background: "linear-gradient(transparent, var(--mantine-color-body) 30%)", }} > @@ -223,14 +265,11 @@ const WidgetItem = ({ h={selectGridCardHeight} withBorder pos="relative" + className={classes.selectionCard} style={{ - overflow: "hidden", - "--_hover-opacity": "0", borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined, borderWidth: hasMatchingIntegration ? 2 : undefined, }} - onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} > @@ -244,6 +283,11 @@ const WidgetItem = ({ {item.description} + {hasMatchingIntegration && ( + + {t("item.create.integrationAvailable")} + + )} 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 8520d6b89..d57161787 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,35 +1,40 @@ "use client"; -import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } 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, IconCopy, IconLayoutKanban, + IconMaximize, IconRefresh, IconSettings, IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { partialMatchKey, 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 { useConfirmModal, 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 } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { useBoardPermissions } from "../permissions/client"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; import { ItemMoveModal } from "./item-move-modal"; @@ -37,13 +42,16 @@ import { ItemMoveModal } from "./item-move-modal"; interface WidgetContextMenuProps { item: SectionItem; widgetStateRef: MutableRefObject | null>; + sourceRef: RefObject; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { +export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const { updateAndPersistBoard } = usePersistBoard(board); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); @@ -55,19 +63,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); + const { open: openAdvancedFocus } = useAdvancedFocus(); + const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); + const [menuOpened, setMenuOpened] = useState(false); - const widgetQueryKey = useMemo( - () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), - [currentDefinition, item.kind], + const widgetQueryKeys = useMemo( + () => getWidgetQueryKeys(currentDefinition as Parameters[0]), + [currentDefinition], ); - const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; + const matchesWidgetQuery = useCallback( + (queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)), + [widgetQueryKeys], + ); + const isWidgetFetching = + useIsFetching({ + type: "active", + predicate: (query) => matchesWidgetQuery(query.queryKey), + }) > 0; const handleRefetch = useCallback(() => { - void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); - }, [queryClient, widgetQueryKey]); + void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" }))); + }, [queryClient, widgetQueryKeys]); + + 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 options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, @@ -76,11 +99,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { + if (!hasChangeAccess) return []; const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [currentDefinition, hasChangeAccess, settings]); - const widgetContextActions = useMemo(() => { + const widgetContextActions = (() => { const def = currentDefinition as Record; if (typeof def.contextActions !== "function") return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -90,17 +114,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); }, integrationIds: item.integrationIds, - context: { isEditMode, boardId: board.id, itemId: item.id }, + context: { + isEditMode, + boardId: board.id, + itemId: item.id, + canInteractWithSelectedIntegrations, + }, widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); + })(); const persistBoard = useCallback( - (updatedItems: typeof board.items) => { - saveBoard({ ...board, items: updatedItems }); + (updater: (previous: typeof board) => typeof board) => { + updateAndPersistBoard(updater, { + onError: () => { + showErrorNotification({ + title: t("item.menu.notification.saveError.title"), + message: t("item.menu.notification.saveError.message"), + }); + }, + }); }, - [board, saveBoard], + [t, updateAndPersistBoard], ); const openEditModal = useCallback(() => { @@ -113,12 +149,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont integrationIds: item.integrationIds, }, onSuccessfulEdit: (editResult) => { - updateItemOptions({ itemId: item.id, newOptions: editResult.options }); - updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); - updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); - if (!isEditMode) { - persistBoard( - board.items.map((boardItem) => + if (isEditMode) { + updateItemOptions({ itemId: item.id, newOptions: editResult.options }); + updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); + updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); + } else { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => boardItem.id !== item.id ? boardItem : { @@ -128,7 +166,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont integrationIds: editResult.integrationIds, }, ), - ); + })); } }, integrationData: (integrationData ?? []).filter( @@ -154,18 +192,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont settings, isEditMode, persistBoard, - board, ]); const handleToggle = useCallback( (key: string) => (checked: boolean) => { - const newOptions = { ...options, [key]: checked }; - updateItemOptions({ itemId: item.id, newOptions }); - persistBoard( - board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })), - ); + if (isEditMode) { + updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } }); + } else { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } }, + ), + })); + } }, - [options, item.id, updateItemOptions, persistBoard, board], + [options, item.id, updateItemOptions, isEditMode, persistBoard], ); if (!session) return <>{children}; @@ -174,9 +216,28 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( - + {children} + } + onClick={() => { + if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current, true); + }} + disabled={isEditMode} + > + {t("item.advancedFocus.open")} + + {toggleOptions.length > 0 && ( <> {tMenu("options")} @@ -248,20 +309,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont {tMenu("refresh")} - } - onClick={openEditModal} - disabled={isPending} - > - {tMenu("settings")} - + {hasChangeAccess && ( + } + onClick={openEditModal} + disabled={isPending} + > + {tMenu("settings")} + + )} {isEditMode && ( @@ -290,16 +353,20 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKey: QueryKey; + queryKeys: readonly QueryKey[]; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { - const queries = queryClient.getQueryCache().findAll({ queryKey }); +const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { + const queries = queryClient.getQueryCache().findAll({ + type: "active", + predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, 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 ( @@ -334,7 +401,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.error")} · {ageLabel} + {t("item.menu.status.error")} + {ageSuffix} @@ -345,7 +413,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.success")} · {ageLabel} + {t("item.menu.status.success")} + {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 89b3ba93e..f6ec2600f 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 @@ -13,10 +13,8 @@ transform 150ms ease; } -:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) - .wrapper, -:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) - .wrapper { +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper, +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper { opacity: 1; transform: translateY(0); } 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/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx index e851e5c2a..6934008f5 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -1,13 +1,13 @@ "use client"; import type { BoxProps } from "@mantine/core"; -import { Box } from "@mantine/core"; import combineClasses from "clsx"; import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; import { SectionContent } from "../content"; import { SectionProvider } from "../section-context"; import { useSectionItems } from "../use-section-items"; +import { BoardEmptySpaceContextMenu } from "./board-empty-space-context-menu"; import { useGridstack } from "./use-gridstack"; interface Props extends BoxProps { @@ -22,15 +22,16 @@ export const GridStack = ({ section, ...props }: Props) => { return ( - - + ); }; diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index b5e468de6..0c593b0a1 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -14,6 +14,7 @@ import { initializeGridstack } from "./init-gridstack"; export interface UseGridstackRefs { wrapper: Ref; + wrapperElement: RefObject; items: RefObject>>; gridstack: RefObject; } @@ -287,6 +288,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) refs: { items: itemRefs, wrapper: wrapperRef, + wrapperElement: wrapperElementRef, gridstack: gridRef, }, }; 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/patchmon.spec.ts b/packages/api/src/router/test/widgets/patchmon.spec.ts index df37b5647..27f5cfe40 100644 --- a/packages/api/src/router/test/widgets/patchmon.spec.ts +++ b/packages/api/src/router/test/widgets/patchmon.spec.ts @@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => { }); const caller = createCaller(db, null); - await expect(caller.getStats({ integrationId })).rejects.toThrow( - new TRPCError({ code: "UNAUTHORIZED" }), - ); + await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" })); }); test("should throw FORBIDDEN for authenticated users without board or integration access", async () => { 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 09fafa880..a306e71ca 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -9,13 +9,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { applyAuth } from "../custom-widget/auth"; import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data"; +import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "./custom-api-security"; const logger = createLogger({ module: "widget:customApi" }); const FETCH_TIMEOUT_MS = 10_000; -const validateUrl = (urlString: string): URL => new URL(urlString); - export const customApiRouter = createTRPCRouter({ getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -47,7 +46,7 @@ export const customApiRouter = createTRPCRouter({ value: decryptSecret(s.value), })); - const url = validateUrl(definition.url); + const url = validateCustomApiUrl(definition.url); const headers = new Headers({ Accept: "application/json" }); if (definition.method !== "GET" && definition.requestBody) { @@ -64,7 +63,7 @@ export const customApiRouter = createTRPCRouter({ method: definition.method, headers, body: definition.method !== "GET" ? definition.requestBody : undefined, - redirect: "follow", + redirect: "error", signal: controller.signal, }); @@ -75,7 +74,7 @@ export const customApiRouter = createTRPCRouter({ }); } - const json: unknown = await response.json(); + const json = await readBoundedJsonResponseAsync(response); return extractDisplayDataWithFallback(json, definition.displayType, displayConfig); } catch (error) { if (error instanceof TRPCError) throw error; 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 5e43efe3e..56fa10fd1 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -34,6 +34,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 f172cec36..71b0652fc 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -61,6 +61,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 9de482008..226cebbf3 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"; @@ -253,49 +253,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/patchmon/patchmon-types.ts b/packages/integrations/src/patchmon/patchmon-types.ts index 1f912d6c9..96c3db293 100644 --- a/packages/integrations/src/patchmon/patchmon-types.ts +++ b/packages/integrations/src/patchmon/patchmon-types.ts @@ -52,9 +52,9 @@ const mapOsDistributionEntry = ( osVersion: entry.os_version, }); -export const parsePatchMonStatsResponseAsync = async ( - response: { json: () => Promise }, -): Promise> => { +export const parsePatchMonStatsResponseAsync = async (response: { + json: () => Promise; +}): Promise> => { let json: unknown; try { json = await response.json(); @@ -82,7 +82,5 @@ export const mapPatchMonStats = (data: z.infer b.count - a.count), + osDistribution: [...data.os_distribution].map(mapOsDistributionEntry).sort((a, b) => b.count - a.count), }); diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts index 64e046256..2a7284b64 100644 --- a/packages/integrations/src/plex/test/plex-integration.spec.ts +++ b/packages/integrations/src/plex/test/plex-integration.spec.ts @@ -137,4 +137,4 @@ describe("PlexIntegration.getMediaReleasesAsync", () => { }, ]); }); -}); \ No newline at end of file +}); 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 de211bf66..e8a9192a7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1632,12 +1632,33 @@ "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", + "integrationAvailable": "Connected integration available", + "notification": { + "error": { + "title": "Unable to add item", + "message": "The item could not be added to this board" + } + } + }, + "advancedFocus": { + "open": "Open advanced view", + "close": "Close advanced view", + "pin": "Keep advanced view open", + "pinned": "Advanced view is pinned", + "dialog": "{widget} advanced view" }, "moveResize": { "title": "Move / resize item", @@ -1791,6 +1812,26 @@ "showBroadcastMessage": { "label": "Show broadcast messages" } + }, + "noProjectSelected": "No project selected", + "runnerStatus": "Runner: {status}", + "updatedAt": "Updated {date}", + "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" } }, "dnsHoleSummary": { @@ -1825,6 +1866,11 @@ "dnsQueriesToday": "Queries today", "domainsBeingBlocked": "Domains on blocklist" }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "unknown": "Unknown" + }, "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked" }, "dnsHoleControls": { @@ -1850,7 +1896,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", @@ -2014,6 +2061,8 @@ "height": "Height" }, "placeholder": "Start writing your notes", + "saveFailed": "Unable to save note", + "documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}", "dismiss": { "title": "Dismiss changes?", "message": "You have unsaved changes in your notebook. Are you sure you want to discard them?", @@ -2043,13 +2092,23 @@ "noNotes": "No notes found", "integrationRequired": "Select an Anchor integration first", "updatedAt": "Updated {date}", + "createdAt": "Created {date}", "untitled": "Untitled", "edit": "Edit", "save": "Save", "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.", + "permission": { + "owner": "Owner", + "editor": "Editor", + "viewer": "Viewer" + }, + "status": { + "pinned": "Pinned", + "archived": "Archived" + } }, "iframe": { "name": "iFrame", @@ -2108,6 +2167,18 @@ "clickable": { "label": "Clickable" } + }, + "advanced": { + "entityId": "Entity ID: {id}", + "lastUpdated": "Updated {date}", + "attribute": { + "unit": "Unit", + "deviceClass": "Device class", + "icon": "Icon" + } + }, + "error": { + "toggleFailed": "Unable to update entity" } }, "smartHome-executeAutomation": { @@ -2123,6 +2194,13 @@ }, "spotlightAction": { "run": "Run {name}" + }, + "advanced": { + "lastExecuted": "Last run at {time}", + "automationId": "Automation ID: {id}" + }, + "error": { + "executeFailed": "Unable to run automation" } }, "stockPrice": { @@ -2498,7 +2576,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" @@ -2755,6 +2839,11 @@ "target": "Target codec" } } + }, + "summary": { + "sessions": "{count, plural, one {# session} other {# sessions}}", + "playing": "{count} playing", + "transcoding": "{count} transcoding" } }, "paperlessNgx": { @@ -3037,7 +3126,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", @@ -3610,6 +3708,13 @@ "error": { "internalServerError": "Unable to get data from firewall" }, + "versionUnknown": "Unknown version", + "metricAccessible": "{metric}: {value}%, {status}", + "status": { + "normal": "Normal", + "warning": "Warning", + "critical": "Critical" + }, "option": { "interfaces": "Network interfaces to display" }, @@ -3673,6 +3778,11 @@ "unhealthy": "Unhealthy", "ok": "OK", "fail": "Fail", + "range": { + "twelveHours": "12h", + "oneDay": "24h", + "all": "All" + }, "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" @@ -3714,6 +3824,11 @@ "downCount": "Down", "pausedCount": "Paused" }, + "status": { + "up": "Up", + "down": "Down", + "paused": "Paused" + }, "error": { "internalServerError": "Failed to fetch uptime data" } @@ -3743,6 +3858,19 @@ "services": "Services", "middlewares": "Middlewares" }, + "resourceType": { + "router": "Router", + "service": "Service", + "middleware": "Middleware" + }, + "resourceStatus": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown" + }, + "failedEndpoint": "Failed: {endpoint}", "status": { "healthy": "Healthy", "errors": "{count} errors", @@ -3889,11 +4017,30 @@ }, "noDefinition": "No definition selected", "definitionNotFound": "Widget definition not found. It may have been deleted.", + "fetchFailed": "Unable to load custom API data", "noDisplayOutput": "No display output", "executeConfirm": "Are you sure you want to execute this action?", "executing": "Executing...", "executeSuccess": "Action executed successfully", - "executeFailed": "Action failed. Check the widget configuration." + "executeFailed": "Action failed. Check the widget configuration.", + "actions": { + "pausePolling": "Pause polling", + "resumePolling": "Resume polling", + "togglePolling": "Pause or resume polling" + }, + "displayType": { + "singleValue": "Single value", + "keyValue": "Key/value", + "table": "Table", + "statGrid": "Stat grid", + "progressBars": "Progress bars", + "statusIndicator": "Status indicator", + "countGrid": "Count grid", + "raw": "Raw data", + "actionButton": "Action button", + "customJsx": "Custom JSX", + "unknown": "Custom data" + } }, "beszelSystemTable": { "name": "Beszel Systems (Table)", @@ -4081,6 +4228,14 @@ } }, "selectSystem": "Select system", + "period": { + "live": "Live", + "oneHour": "1h", + "twelveHours": "12h", + "oneDay": "24h", + "oneWeek": "1w", + "thirtyDays": "30d" + }, "empty": { "noSystems": "No systems found" }, 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 a54aada3f..9baaccf2d 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -5,6 +5,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, + 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/app/index.ts b/packages/widgets/src/app/index.ts index 62f01287d..7755ac105 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -15,6 +15,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("app", { icon: IconApps, + queryKeys: [[["app", "byId"]], [["widget", "app"]]], createOptions(settings) { return optionsBuilder.from( (factory) => ({ 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-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 5e3799a90..f7dd6dee7 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,6 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, + queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { 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({ diff --git a/packages/widgets/src/firewall/firewall-version.tsx b/packages/widgets/src/firewall/firewall-version.tsx index df3daa2a9..87ec9f9d8 100644 --- a/packages/widgets/src/firewall/firewall-version.tsx +++ b/packages/widgets/src/firewall/firewall-version.tsx @@ -29,8 +29,9 @@ export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny autoContrast variant="outline" color="lightgray" - size={isTiny ? "8px" : "xs"} - style={{ minHeight: "24px" }} + size="xs" + style={{ minHeight: "24px", maxWidth: isTiny ? 72 : undefined }} + styles={{ label: { overflow: "hidden", textOverflow: "ellipsis" } }} > {formatVersion(version ?? "", t("widget.firewall.versionUnknown"))} diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx index a8981150e..d14f63521 100644 --- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx +++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx @@ -69,7 +69,7 @@ export const ClusterHealthMonitoring = ({ {options.showUptime && ( - + {formatUptime(uptime, t)} diff --git a/packages/widgets/src/health-monitoring/system-health.module.css b/packages/widgets/src/health-monitoring/system-health.module.css index 1ab18a656..b7b3a70d2 100644 --- a/packages/widgets/src/health-monitoring/system-health.module.css +++ b/packages/widgets/src/health-monitoring/system-health.module.css @@ -5,3 +5,35 @@ [data-mantine-color-scheme="dark"] .card { background-color: var(--mantine-color-dark-7); } + +.systemPanel { + min-width: 0; +} + +.infoAction { + transition: opacity 150ms ease; +} + +.infoActionCompact { + opacity: 0; + pointer-events: none; +} + +.systemPanel:hover .infoActionCompact, +.systemPanel:focus-within .infoActionCompact { + opacity: 1; + pointer-events: auto; +} + +@media (hover: none) { + .infoActionCompact { + opacity: 1; + pointer-events: auto; + } +} + +@media (prefers-reduced-motion: reduce) { + .infoAction { + transition: none; + } +} diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 317f97f5a..0c9665094 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -83,16 +83,29 @@ export const SystemHealthMonitoring = ({ const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId); const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart); const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes); + const hasAttentionState = healthInfo.rebootRequired || healthInfo.availablePkgUpdates > 0; return ( - + {t("albumPhoto")} - {advanced && ( - <> - move(-1)} - > - - - + {assets.length > 1 && ( + + {advanced && ( setPaused((value) => !value)} + size={40} + onClick={() => move(-1)} > - {paused ? : } + - + )} move(1)} + size={advanced ? 40 : 32} + onClick={() => setPaused((value) => !value)} > - + {paused ? : } - + {advanced && ( + move(1)} + > + + + )} + )} {(showPhotoInfo || advanced) && ( diff --git a/packages/widgets/src/immich/server-stats/component.module.css b/packages/widgets/src/immich/server-stats/component.module.css index b16fdb597..66608badc 100644 --- a/packages/widgets/src/immich/server-stats/component.module.css +++ b/packages/widgets/src/immich/server-stats/component.module.css @@ -8,3 +8,11 @@ background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5)); } } + +.statItemDense { + min-width: 0; + min-height: 44px; + padding: 4px 6px; + border-radius: var(--mantine-radius-sm); + background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6)); +} diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx index d1b9b8c4a..b17ce1de5 100644 --- a/packages/widgets/src/immich/server-stats/component.tsx +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -21,6 +21,7 @@ export default function ImmichServerStatsWidget({ options, displayMode = "compact", width, + height, widgetStateRef, }: WidgetComponentProps<"immich-serverStats">) { const t = useI18n(); @@ -40,16 +41,26 @@ export default function ImmichServerStatsWidget({ if (!stats) return ; + const statCount = + Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage); + const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced); + const statsContent = ( - = 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm"> + {options.showUsers && ( - } label={t("widget.immich-serverStats.users")} value={stats.userCount} /> + } + label={t("widget.immich-serverStats.users")} + value={stats.userCount} + dense={statsLayout.dense} + /> )} {options.showPhotos && ( } label={t("widget.immich-serverStats.photos")} value={stats.photoCount} + dense={statsLayout.dense} /> )} {options.showVideos && ( @@ -57,6 +68,7 @@ export default function ImmichServerStatsWidget({ icon={} label={t("widget.immich-serverStats.videos")} value={stats.videoCount} + dense={statsLayout.dense} /> )} {options.showStorage && ( @@ -64,6 +76,7 @@ export default function ImmichServerStatsWidget({ icon={} label={t("widget.immich-serverStats.storage")} value={formatBytes(stats.totalLibraryUsageInBytes)} + dense={statsLayout.dense} /> )} @@ -71,7 +84,7 @@ export default function ImmichServerStatsWidget({ if (!isAdvanced) { return ( - + {statsContent} ); @@ -110,20 +123,46 @@ interface StatItemProps { icon: React.ReactNode; label: string; value: string | number; + dense: boolean; } -function StatItem({ icon, label, value }: StatItemProps) { +function StatItem({ icon, label, value, dense }: StatItemProps) { + if (dense) { + return ( + + + {value} + + + {label} + + + ); + } + return ( - + {icon} - + {label} - + {value} ); } + +export const getImmichStatsLayout = (width: number, height: number, itemCount: number, isAdvanced = false) => { + const count = Math.max(1, itemCount); + if (isAdvanced) { + return { columns: width >= 720 ? Math.min(count, 4) : width >= 360 ? Math.min(count, 2) : 1, dense: false }; + } + + const dense = width < 320 || height < 160; + if (height < 140) return { columns: Math.min(count, width >= 360 ? 4 : 2), dense: true }; + if (height < 220) return { columns: Math.min(count, 2), dense: true }; + return { columns: Math.min(count, width >= 280 ? 2 : 1), dense }; +}; diff --git a/packages/widgets/src/immich/server-stats/layout.spec.ts b/packages/widgets/src/immich/server-stats/layout.spec.ts new file mode 100644 index 000000000..7640bda4b --- /dev/null +++ b/packages/widgets/src/immich/server-stats/layout.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "vitest"; + +import { getImmichStatsLayout } from "./component"; + +describe("Immich server statistics compact layout", () => { + test("keeps four enabled stats legible in a short widget", () => { + expect(getImmichStatsLayout(280, 120, 4)).toEqual({ columns: 2, dense: true }); + expect(getImmichStatsLayout(480, 120, 4)).toEqual({ columns: 4, dense: true }); + }); + + test("uses the roomier summary layout when space is available", () => { + expect(getImmichStatsLayout(360, 240, 4)).toEqual({ columns: 2, dense: false }); + expect(getImmichStatsLayout(200, 180, 4)).toEqual({ columns: 2, dense: true }); + }); +}); diff --git a/packages/widgets/src/indexer-manager/component.module.css b/packages/widgets/src/indexer-manager/component.module.css index 1ab18a656..700a080b9 100644 --- a/packages/widgets/src/indexer-manager/component.module.css +++ b/packages/widgets/src/indexer-manager/component.module.css @@ -5,3 +5,44 @@ [data-mantine-color-scheme="dark"] .card { background-color: var(--mantine-color-dark-7); } + +.root { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.testAction { + margin-inline-start: auto; + flex-shrink: 0; + opacity: 0; + transition: opacity 120ms ease; +} + +.root:hover .testAction, +.root:focus-within .testAction { + opacity: 1; +} + +.indexerRow { + min-height: 24px; + padding: 2px 4px; + border-radius: var(--mantine-radius-xs); + + &:hover, + &:focus-within { + background-color: var(--mantine-color-default-hover); + } +} + +@media (hover: none) { + .testAction { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .testAction { + transition: none; + } +} diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index 2868c3caf..3d8b6138d 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useEffect, useMemo } from "react"; -import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; +import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -31,7 +31,7 @@ export default function IndexerManagerWidget({ const board = useRequiredBoard(); const isAdvanced = displayMode === "advanced"; const hasSmallWidth = !isAdvanced && width < 256; - const hasSmallHeight = !isAdvanced && height < 256; + const isDense = !isAdvanced && (width < 280 || height < 180); const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]); const unavailableCount = allIndexers.filter( (indexer) => indexer.status === false || indexer.enabled === false, @@ -49,16 +49,26 @@ export default function IndexerManagerWidget({ }, [integrationIds, testAll, widgetStateRef]); return ( - - - - - {t("widget.indexerManager.title")} - + + + + + + {!hasSmallWidth && ( + + {t("widget.indexerManager.title")} + + )} {isAdvanced && ( @@ -71,9 +81,9 @@ export default function IndexerManagerWidget({ )} )} - {hasSmallHeight && ( + { testAll({ integrationIds }); }} + aria-label={t("widget.indexerManager.testAll")} > - + - )} + @@ -114,7 +125,7 @@ export default function IndexerManagerWidget({ )} {indexers.map((indexer) => ( - + {indexer.name} @@ -153,24 +171,6 @@ export default function IndexerManagerWidget({ ))} - {!hasSmallHeight && ( - - )} ); } diff --git a/packages/widgets/src/media-missing/component.module.css b/packages/widgets/src/media-missing/component.module.css index 5b1c9f568..9068a25b3 100644 --- a/packages/widgets/src/media-missing/component.module.css +++ b/packages/widgets/src/media-missing/component.module.css @@ -5,10 +5,10 @@ text-decoration: none; color: inherit; border: 1px solid var(--border-color); + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); transition: border-color 120ms ease, - transform 120ms ease, - box-shadow 120ms ease; + background-color 120ms ease; @mixin dark { --border-color: var(--mantine-color-dark-4); @@ -17,22 +17,16 @@ --border-color: var(--mantine-color-gray-3); } - &:hover { + &:hover, + &:focus-visible { border-color: var(--mantine-primary-color-filled); - transform: translateY(-1px); - box-shadow: var(--mantine-shadow-sm); + background-color: var(--mantine-color-default-hover); } -} -.backdrop { - position: absolute; - inset: 0; - background-size: cover; - background-position: center; - filter: blur(20px) saturate(1.2); - transform: scale(1.3); - opacity: 0.16; - pointer-events: none; + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } } .content { @@ -47,3 +41,9 @@ object-fit: cover; box-shadow: var(--mantine-shadow-sm); } + +@media (prefers-reduced-motion: reduce) { + .card { + transition: none; + } +} diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index c1bddb97b..b170b9061 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -295,9 +295,6 @@ const CardShell = ({ p="xs" h={CARD_HEIGHT[density]} > - {item.imageUrl && ( - - )}
{children}
); diff --git a/packages/widgets/src/media-releases/component.module.css b/packages/widgets/src/media-releases/component.module.css new file mode 100644 index 000000000..602f43ba1 --- /dev/null +++ b/packages/widgets/src/media-releases/component.module.css @@ -0,0 +1,24 @@ +.item { + display: block; + width: 100%; + min-width: 0; + color: inherit; + text-align: start; + transition: background-color 120ms ease; + + &:hover, + &:focus-visible { + background-color: var(--mantine-color-default-hover); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } +} + +@media (prefers-reduced-motion: reduce) { + .item { + transition: none; + } +} diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index f768f7ed4..e90743f27 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -29,11 +29,13 @@ import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; +import classes from "./component.module.css"; export default function MediaReleasesWidget({ options, integrationIds, width, + height, displayMode, }: WidgetComponentProps<"mediaReleases">) { const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ @@ -48,7 +50,7 @@ export default function MediaReleasesWidget({ {releases.map((item, index) => ( {!isAdvanced && index !== 0 && options.layout === "poster" && } - + ))}
@@ -60,25 +62,30 @@ interface ItemProps { item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; + width: number; + height: number; } -const formatReleaseDate = (value: unknown, locale: string) => { +const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => { const date = toValidDate(value); if (!date) return "—"; return Intl.DateTimeFormat(locale, { - month: "2-digit", + month: compact ? "short" : "2-digit", year: "numeric", day: "2-digit", - hour: "2-digit", - minute: "2-digit", - hour12: false, + ...(compact ? {} : { hour: "2-digit", minute: "2-digit", hour12: false }), }).format(date); }; -const Item = ({ item, options, isAdvanced }: ItemProps) => { +const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { const locale = useCurrentLocale(); const t = useI18n(); const length = formatLength(item.length, item.type, t); + const isCompact = !isAdvanced && (width < 340 || height < 180); + const isTiny = !isAdvanced && (width < 220 || height < 105); + const showPoster = (isAdvanced || options.layout === "poster") && !isTiny; + const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360); + const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource)); return ( { } > { }} /> )} - - - {(isAdvanced || options.layout === "poster") && ( - {item.title} + + + {showPoster && ( + )} - + - + {item.title} - {item.subtitle !== undefined && ( - + {item.subtitle !== undefined && !isTiny && ( + {item.subtitle} )} - - - {length !== undefined && ( + + + {length !== undefined && !isTiny && ( <> )} - {item.producer !== undefined && ( + {item.producer !== undefined && showExtendedMetadata && ( <> )} - {item.rating !== undefined && ( + {item.rating !== undefined && showExtendedMetadata && ( <> )} - {item.price !== undefined && ( + {item.price !== undefined && showExtendedMetadata && ( <> )} - {item.tags.length > 0 && ( + {item.tags.length > 0 && (isAdvanced || !isCompact) && ( )} {isAdvanced && item.description && ( @@ -186,7 +201,7 @@ const Item = ({ item, options, isAdvanced }: ItemProps) => { )} - {(isAdvanced || options.showType || options.showSource) && ( + {showSide && ( {(isAdvanced || options.showType) && ( ( const Info = ({ icon: Icon, label }: IconAndLabelProps) => { return ( - - {Icon && } - + + {Icon && } + {label} diff --git a/packages/widgets/src/media-requests/list/component.module.css b/packages/widgets/src/media-requests/list/component.module.css new file mode 100644 index 000000000..6dc16de32 --- /dev/null +++ b/packages/widgets/src/media-requests/list/component.module.css @@ -0,0 +1,45 @@ +.card { + min-width: 0; + background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7)); + transition: + background-color 120ms ease, + border-color 120ms ease; + + &:hover, + &:focus-within { + border-color: var(--mantine-primary-color-filled); + background-color: var(--mantine-color-default-hover); + } +} + +.pendingActions { + flex-shrink: 0; + opacity: 0; + transition: opacity 120ms ease; +} + +.card:hover .pendingActions, +.card:focus-within .pendingActions, +.pendingActionsVisible { + opacity: 1; +} + +@media (hover: none) { + .pendingActions { + opacity: 1; + } +} + +@media (pointer: coarse) { + .pendingActions button { + min-width: 36px; + min-height: 36px; + } +} + +@media (prefers-reduced-motion: reduce) { + .card, + .pendingActions { + transition: none; + } +} diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index 7017d3495..79d0184eb 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -17,7 +17,8 @@ import { WidgetEmptyState } from "../../common/empty-state"; import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; -import classes from "../search-button.module.css"; +import classes from "./component.module.css"; +import searchClasses from "../search-button.module.css"; export default function MediaServerWidget({ integrationIds, @@ -46,7 +47,7 @@ export default function MediaServerWidget({ if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); return ( - + {!isEditMode && } {failedIntegrations.length > 0 && ( @@ -64,6 +65,7 @@ export default function MediaServerWidget({ key={`${mediaRequest.integrationId}-${mediaRequest.id}`} request={mediaRequest} isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)} + isDense={displayMode !== "advanced" && (width < 340 || height < 150)} isAdvanced={displayMode === "advanced"} canInteract={interactIntegrationIds.has(mediaRequest.integrationId)} options={options} @@ -81,7 +83,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] return ( ["options"]; } -const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => { +const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => { const board = useRequiredBoard(); const t = useScopedI18n("widget.mediaRequests-requestList"); return ( - - - + {!isTiny && ( )} - - - + + + + {request.name || "unknown"} + + {request.status === "pending" ? ( + + ) : ( + + )} + + + {toValidDate(request.airDate)?.getFullYear() ?? t("toBeDetermined")} @@ -164,46 +191,26 @@ const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: )} - - - - {(request.requestedBy?.displayName ?? "") || "unknown"} - - - - - - {request.name || "unknown"} - - {request.status === "pending" ? ( - - ) : ( - + {!isTiny && ( + + + + {(request.requestedBy?.displayName ?? "") || "unknown"} + + )}
@@ -217,9 +224,10 @@ interface DecisionButtonsProps { requestId: number; integrationId: string; canInteract: boolean; + alwaysVisible: boolean; } -const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => { +const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible }: DecisionButtonsProps) => { const utils = clientApi.useUtils(); const { mutate: mutateRequestAnswer, @@ -239,13 +247,18 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt }; return ( - + { @@ -260,7 +273,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButt className="mediaRequests-list-item-pending-button-decline" variant="light" color="red" - size="xs" + size="sm" disabled={!canInteract || isPending} aria-label={t("pending.decline")} onClick={() => { diff --git a/packages/widgets/src/media-requests/stats/component.module.css b/packages/widgets/src/media-requests/stats/component.module.css index 1ab18a656..913c196c4 100644 --- a/packages/widgets/src/media-requests/stats/component.module.css +++ b/packages/widgets/src/media-requests/stats/component.module.css @@ -5,3 +5,25 @@ [data-mantine-color-scheme="dark"] .card { background-color: var(--mantine-color-dark-7); } + +.userCard { + color: inherit; + text-decoration: none; + transition: background-color 120ms ease; + + &:hover, + &:focus-visible { + background-color: var(--mantine-color-default-hover); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } +} + +@media (prefers-reduced-motion: reduce) { + .userCard { + transition: none; + } +} diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index 9ced6ceb2..be1a69e54 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -97,6 +97,7 @@ export default function MediaServerWidget({ ] satisfies { name: keyof RequestStats; icon: Icon; number: number }[]; const isTiny = displayMode !== "advanced" && (width < 256 || height < 180); + const showSectionTitles = displayMode === "advanced" || height >= 180; const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8); const visibleUsers = requestStats.users.slice( 0, @@ -119,9 +120,11 @@ export default function MediaServerWidget({ style={{ pointerEvents: isEditMode ? "none" : undefined }} > - - {t("titles.stats.main")} - + {showSectionTitles && ( + + {t("titles.stats.main")} + + )} {visibleStats.map((stat) => ( - - {t("titles.users.main")} ({t("titles.users.requests")}) - + {showSectionTitles && ( + + {t("titles.users.main")} ({t("titles.users.requests")}) + + )} {visibleUsers.map((user) => ( @@ -159,6 +164,7 @@ export default function MediaServerWidget({ "mediaRequests-stats-users-user-wrapper", `mediaRequests-stats-users-user-${user.id}`, classes.card, + classes.userCard, )} key={`${user.integration.id}:${user.id}`} p="xs" @@ -171,7 +177,7 @@ export default function MediaServerWidget({ gap="sm" justify="space-between" > - + - + {user.displayName} diff --git a/packages/widgets/src/media-server/component.module.css b/packages/widgets/src/media-server/component.module.css new file mode 100644 index 000000000..3987924f4 --- /dev/null +++ b/packages/widgets/src/media-server/component.module.css @@ -0,0 +1,13 @@ +.sessionRow { + cursor: pointer; + + &:hover, + &:focus-visible { + background-color: var(--mantine-color-default-hover); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } +} diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index ff2acd148..862376d1b 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -26,6 +26,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import classes from "./component.module.css"; type TranscodingDecision = NonNullable["metadata"]>["transcoding"]; @@ -265,19 +266,26 @@ export default function MediaServerWidget({ pointerEvents: isEditMode ? "none" : undefined, }, }, - mantineTableBodyCellProps: ({ row }) => ({ - onClick: isEditMode - ? undefined - : () => { - openModal( - { - item: row.original, - }, - { - title: row.original.sessionName, - }, - ); - }, + mantineTableBodyRowProps: ({ row }) => { + const openDetails = () => { + openModal({ item: row.original }, { title: row.original.sessionName }); + }; + + return { + className: isEditMode ? undefined : classes.sessionRow, + tabIndex: isEditMode ? -1 : 0, + "aria-label": row.original.sessionName, + onClick: isEditMode ? undefined : openDetails, + onKeyDown: isEditMode + ? undefined + : (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + openDetails(); + }, + }; + }, + mantineTableBodyCellProps: () => ({ py: 4, style: { overflowX: "hidden", @@ -330,7 +338,7 @@ export default function MediaServerWidget({ )} - {(isAdvanced || height >= 96) && ( + {(isAdvanced || height >= 144) && ( - {totalBitrateLabel && ( + {totalBitrateLabel && (isAdvanced || width >= 300) && ( {t("footer.totalBitrate", { bitrate: totalBitrateLabel })} @@ -358,9 +366,11 @@ export default function MediaServerWidget({ {uniqueIntegrations.map((integration) => ( - - {integration.integrationName} - + {(isAdvanced || width >= 480) && ( + + {integration.integrationName} + + )} ))} diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index 9949cbab8..724ddc05a 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -14,6 +14,7 @@ import { SimpleGrid, Stack, Text, + VisuallyHidden, } from "@mantine/core"; import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react"; @@ -90,7 +91,7 @@ export default function MediaTranscodingWidget({ if (!transcodingData) return ; const queuePageCount = totalQueuePages ?? 1; - const isTiny = !isAdvanced && width < 256; + const isTiny = !isAdvanced && (width < 280 || height < 140); if (isAdvanced) { return ( @@ -134,7 +135,9 @@ export default function MediaTranscodingWidget({ label: (
- {!isTiny && ( + {isTiny ? ( + {t(`tab.${value}`)} + ) : ( {t(`tab.${value}`)} diff --git a/packages/widgets/src/media-transcoding/health-check-status.tsx b/packages/widgets/src/media-transcoding/health-check-status.tsx index 24a8100d3..9f10ddf27 100644 --- a/packages/widgets/src/media-transcoding/health-check-status.tsx +++ b/packages/widgets/src/media-transcoding/health-check-status.tsx @@ -1,5 +1,5 @@ import type { MantineColor } from "@mantine/core"; -import { Divider, Group, HoverCard, Indicator, RingProgress, Stack, Text } from "@mantine/core"; +import { ActionIcon, Divider, Group, Indicator, Popover, RingProgress, Stack, Text } from "@mantine/core"; import { useColorScheme } from "@mantine/hooks"; import { IconHeartbeat } from "@tabler/icons-react"; @@ -21,13 +21,15 @@ export function HealthCheckStatus(props: HealthCheckStatusProps) { : "green"; return ( - - - - - - - + + + + + + + + - - + + ); } diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx index 2f33ebb19..303b8f614 100644 --- a/packages/widgets/src/minecraft/server-status/component.tsx +++ b/packages/widgets/src/minecraft/server-status/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core"; +import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip, VisuallyHidden } from "@mantine/core"; import { IconCube, IconUsersGroup } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -16,14 +16,25 @@ export default function MinecraftServerStatusWidget({ height, displayMode, }: WidgetComponentProps<"minecraftServerStatus">) { - const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options); + const { data: result, isPending } = clientApi.widget.minecraft.getServerStatus.useQuery(options); const t = useI18n(); + if (isPending) { + return ( + + + {t("common.action.loading")} + + + ); + } if (!result) return ; const { data } = result; const title = options.title.trim().length > 0 ? options.title : options.domain; const isAdvanced = displayMode === "advanced"; + const isDense = !isAdvanced && (width < 220 || height < 120); + const showServerIcon = isAdvanced || (!isDense && height >= 144); const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45)); const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0; @@ -33,11 +44,11 @@ export default function MinecraftServerStatusWidget({ h="100%" w="100%" direction="column" - p={isAdvanced ? "lg" : "sm"} + p={isAdvanced ? "lg" : isDense ? "xs" : "sm"} justify="center" align="center" > - + - + + + {data.online + ? t("widget.minecraftServerStatus.status.online") + : t("widget.minecraftServerStatus.status.offline")} + {title} @@ -62,6 +78,7 @@ export default function MinecraftServerStatusWidget({ {data.online && ( <> {!options.isBedrockServer && + showServerIcon && (data.icon ? ( - + {formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)} diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index 6bfa59407..d931df9fb 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -1,13 +1,14 @@ "use client"; import { useMemo } from "react"; -import { Box, Card, SimpleGrid } from "@mantine/core"; +import { Box, Card, Center, SimpleGrid, Text } from "@mantine/core"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; import objectSupport from "dayjs/plugin/objectSupport"; import relativeTime from "dayjs/plugin/relativeTime"; import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { WifiVariant } from "./variants/wifi-variant"; @@ -22,10 +23,12 @@ export default function NetworkControllerNetworkStatusWidget({ integrationIds, displayMode, width, + height, }: WidgetComponentProps<"networkControllerStatus">) { - const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({ + const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); + const t = useI18n(); const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); const isAdvanced = displayMode === "advanced"; @@ -33,18 +36,39 @@ export default function NetworkControllerNetworkStatusWidget({ const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0); const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0); const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0); + const useHorizontalStats = !isAdvanced && height < 150 && width >= 200; + + if (isPending || data.length === 0) { + return ( +
+ + {isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")} + +
+ ); + } return ( - + = 560 ? 2 : 1} h="100%" spacing="sm"> {(isAdvanced || options.content === "wifi") && ( - + )} {(isAdvanced || options.content === "wired") && ( - + )} diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx index 9560c984a..ebcfbe2d9 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx @@ -1,4 +1,4 @@ -import { Group, Stack, Text } from "@mantine/core"; +import { Group, SimpleGrid, Text } from "@mantine/core"; import { IconWifi } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; @@ -9,10 +9,12 @@ export const WifiVariant = ({ countGuests, countUsers, compact = false, + horizontal = false, }: { countUsers: number; countGuests: number; compact?: boolean; + horizontal?: boolean; }) => { const t = useScopedI18n("widget.networkControllerStatus.card"); return ( @@ -23,10 +25,10 @@ export const WifiVariant = ({ {t("variants.wifi.name")}
- + - + ); }; diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx index 46ca76a83..ff19d41e2 100644 --- a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx +++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx @@ -1,4 +1,4 @@ -import { Group, Stack, Text } from "@mantine/core"; +import { Group, SimpleGrid, Text } from "@mantine/core"; import { IconNetwork } from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; @@ -9,10 +9,12 @@ export const WiredVariant = ({ countGuests, countUsers, compact = false, + horizontal = false, }: { countUsers: number; countGuests: number; compact?: boolean; + horizontal?: boolean; }) => { const t = useScopedI18n("widget.networkControllerStatus.card"); return ( @@ -23,10 +25,10 @@ export const WiredVariant = ({ {t("variants.wired.name")}
- + - + ); }; diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx index 86075dc0b..2d123ab5d 100644 --- a/packages/widgets/src/network-controller/summary/component.tsx +++ b/packages/widgets/src/network-controller/summary/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Badge, Card, Center, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core"; import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react"; import dayjs from "dayjs"; import duration from "dayjs/plugin/duration"; @@ -19,19 +19,31 @@ dayjs.extend(duration); export default function NetworkControllerSummaryWidget({ integrationIds, width, + height, displayMode, }: WidgetComponentProps<"networkControllerSummary">) { - const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({ + const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); const t = useI18n(); const isAdvanced = displayMode === "advanced"; + const isDense = !isAdvanced && height < 160; const columns = isAdvanced && width >= 720 ? 2 : 1; + if (isPending || summaries.length === 0) { + return ( +
+ + {isPending ? t("common.action.loading") : t("widget.networkControllerSummary.error.integrationsDisconnected")} + +
+ ); + } + return ( - + {summaries.map(({ integration, summary }) => ( 1 || isAdvanced}> @@ -46,9 +58,9 @@ export default function NetworkControllerSummaryWidget({ )} - - }>WAN - }> + + }>WAN + }> WWW @@ -56,15 +68,15 @@ export default function NetworkControllerSummaryWidget({ - }> + }> Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""} {isAdvanced && ( - }> + }> LAN · {summary.lan.users + summary.lan.guests} )} - }> + }> VPN @@ -81,11 +93,13 @@ export default function NetworkControllerSummaryWidget({ ); } -const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => { +const StatusIcon = ({ status, size }: { status?: "enabled" | "disabled"; size: number }) => { const mantineTheme = useMantineTheme(); if (status === "enabled") { - return ; + return ; } - if (status === "disabled") return ; - return ; + if (status === "disabled") { + return ; + } + return ; }; diff --git a/packages/widgets/src/notebook/notebook.css b/packages/widgets/src/notebook/notebook.css index 6b8054367..002195400 100644 --- a/packages/widgets/src/notebook/notebook.css +++ b/packages/widgets/src/notebook/notebook.css @@ -70,3 +70,31 @@ .ProseMirror { height: 100%; } + +.homarr-notebook-action { + opacity: 0; + transform: translateX(2px); + transition: + opacity 120ms ease, + transform 120ms ease; +} + +.homarr-notebook:hover .homarr-notebook-action, +.homarr-notebook:focus-within .homarr-notebook-action, +.homarr-notebook-action[data-visible="true"] { + opacity: 1; + transform: none; +} + +@media (any-pointer: coarse) { + .homarr-notebook-action { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .homarr-notebook-action { + transition: none; + } +} diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 720ded0a0..53a334189 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -341,7 +341,7 @@ export function Notebook({ }; return ( - + {isEditing && ( ) { - const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({ + const { data: notificationIntegrations = [], isPending } = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); @@ -43,6 +44,8 @@ export default function NotificationsWidget({ (integration): integration is typeof integration & { error: string } => Boolean(integration.error), ); const isAdvanced = displayMode === "advanced"; + const isDense = !isAdvanced && (width < 280 || height < 180); + const bodyLineClamp = isAdvanced ? 12 : height < 112 ? 1 : isDense ? 2 : 4; const columns = isAdvanced && width >= 720 ? 2 : 1; return ( @@ -59,7 +62,13 @@ export default function NotificationsWidget({ ))} )} - {sortedNotifications.length > 0 ? ( + {isPending ? ( + + + {t("common.action.loading")} + + + ) : sortedNotifications.length > 0 ? ( {sortedNotifications.map((notification) => ( - + {!options.hideLogos && notification.source?.iconUrl && ( )} - + {notification.title && ( - + {notification.title} )} - + {notification.body} @@ -103,22 +125,24 @@ export default function NotificationsWidget({ ))} ) : ( - - {t("widget.notifications.noItems")} - + + + {t("widget.notifications.noItems")} + + )} ); } -const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => { +const InfoDisplay = ({ date, source, dense }: { date: Date; source?: string; dense: boolean }) => { const timeAgo = useTimeAgo(date, 30000); // update every 30sec return ( - - + + {timeAgo} {source && •} diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index 4744c1bec..e0d08e49e 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -2,7 +2,7 @@ import { Fragment } from "react"; import type { CSSProperties } from "react"; -import { RingProgress, Text } from "@mantine/core"; +import { Center, RingProgress, Text } from "@mantine/core"; import { IconFileDescription, IconFileText, IconInbox, IconTag, IconUsers } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -78,10 +78,20 @@ export default function PaperlessNgxWidget({ displayMode = "compact", }: WidgetComponentProps<"paperlessNgx">) { const t = useScopedI18n("widget.paperlessNgx"); - const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({ + const tCommon = useScopedI18n("common"); + const { data: stats, isPending } = clientApi.widget.paperlessNgx.getStats.useQuery({ integrationId: integrationIds[0] ?? "", }); + if (isPending) { + return ( +
+ + {tCommon("action.loading")} + +
+ ); + } if (!stats) return ; const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal; diff --git a/packages/widgets/src/releases/component.module.scss b/packages/widgets/src/releases/component.module.scss index 9d41454cb..caafaa929 100644 --- a/packages/widgets/src/releases/component.module.scss +++ b/packages/widgets/src/releases/component.module.scss @@ -7,20 +7,41 @@ } .releasesRepositoryHeader { + width: 100%; + border: 0; + color: inherit; + font: inherit; + text-align: start; user-select: none; cursor: pointer; &:hover, &.active, + &:focus-visible, &:has(~ .releasesRepositoryDetails:hover) { background-color: var(--mantine-color-secondaryColor-light); } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } } .releasesRepositoryDetails { + width: 100%; + border: 0; + color: inherit; + font: inherit; + text-align: start; background-color: var(--mantine-color-default-hover); user-select: none; cursor: pointer; + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } } .releasesRepositoryExpanded { diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index c0f89c924..2c7470875 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -1,7 +1,18 @@ "use client"; import { useCallback, useMemo, useState } from "react"; -import { Button, Divider, Group, ScrollArea, SimpleGrid, Stack, Text, Title, Tooltip } from "@mantine/core"; +import { + Button, + Divider, + Group, + ScrollArea, + SimpleGrid, + Stack, + Text, + Title, + Tooltip, + UnstyledButton, +} from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { IconArchive, @@ -204,105 +215,111 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }: : undefined } > - toggleExpandedDisplay(repository)} + aria-expanded={isActive} > - + + - - {!options.showOnlyIcon && ( - - {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} - {repository.name || repository.identifier} - - )} + + {!options.showOnlyIcon && ( + + {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */} + {repository.name || repository.identifier} + + )} - + + {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))} + + + + + - {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))} + {repository.latestReleaseAt && + !hasError && + formatter.relativeTime(repository.latestReleaseAt, { + now, + style: "long", + })} - - - - - - {repository.latestReleaseAt && - !hasError && - formatter.relativeTime(repository.latestReleaseAt, { - now, - style: "long", - })} - - {hasError ? ( - - ) : repository.viewed ? ( - - ) : ( - (repository.isNewRelease || repository.isStaleRelease) && ( - - ) - )} + ) : repository.viewed ? ( + + ) : ( + (repository.isNewRelease || repository.isStaleRelease) && ( + + ) + )} + - + {(options.showDetails || isAdvanced) && ( )} @@ -334,198 +351,203 @@ const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayPro return ( <> - toggleExpandedDisplay(repository)} /> - + toggleExpandedDisplay(repository)} > - - - + + - - - - - - - - - - - - - - - - {!repository.starsCount - ? "-" - : formatter.number(repository.starsCount, { - notation: "compact", - maximumFractionDigits: 1, - })} - - - + - - + + + + - - {!repository.forksCount - ? "-" - : formatter.number(repository.forksCount, { - notation: "compact", - maximumFractionDigits: 1, - })} - - - - - - + + + - - - {!repository.openIssues - ? "-" - : formatter.number(repository.openIssues, { - notation: "compact", - maximumFractionDigits: 1, - })} - - - + + + {!repository.starsCount + ? "-" + : formatter.number(repository.starsCount, { + notation: "compact", + maximumFractionDigits: 1, + })} + + + + + + + + + {!repository.forksCount + ? "-" + : formatter.number(repository.forksCount, { + notation: "compact", + maximumFractionDigits: 1, + })} + + + + + + + + + {!repository.openIssues + ? "-" + : formatter.number(repository.openIssues, { + notation: "compact", + maximumFractionDigits: 1, + })} + + + +
- + ); }; diff --git a/packages/widgets/src/rssFeed/component.module.scss b/packages/widgets/src/rssFeed/component.module.scss index cd68be962..7d68fd7de 100644 --- a/packages/widgets/src/rssFeed/component.module.scss +++ b/packages/widgets/src/rssFeed/component.module.scss @@ -1,11 +1,26 @@ -.backgroundImage { - position: absolute; - width: 100%; - height: 100%; - filter: blur(5px); - transform: scaleX(-1.05) scaleZ(-1.05); - opacity: 0.25; - top: 0; - left: 0; - object-fit: cover; +.entry { + min-width: 0; + color: inherit; + text-decoration: none; + transition: background-color 120ms ease; + + &:hover, + &:focus-visible { + background-color: var(--mantine-color-default-hover); + } + + &:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: -2px; + } +} + +.poster { + flex-shrink: 0; +} + +@media (prefers-reduced-motion: reduce) { + .entry { + transition: none; + } } diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 79a070420..0da7d7c70 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -33,7 +33,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { }; }; -export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) { const { entries: feedEntries, failedFeedCount, @@ -54,11 +54,13 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent const languageDir = options.enableRtl ? "RTL" : "LTR"; const isAdvanced = displayMode === "advanced"; - const isNarrow = !isAdvanced && width < 128 * 3; + const isDense = !isAdvanced && (width < 420 || height < 180); + const isTiny = !isAdvanced && (width < 260 || height < 110); const columns = isAdvanced && width >= 720 ? 2 : 1; + const descriptionLines = isAdvanced ? Math.max(options.textLinesClamp, 8) : isDense ? 1 : options.textLinesClamp; return ( - + {warning && ( } p="xs" mb="xs"> {warning} @@ -68,25 +70,24 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent {feedEntries.map((feedEntry) => ( - {feedEntry.enclosure !== undefined && ( - backdrop - )} - - - {feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && ( + + {feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && ( {feedEntry.title} @@ -96,19 +97,14 @@ export default function RssFeed({ options, width, displayMode }: WidgetComponent {feedEntry.title} - {!options.hideDescription && feedEntry.description && ( - + {!options.hideDescription && feedEntry.description && !isTiny && ( + {feedDescriptionToText(feedEntry.description)} )} diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 197832f34..6aadfba46 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -83,12 +83,18 @@ export default function SmartHomeEntityStateWidget({ { key: "deviceClass" as const, value: entity?.attributes.device_class }, { key: "icon" as const, value: entity?.attributes.icon }, ].filter(({ value }) => typeof value === "string" && value.length > 0); + const displayName = + displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string" + ? entity.attributes.friendly_name + : options.displayName; + const state = entity?.state ?? "—"; return (
- - {displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string" - ? entity.attributes.friendly_name - : options.displayName} - - - {entity?.state ?? "—"} + + {state} {attribute} + + {displayName} + {displayMode === "advanced" && entity && ( <> @@ -131,7 +135,7 @@ export default function SmartHomeEntityStateWidget({ )} {error && ( - + {t("widget.smartHome-entityState.error.toggleFailed")} diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx index 5d57d2c62..98964991d 100644 --- a/packages/widgets/src/smart-home/execute-automation/component.tsx +++ b/packages/widgets/src/smart-home/execute-automation/component.tsx @@ -87,7 +87,7 @@ export default function SmartHomeTriggerAutomationWidget({ return ( - + {isPending ? t("widget.smartHome-executeAutomation.status.running") : isShowSuccess @@ -100,6 +100,7 @@ export default function SmartHomeTriggerAutomationWidget({ )}
- - - + + + + + {options.displayName} {displayMode === "advanced" && ( diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx index 56fbfa07e..8c3c4350d 100644 --- a/packages/widgets/src/speedtest-tracker/component.tsx +++ b/packages/widgets/src/speedtest-tracker/component.tsx @@ -9,7 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { WidgetEmptyState } from "../common/empty-state"; import { AveragesSection } from "./averages"; -import { combineSpeedtestDashboards } from "./helpers"; +import { combineSpeedtestDashboards, getCompactSections } from "./helpers"; import { LatestResultSection } from "./latest-result"; import { RecentResultsSection } from "./recent-results"; @@ -48,6 +48,11 @@ export default function SpeedtestTrackerWidget({ const averages = options.showStats && combined.stats && ( ); + const compactSections = getCompactSections(height, { + latest: Boolean(latest), + chart: hasChart, + averages: Boolean(averages), + }); if (displayMode === "advanced") { return ( @@ -85,13 +90,13 @@ export default function SpeedtestTrackerWidget({ return ( - {hasStatSection && ( + {(compactSections.latest || compactSections.averages) && ( - {latest &&
{latest}
} - {averages && height >= 170 &&
{averages}
} + {compactSections.latest &&
{latest}
} + {compactSections.averages &&
{averages}
}
)} - {hasChart && ( + {compactSections.chart && (
diff --git a/packages/widgets/src/speedtest-tracker/helpers.spec.ts b/packages/widgets/src/speedtest-tracker/helpers.spec.ts index ab2cd8564..1e02bf183 100644 --- a/packages/widgets/src/speedtest-tracker/helpers.spec.ts +++ b/packages/widgets/src/speedtest-tracker/helpers.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types"; -import { combineSpeedtestDashboards, mergeStats } from "./helpers"; +import { combineSpeedtestDashboards, getCompactSections, mergeStats } from "./helpers"; const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({ ping: { avg: average, min: average, max: average }, @@ -26,3 +26,16 @@ describe("speedtest dashboard aggregation", () => { expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2); }); }); + +describe("getCompactSections", () => { + const available = { latest: true, chart: true, averages: true }; + + it("prioritizes the latest result in a short widget", () => { + expect(getCompactSections(180, available)).toEqual({ latest: true, chart: false, averages: false }); + }); + + it("reveals history before averages as height grows", () => { + expect(getCompactSections(300, available)).toEqual({ latest: true, chart: true, averages: false }); + expect(getCompactSections(400, available)).toEqual({ latest: true, chart: true, averages: true }); + }); +}); diff --git a/packages/widgets/src/speedtest-tracker/helpers.ts b/packages/widgets/src/speedtest-tracker/helpers.ts index e438a4fa4..b590d0214 100644 --- a/packages/widgets/src/speedtest-tracker/helpers.ts +++ b/packages/widgets/src/speedtest-tracker/helpers.ts @@ -75,3 +75,21 @@ export const formatStatsSpeed = (band: SpeedtestTrackerStats["download"]): strin if (band.avg_bits != null) return formatBitsPerSec(band.avg_bits); return formatBitsPerSec(band.avg * 8); }; + +type CompactSection = "latest" | "chart" | "averages"; + +export const getCompactSections = ( + height: number, + available: Record, +): Record => { + const budget = height < 240 ? 1 : height < 360 ? 2 : 3; + const visible = new Set( + (["latest", "chart", "averages"] as const).filter((section) => available[section]).slice(0, budget), + ); + + return { + latest: visible.has("latest"), + chart: visible.has("chart"), + averages: visible.has("averages"), + }; +}; diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx index 20b792196..d2851aa43 100644 --- a/packages/widgets/src/speedtest-tracker/latest-result.tsx +++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx @@ -26,7 +26,7 @@ export function LatestResultSection({ result, width = 500 }: { result: Speedtest {t("latestResult")} - + {timestamp} diff --git a/packages/widgets/src/speedtest-tracker/section-label.tsx b/packages/widgets/src/speedtest-tracker/section-label.tsx index 255e05113..ce7e306d2 100644 --- a/packages/widgets/src/speedtest-tracker/section-label.tsx +++ b/packages/widgets/src/speedtest-tracker/section-label.tsx @@ -3,7 +3,7 @@ import { Text } from "@mantine/core"; export function SectionLabel({ children }: { children: ReactNode }) { return ( - + {children} ); diff --git a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx index 0817d07f4..844093417 100644 --- a/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx +++ b/packages/widgets/src/speedtest-tracker/speed-stat-card.tsx @@ -1,6 +1,6 @@ "use client"; -import { Card, Flex, Text } from "@mantine/core"; +import { Box, Flex, Text, Tooltip, VisuallyHidden } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -21,34 +21,37 @@ export function SpeedStatCard({ icon: Icon, color, value, label, compact = false const hideLabel = height > 0 && height <= 38; return ( - - + - - - - {value} - - {!hideLabel && ( - - {label} + + + + + {value} - )} + {!hideLabel && ( + + {label} + + )} + {hideLabel && {label}} + - - + + ); } diff --git a/packages/widgets/src/stocks/component.spec.ts b/packages/widgets/src/stocks/component.spec.ts index 56ad94954..5f74928ef 100644 --- a/packages/widgets/src/stocks/component.spec.ts +++ b/packages/widgets/src/stocks/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getStockSummary } from "./component"; +import { getStockLayout, getStockSummary } from "./component"; describe("stock summary", () => { it("returns no summary for an empty series", () => { @@ -16,3 +16,38 @@ describe("stock summary", () => { }); }); }); + +describe("stock layout", () => { + it("keeps compact widgets focused on symbol and price", () => { + expect(getStockLayout(320, 100, false)).toMatchObject({ + showName: false, + showChange: false, + showRange: false, + priceOrder: 2, + }); + }); + + it("reveals secondary data only when both axes have room", () => { + expect(getStockLayout(320, 160, false)).toMatchObject({ + showName: false, + showChange: true, + showRange: true, + }); + expect(getStockLayout(320, 220, false)).toMatchObject({ + showName: true, + showChange: true, + showRange: true, + graphHeight: "68%", + }); + }); + + it("always exposes the complete advanced summary", () => { + expect(getStockLayout(120, 80, true)).toEqual({ + showName: true, + showChange: true, + showRange: true, + graphHeight: "75%", + priceOrder: 1, + }); + }); +}); diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index aacd9c16c..4fee07497 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -30,6 +30,34 @@ export interface StockSummary { graphValues: number[]; } +export interface StockLayout { + showName: boolean; + showChange: boolean; + showRange: boolean; + graphHeight: "48%" | "68%" | "75%"; + priceOrder: 1 | 2; +} + +export function getStockLayout(width: number, height: number, isAdvanced: boolean): StockLayout { + if (isAdvanced) { + return { + showName: true, + showChange: true, + showRange: true, + graphHeight: "75%", + priceOrder: 1, + }; + } + + return { + showName: width >= 300 && height >= 190, + showChange: width >= 260 && height >= 130, + showRange: width >= 220 && height >= 130, + graphHeight: height >= 220 ? "68%" : "48%", + priceOrder: width >= 280 && height >= 120 ? 1 : 2, + }; +} + export function getStockSummary(priceHistory: number[], previousClose: number): StockSummary | null { const currentPrice = priceHistory.at(-1); if (currentPrice === undefined) return null; @@ -57,6 +85,7 @@ export default function StockPriceWidget({ options, width, height, displayMode } const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage); const stockGraphValues = summary.graphValues; const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6"; + const layout = getStockLayout(width, height, displayMode === "advanced"); return ( @@ -64,7 +93,7 @@ export default function StockPriceWidget({ options, width, height, displayMode } pos="absolute" bottom={10} w="100%" - h={displayMode === "advanced" || height > 280 ? "75%" : "50%"} + h={layout.graphHeight} data={stockGraphValues} curveType="linear" color={trendColor} @@ -81,18 +110,18 @@ export default function StockPriceWidget({ options, width, height, displayMode } )} {data.symbol} - {(displayMode === "advanced" || (width > 280 && height > 280)) && ( + {layout.showName && ( {data.shortName} )} - 280 ? 1 : 2} fw={700}> + <Title pos="absolute" bottom={10} right={10} order={layout.priceOrder} fw={700}> {new Intl.NumberFormat().format(round(summary.currentPrice))} - {(displayMode === "advanced" || width > 280) && ( + {layout.showChange && ( {new Intl.NumberFormat().format(stockValuesChange)} {stockValuesChangePercentage === null @@ -101,7 +130,7 @@ export default function StockPriceWidget({ options, width, height, displayMode } )} - {(displayMode === "advanced" || width > 280) && ( + {layout.showRange && ( {t(`option.timeRange.option.${options.timeRange}.label`)} diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 73a21fdb2..7977318c2 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -58,6 +58,7 @@ interface SystemDiskCardProps { integrationName?: string; secondaryText?: string; isAdvanced: boolean; + showTemperature: boolean; } const SystemDiskCard = ({ @@ -70,6 +71,7 @@ const SystemDiskCard = ({ integrationName, secondaryText, isAdvanced, + showTemperature, }: SystemDiskCardProps) => { const board = useRequiredBoard(); const scheme = useMantineColorScheme(); @@ -95,11 +97,18 @@ const SystemDiskCard = ({ }, [displayText, healthy]); const unhealthyLabel = t("widget.systemDisks.status.unhealthy"); + const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature; + const tooltipLabel = [ + healthy ? displayText : `${displayText} (${unhealthyLabel})`, + hasHiddenTemperature ? `${temperature}°C` : null, + ] + .filter(Boolean) + .join(" · "); return ( @@ -109,9 +118,9 @@ const SystemDiskCard = ({ bg={scheme.colorScheme === "dark" ? "dark.7" : "gray.1"} style={{ overflow: "hidden", position: "relative" }} > - -
- + +
+ {deviceName} {integrationName && ( @@ -119,22 +128,23 @@ const SystemDiskCard = ({ {integrationName} )} -

+ {displayText} {!healthy && {unhealthyLabel}} -

+ {isAdvanced && secondaryText && ( {secondaryText} )}
-
- {temperature !== null && temperature !== undefined ?

{temperature}°C

: null} +
+ {showTemperature && temperature !== null && temperature !== undefined ? ( + {temperature}°C + ) : null}
@@ -154,6 +165,7 @@ export default function SystemResources({ integrationIds, options, width, + height, displayMode: surfaceMode, }: WidgetComponentProps<"systemDisks">) { const queryInput = { integrationIds }; @@ -182,7 +194,9 @@ export default function SystemResources({ if (disks.length === 0) return ; const isAdvanced = surfaceMode === "advanced"; - const columns = isAdvanced ? Math.max(1, Math.min(disks.length, Math.floor(width / 320))) : 1; + const minimumCardWidth = isAdvanced ? 320 : 260; + const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth))); + const cellWidth = width / columns; return ( @@ -199,6 +213,7 @@ export default function SystemResources({ integrationName={isAdvanced || data.length > 1 ? integrationName : undefined} secondaryText={getAbsoluteText(item)} isAdvanced={isAdvanced} + showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)} /> ))} diff --git a/packages/widgets/src/system-resources/chart/common-chart.tsx b/packages/widgets/src/system-resources/chart/common-chart.tsx index f2f9f1d87..ffc10a45f 100644 --- a/packages/widgets/src/system-resources/chart/common-chart.tsx +++ b/packages/widgets/src/system-resources/chart/common-chart.tsx @@ -83,6 +83,13 @@ export const CommonChart = ({ )} )} + {data.length > 1 && height > 0 && height <= 40 && lastValue && !hovered && ( +
+ + {lastValue} + +
+ )} {data.length <= 1 ? (
@@ -92,6 +99,11 @@ export const CommonChart = ({ {title} )} + {lastValue && ( + 100 ? "md" : "xs"} fw={600} ta="center"> + {lastValue} + + )}
) : ( diff --git a/packages/widgets/src/system-resources/chart/memory-chart.tsx b/packages/widgets/src/system-resources/chart/memory-chart.tsx index b49ea81be..429ec7016 100644 --- a/packages/widgets/src/system-resources/chart/memory-chart.tsx +++ b/packages/widgets/src/system-resources/chart/memory-chart.tsx @@ -22,7 +22,7 @@ export const SystemResourceMemoryChart = ({ const t = useScopedI18n("widget.systemResources.card"); const percentageUsed = - memoryUsageOverTime.length > 0 + memoryUsageOverTime.length > 0 && totalCapacityInBytes > 0 ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion memoryUsageOverTime[memoryUsageOverTime.length - 1]! / totalCapacityInBytes : undefined; @@ -45,7 +45,8 @@ export const SystemResourceMemoryChart = ({ return ( - {memory.used} / {memory.available} ({Math.round((value / totalCapacityInBytes) * 100)}%) + {memory.used} / {memory.available} ( + {totalCapacityInBytes > 0 ? Math.round((value / totalCapacityInBytes) * 100) : 0}%) ); diff --git a/packages/widgets/src/system-resources/chart/network-traffic.tsx b/packages/widgets/src/system-resources/chart/network-traffic.tsx index fc3b644b6..5ea5bff9a 100644 --- a/packages/widgets/src/system-resources/chart/network-traffic.tsx +++ b/packages/widgets/src/system-resources/chart/network-traffic.tsx @@ -23,6 +23,7 @@ export const NetworkTrafficChart = ({ const max = Math.max(...usageOverTime); const upperBound = max + max * 0.2; + const latest = usageOverTime.at(-1) ?? 0; return ( items.flatMap((item) => (item.network === null ? [] : [item.network])); +export const getCompactChartBudget = (height: number): number => { + if (height < 140) return 1; + if (height < 260) return 2; + return 4; +}; + export default function SystemResources({ integrationIds, options, width, + height, displayMode, }: WidgetComponentProps<"systemResources">) { - const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ - integrationIds, - }); + const { + data = [], + dataUpdatedAt, + isPending, + } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); const isAdvanced = displayMode === "advanced"; const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]); const [historyByIntegration, setHistoryByIntegration] = useState>({}); @@ -83,7 +93,7 @@ export default function SystemResources({ const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1; const panelWidth = width / panelColumns; - const renderCharts = (entry: (typeof data)[number], availableWidth: number) => { + const renderCharts = (entry: (typeof data)[number], availableWidth: number, availableHeight: number) => { const currentItem = toChartItem(entry.healthInfo); const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem]; const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE); @@ -96,24 +106,37 @@ export default function SystemResources({ memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes} options={options} width={availableWidth} + height={availableHeight} isAdvanced={isAdvanced} showTitle={isAdvanced || data.length > 1} /> ); }; + if (isPending) { + return ( +
+ +
+ ); + } + + if (data.length === 0) return ; + if (!isAdvanced && data.length === 1 && data[0]) { return ( - {renderCharts(data[0], width)} + {renderCharts(data[0], width, Math.max(0, height - 20))} ); } + const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2))); + return ( - {data.map((entry) => renderCharts(entry, panelWidth))} + {data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))} ); @@ -125,6 +148,7 @@ interface SystemChartsProps { memoryCapacityInBytes: number; options: WidgetComponentProps<"systemResources">["options"]; width: number; + height: number; isAdvanced: boolean; showTitle: boolean; } @@ -135,26 +159,36 @@ const SystemCharts = ({ memoryCapacityInBytes, options, width, + height, isAdvanced, showTitle, }: SystemChartsProps) => { const networkItems = getNetworkHistory(items); - const showNetwork = networkItems.length > 0 && options.visibleCharts.includes("network"); - const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork); + const availableCharts = options.visibleCharts.filter((chart) => chart !== "network" || networkItems.length > 0); + const visibleCharts = isAdvanced ? availableCharts : availableCharts.slice(0, getCompactChartBudget(height)); + const showNetwork = visibleCharts.includes("network"); + const chartCount = visibleCharts.length; const chartColumns = isAdvanced && width >= 560 ? 2 : 1; const chartHeight = isAdvanced ? 180 - : `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`; + : `calc((100% - ${Math.max(0, chartCount - 1) * 10}px) / ${Math.max(1, chartCount)})`; return ( - + {showTitle && ( {integrationName} )} - - {options.visibleCharts.includes("cpu") && ( + + {chartCount === 0 && ( +
+ + — + +
+ )} + {visibleCharts.includes("cpu") && ( item.cpu)} @@ -163,7 +197,7 @@ const SystemCharts = ({ /> )} - {options.visibleCharts.includes("memory") && ( + {visibleCharts.includes("memory") && ( item.memory)} @@ -173,7 +207,7 @@ const SystemCharts = ({ /> )} - {options.visibleCharts.includes("gpu") && ( + {visibleCharts.includes("gpu") && ( item.gpu)} diff --git a/packages/widgets/src/system-resources/history.spec.ts b/packages/widgets/src/system-resources/history.spec.ts index bb766d492..f08be4208 100644 --- a/packages/widgets/src/system-resources/history.spec.ts +++ b/packages/widgets/src/system-resources/history.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "vitest"; -import { appendBoundedHistory, getNetworkHistory, toChartItem } from "./component"; +import { appendBoundedHistory, getCompactChartBudget, getNetworkHistory, toChartItem } from "./component"; describe("system resource history", () => { test("averages all GPU values", () => { @@ -30,4 +30,10 @@ describe("system resource history", () => { expect(getNetworkHistory([missing, available, missing])).toEqual([{ up: 12, down: 34 }]); }); + + test("limits compact charts to the available height", () => { + expect(getCompactChartBudget(100)).toBe(1); + expect(getCompactChartBudget(200)).toBe(2); + expect(getCompactChartBudget(300)).toBe(4); + }); }); diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx index 7a50c3f70..bc6b62d20 100644 --- a/packages/widgets/src/timetable/component.tsx +++ b/packages/widgets/src/timetable/component.tsx @@ -12,6 +12,7 @@ export default function TimetableWidget({ options, displayMode = "compact", width, + height, itemId, }: WidgetComponentProps<"timetable">) { const t = useScopedI18n("widget.timetable"); @@ -27,6 +28,7 @@ export default function TimetableWidget({ itemId={itemId} displayMode={displayMode} width={width} + height={height} /> ); } @@ -37,16 +39,22 @@ interface TimetableWidgetInnerProps { itemId?: string; displayMode: "compact" | "advanced"; width: number; + height: number; } -const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: TimetableWidgetInnerProps) => { - const { data: timetable, error } = clientApi.widget.timetable.getTimetable.useQuery({ +const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width, height }: TimetableWidgetInnerProps) => { + const { + data: timetable, + error, + isPending, + } = clientApi.widget.timetable.getTimetable.useQuery({ baseUrl, itemId, stationId: station.value, limit: displayMode === "advanced" ? 50 : 10, }); const t = useScopedI18n("widget.timetable"); + const tCommon = useScopedI18n("common"); if (error && timetable === undefined) throw error; @@ -54,10 +62,19 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: const staleTime = timetable ? dayjs(timetable.timestamp).format("HH:mm:ss") : undefined; const staleWarning = error && staleTime ? t("warning.stale", { time: staleTime }) : undefined; const compactStaleWarning = error && staleTime ? t("warning.staleCompact") : undefined; + const isAdvanced = displayMode === "advanced"; + const isDense = !isAdvanced && (width < 300 || height < 160); + const showTitle = isAdvanced || height >= 112; + const showLine = isAdvanced || width >= 200; + const showPlatform = isAdvanced || width >= 320; return ( - - {t("title", { station: station.label })} + + {showTitle && ( + + {t("title", { station: station.label })} + + )} {compactStaleWarning && displayMode === "compact" && ( @@ -73,9 +90,29 @@ const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: )} = 760 ? 2 : 1} spacing="xs"> - {entries.map((entry) => ( - - ))} + {isPending ? ( +
+ + {tCommon("action.loading")} + +
+ ) : entries.length > 0 ? ( + entries.map((entry) => ( + + )) + ) : ( +
+ + — + +
+ )}
{displayMode === "advanced" && timetable && ( @@ -95,11 +132,21 @@ interface TimetableEntryView { platform: { name: string; hasChanged: boolean } | null; } -function DepartureRow({ entry }: { entry: TimetableEntryView }) { +function DepartureRow({ + entry, + dense, + showLine, + showPlatform, +}: { + entry: TimetableEntryView; + dense: boolean; + showLine: boolean; + showPlatform: boolean; +}) { return ( - - {entry.line && ( + + {showLine && entry.line && ( )} - + {dayjs(entry.timestamp).format("HH:mm")}{" "} {entry.delay >= 1 && ( - + +{entry.delay}' )} - + {entry.location} - {entry.platform && ( - + {showPlatform && entry.platform && ( + {entry.platform.name} )} diff --git a/packages/widgets/src/tracearr/component.spec.ts b/packages/widgets/src/tracearr/component.spec.ts index 171dead78..e74b35a54 100644 --- a/packages/widgets/src/tracearr/component.spec.ts +++ b/packages/widgets/src/tracearr/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getAdvancedColumnWidth } from "./component"; +import { getAdvancedColumnWidth, getCompactSectionVisibility } from "./component"; describe("Tracearr advanced layout", () => { it("subtracts grid padding and the inter-column gap", () => { @@ -12,3 +12,29 @@ describe("Tracearr advanced layout", () => { expect(getAdvancedColumnWidth(799, false)).toBe(767); }); }); + +describe("Tracearr compact disclosure", () => { + it("keeps urgent violations visible in a short widget", () => { + expect( + getCompactSectionVisibility({ + height: 160, + showStreams: true, + showViolations: true, + hasViolations: true, + showRecentActivity: true, + }), + ).toEqual({ violations: true, recentActivity: false }); + }); + + it("reveals secondary history as height becomes available", () => { + expect( + getCompactSectionVisibility({ + height: 400, + showStreams: true, + showViolations: true, + hasViolations: false, + showRecentActivity: true, + }), + ).toEqual({ violations: true, recentActivity: true }); + }); +}); diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index 17f6df5bc..3992fe4f7 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -18,6 +18,23 @@ const ADVANCED_GRID_BREAKPOINT = 800; const ADVANCED_GRID_GAP_PX = 16; const ADVANCED_GRID_PADDING_PX = 16; +export const getCompactSectionVisibility = ({ + height, + showStreams, + showViolations, + hasViolations, + showRecentActivity, +}: { + height: number; + showStreams: boolean; + showViolations: boolean; + hasViolations: boolean; + showRecentActivity: boolean; +}) => ({ + violations: showViolations && (hasViolations || height >= 240 || !showStreams), + recentActivity: showRecentActivity && (height >= 360 || (!showStreams && (!showViolations || !hasViolations))), +}); + export default function TracearrWidget({ options, integrationIds, @@ -124,6 +141,13 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit); const violations = combined.violations?.data ?? []; const recentActivity = combined.recentActivity?.data ?? []; + const compactSections = getCompactSectionVisibility({ + height, + showStreams: options.showStreams, + showViolations: options.showViolations, + hasViolations: violations.length > 0, + showRecentActivity: options.showRecentActivity, + }); if (displayMode === "advanced") { const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; @@ -154,9 +178,9 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } {options.showStats && } + {compactSections.violations && } {options.showStreams && } - {options.showViolations && } - {options.showRecentActivity && } + {compactSections.recentActivity && } {noSectionsEnabled && ( {t("noSectionsEnabled")} diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx index e078fd6f2..982101350 100644 --- a/packages/widgets/src/tracearr/recent-activity-section.tsx +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -8,7 +8,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess return ( - + {t("recentActivity.title")} {sessions.length === 0 ? ( diff --git a/packages/widgets/src/tracearr/violations-section.tsx b/packages/widgets/src/tracearr/violations-section.tsx index 2ce675ed8..6d052dd59 100644 --- a/packages/widgets/src/tracearr/violations-section.tsx +++ b/packages/widgets/src/tracearr/violations-section.tsx @@ -9,7 +9,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[] return ( - + {t("violations.title")} {violations.length === 0 ? ( diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx index 42d2d013b..a5503583c 100644 --- a/packages/widgets/src/traefik/component.tsx +++ b/packages/widgets/src/traefik/component.tsx @@ -126,7 +126,7 @@ function TraefikWidgetContent({ ))}
- {options.showEntryPoints && combined.entryPoints.length > 0 && ( + {options.showEntryPoints && combined.entryPoints.length > 0 && (displayMode === "advanced" || height >= 340) && (
{dedupe(combined.entryPoints) .slice(0, getEntryPointLimit(width)) diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 428bacff7..1179fb4f6 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -43,10 +43,11 @@ export function UmamiContent({ displayMode, }: UmamiContentProps) { const t = useScopedI18n("widget.umami"); + const tCommon = useScopedI18n("common"); const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; - const { data: results = [] } = clientApi.widget.umami.getVisitorStats.useQuery( + const { data: results = [], isPending: isStatsPending } = clientApi.widget.umami.getVisitorStats.useQuery( { integrationIds, websiteId, timeFrame, eventName }, umamiQueryOptions, ); @@ -67,6 +68,15 @@ export function UmamiContent({ : undefined; const firstResult = results[0]; + if (isStatsPending) { + return ( + + + {tCommon("action.loading")} + + + ); + } if (!firstResult) { return ( @@ -93,6 +103,11 @@ export function UmamiContent({ const isOverlay = chartStyle === "overlay" && hasEventSeries; const overlayBarSize = 16; + const isAdvanced = displayMode === "advanced"; + const isDense = !isAdvanced && height < 120; + const showXAxis = isAdvanced || height >= 140; + const showSecondaryStats = isAdvanced || height >= 96; + const showDetailedStats = isAdvanced || (width >= 260 && height >= 150); const selectedView = viewMode === "events" ? ( @@ -102,6 +117,7 @@ export function UmamiContent({ timeFrame={timeFrame} eventNames={eventNames} chartType={chartType} + showXAxis={showXAxis} /> ) : viewMode === "topPages" ? ( @@ -143,7 +159,7 @@ export function UmamiContent({ gridAxis="none" withLegend={false} withTooltip - withXAxis + withXAxis={showXAxis} withYAxis={false} barProps={isOverlay ? { barSize: overlayBarSize, radius: 2 } : { radius: 2 }} barChartProps={isOverlay ? { barGap: -overlayBarSize } : undefined} @@ -190,7 +206,7 @@ export function UmamiContent({ ); return ( - + {visitorStats.domain} ({formatTimeFrameLabel(timeFrame, t)}) @@ -231,27 +247,29 @@ export function UmamiContent({ )} - - - ● {activeVisitors.toLocaleString()} {t("active")} - - - {visitorStats.totalPageviews.toLocaleString()} {t("pageviews")} - - - {visitorStats.totalVisits.toLocaleString()} {t("visits")} - - {(displayMode === "advanced" || width >= 260) && ( - <> - - {visitorStats.bounceRate}% {t("bounceRate")} - - - {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")} - - - )} - + {showSecondaryStats && ( + + + ● {activeVisitors.toLocaleString()} {t("active")} + + + {visitorStats.totalPageviews.toLocaleString()} {t("pageviews")} + + + {visitorStats.totalVisits.toLocaleString()} {t("visits")} + + {showDetailedStats && ( + <> + + {visitorStats.bounceRate}% {t("bounceRate")} + + + {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")} + + + )} + + )} {displayMode === "advanced" ? advancedContent : selectedView} diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index ef91df422..e503df97a 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -15,6 +15,7 @@ interface UmamiEventsContentProps { timeFrame: string; eventNames: string[]; chartType: string; + showXAxis: boolean; } export function UmamiEventsContent({ @@ -23,6 +24,7 @@ export function UmamiEventsContent({ timeFrame, eventNames, chartType, + showXAxis, }: UmamiEventsContentProps) { const t = useScopedI18n("widget.umami"); const { colorScheme } = useMantineColorScheme(); @@ -104,7 +106,7 @@ export function UmamiEventsContent({ gridAxis="none" withLegend={false} withTooltip - withXAxis + withXAxis={showXAxis} withYAxis={false} xAxisProps={{ tick: { fontSize: 9, fill: tickColor }, @@ -121,7 +123,7 @@ export function UmamiEventsContent({ gridAxis="none" withLegend={false} withTooltip - withXAxis + withXAxis={showXAxis} withYAxis={false} barProps={{ radius: 2 }} xAxisProps={{ diff --git a/packages/widgets/src/uptime-kuma/aggregate.spec.ts b/packages/widgets/src/uptime-kuma/aggregate.spec.ts index c4aaf9f18..4982f5331 100644 --- a/packages/widgets/src/uptime-kuma/aggregate.spec.ts +++ b/packages/widgets/src/uptime-kuma/aggregate.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { aggregateUptimeKumaDashboards } from "./aggregate"; +import { getCompactStatLimit, getCompactStatPriority } from "./component"; describe("aggregateUptimeKumaDashboards", () => { it("weights uptime by monitors instead of integrations", () => { @@ -29,3 +30,26 @@ describe("aggregateUptimeKumaDashboards", () => { expect(combined.averageUptimePercent).toBe(25); }); }); + +describe("getCompactStatLimit", () => { + it("reserves compact height for the uptime summary", () => { + expect(getCompactStatLimit(150, true, 4)).toBe(0); + expect(getCompactStatLimit(220, true, 4)).toBe(2); + }); + + it("uses the space for metrics when the summary is disabled", () => { + expect(getCompactStatLimit(100, false, 4)).toBe(2); + expect(getCompactStatLimit(240, false, 4)).toBe(4); + }); +}); + +describe("getCompactStatPriority", () => { + it("puts active failures before healthy totals", () => { + expect(getCompactStatPriority("downCount", 2)).toBeLessThan(getCompactStatPriority("totalMonitors", 10)); + expect(getCompactStatPriority("pausedCount", 1)).toBeLessThan(getCompactStatPriority("upCount", 8)); + }); + + it("moves zero-value problem states behind healthy totals", () => { + expect(getCompactStatPriority("downCount", 0)).toBeGreaterThan(getCompactStatPriority("upCount", 8)); + }); +}); diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index efe60c72c..838ce7eaa 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -76,6 +76,25 @@ const heroVariantByRing = { false: classes.heroTextOnly, } as const; +export function getCompactStatLimit(height: number, showHero: boolean, statCount: number): number { + if (showHero) { + if (height < 180) return 0; + if (height < 260) return Math.min(2, statCount); + if (height < 340) return Math.min(4, statCount); + } else { + if (height < 130) return Math.min(2, statCount); + if (height < 220) return Math.min(4, statCount); + } + return statCount; +} + +export function getCompactStatPriority(statKey: keyof typeof statIcons, value: number): number { + if (statKey === "downCount") return value > 0 ? 0 : 4; + if (statKey === "pausedCount") return value > 0 ? 1 : 5; + if (statKey === "totalMonitors") return 2; + return 3; +} + export default function UptimeKumaWidget({ integrationIds, options, @@ -126,16 +145,30 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode const iconSize = getIconSize(width); const gridCols = getGridCols(width); - const visibleStatKeys = Object.entries(statVisibilityByOption) + const enabledStatKeys = Object.entries(statVisibilityByOption) .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); const showHero = options.showAverageUptime; + const showRing = options.showUptimeRing && (displayMode === "advanced" || (width >= 170 && height >= 110)); + const prioritizedStatKeys = + displayMode === "advanced" + ? enabledStatKeys + : enabledStatKeys.toSorted( + (left, right) => + getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), + ); + const visibleStatKeys = prioritizedStatKeys.slice( + 0, + displayMode === "advanced" + ? prioritizedStatKeys.length + : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), + ); const hasContent = showHero || visibleStatKeys.length > 0; const heroLayoutClass = heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats]; - const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing]; + const heroRingClass = heroVariantByRing[String(showRing) as keyof typeof heroVariantByRing]; const summaryContent = (
@@ -145,7 +178,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode {t("averageUptime")} {formatNumber(uptimeValue, 1)}%
- {options.showUptimeRing && ( + {showRing && ( )} - {displayMode === "compact" && height >= 240 && combined.downCount > 0 && ( + {displayMode === "compact" && height >= 300 && combined.downCount > 0 && ( {monitors .filter((monitor) => monitor.status === "down") diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx index 4d43bc302..295499d78 100644 --- a/packages/widgets/src/vpn/component.tsx +++ b/packages/widgets/src/vpn/component.tsx @@ -1,8 +1,9 @@ "use client"; -import { Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { VpnIntegrationCard } from "./vpn-card"; @@ -11,12 +12,26 @@ export default function VpnWidget({ options, integrationIds, width, + height, displayMode = "compact", }: WidgetComponentProps<"vpn">) { - const { data: integrations = [] } = clientApi.widget.vpn.getSummaries.useQuery({ + const { data: integrations = [], isPending } = clientApi.widget.vpn.getSummaries.useQuery({ ...options, integrationIds, }); + const t = useScopedI18n("widget.vpn"); + const tCommon = useScopedI18n("common"); + const dense = displayMode === "compact" && (width < 240 || height < 120); + + if (isPending || integrations.length === 0) { + return ( +
+ + {isPending ? tCommon("action.loading") : t("serviceUnavailable")} + +
+ ); + } const [vpn] = integrations; if (displayMode === "compact" && integrations.length === 1 && vpn) { @@ -26,6 +41,7 @@ export default function VpnWidget({ vpn={vpn.summary} integrationName={vpn.integration.name} variant={width < 240 ? "list" : "single"} + dense={dense} /> ); @@ -59,6 +75,7 @@ export default function VpnWidget({ vpn={result.summary} integrationName={result.integration.name} variant="list" + dense={dense} /> ))}
diff --git a/packages/widgets/src/vpn/vpn-card.tsx b/packages/widgets/src/vpn/vpn-card.tsx index 3cb26bdd5..8ddcfd7fd 100644 --- a/packages/widgets/src/vpn/vpn-card.tsx +++ b/packages/widgets/src/vpn/vpn-card.tsx @@ -1,8 +1,15 @@ "use client"; import React from "react"; -import { Badge, Box, Card, Flex, Group, Text } from "@mantine/core"; -import { IconArrowsExchange, IconMapPin, IconShieldCheck, IconShieldX } from "@tabler/icons-react"; +import { Badge, Box, Card, Flex, Group, Text, VisuallyHidden } from "@mantine/core"; +import { + IconArrowsExchange, + IconCircleCheckFilled, + IconCircleXFilled, + IconMapPin, + IconShieldCheck, + IconShieldX, +} from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { useRequiredBoard } from "@homarr/boards/context"; @@ -16,17 +23,19 @@ export function VpnIntegrationCard({ vpn, integrationName, variant = "single", + dense = false, }: { vpn: VpnInfo; integrationName?: string; variant?: "single" | "list"; + dense?: boolean; }) { const board = useRequiredBoard(); const compact = variant === "list"; const content = vpn ? ( - + ) : ( - + ); if (compact) { return ( - + {content} ); @@ -52,20 +62,28 @@ export function VpnIntegrationCard({ return {content}; } -function VpnUnavailableContent({ compact, integrationName }: { compact: boolean; integrationName?: string }) { +function VpnUnavailableContent({ + compact, + dense, + integrationName, +}: { + compact: boolean; + dense: boolean; + integrationName?: string; +}) { const t = useScopedI18n("widget.vpn"); return ( {/* Empty statuses render the shield and DNS badge in red (see getStatusColor). */} - + {integrationName ? ( {integrationName} ) : null} - + {t("serviceUnavailable")} @@ -77,17 +95,30 @@ function VpnStatusColumn({ vpnStatus, dnsStatus, compact, + dense, }: { vpnStatus: string; dnsStatus: string; compact: boolean; + dense: boolean; }) { // Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross. const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX; return ( - - + + ); @@ -101,6 +132,7 @@ function VpnInfoColumn({ provider, protocol, compact, + dense, }: { integrationName?: string; publicIp: string; @@ -109,6 +141,7 @@ function VpnInfoColumn({ provider: string; protocol: string; compact: boolean; + dense: boolean; }) { return ( @@ -117,16 +150,18 @@ function VpnInfoColumn({ {integrationName} ) : null} - - {publicIp} + + {publicIp || "—"} - - - - {city}, {country} - - - + {!dense && (city || country) && ( + + + + {[city, country].filter(Boolean).join(", ")} + + + )} + {!dense && (provider || protocol) && } ); } @@ -145,20 +180,18 @@ function VpnProviderDetails({ provider, protocol }: { provider: string; protocol ); } -function StatusDot({ status, size = 10 }: { status: string; size?: number }) { - return ; -} - function DnsStatusBadge({ status }: { status: string }) { + const StatusIcon = status === RUNNING_STATUS ? IconCircleCheckFilled : IconCircleXFilled; return ( } + leftSection={} > DNS + {status || "unavailable"} ); } diff --git a/packages/widgets/src/weather/component.spec.ts b/packages/widgets/src/weather/component.spec.ts index 54f8f6a56..44bfe8847 100644 --- a/packages/widgets/src/weather/component.spec.ts +++ b/packages/widgets/src/weather/component.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { getPreferredUnit, getVisibleForecastDays } from "./component"; +import { getPreferredTime, getPreferredUnit, getPreferredWindSpeed, getVisibleForecastDays } from "./component"; describe("weather formatting", () => { it("preserves valid zero temperatures", () => { @@ -8,9 +8,15 @@ describe("weather formatting", () => { expect(getPreferredUnit(0, true, true)).toBe("32°F"); }); + it("keeps zero wind speeds and missing forecast values truthful", () => { + expect(getPreferredWindSpeed(0)).toBe("0.0"); + expect(getPreferredWindSpeed(undefined)).toBe("?"); + expect(getPreferredTime(undefined)).toBe("?"); + expect(getPreferredTime("invalid")).toBe("?"); + }); + it("limits compact forecasts by both axes", () => { - expect(getVisibleForecastDays(7, 120, 100, false)).toBe(2); - expect(getVisibleForecastDays(7, 600, 100, false)).toBe(3); - expect(getVisibleForecastDays(7, 120, 100, true)).toBe(7); + expect(getVisibleForecastDays(7, 120, 100)).toBe(2); + expect(getVisibleForecastDays(7, 600, 100)).toBe(3); }); }); diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 4b3c6c0bc..59f16347e 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,7 +1,16 @@ "use client"; -import { Box, Group, HoverCard, Stack, Text } from "@mantine/core"; -import { IconArrowDownRight, IconArrowUpRight, IconDroplets, IconMapPin, IconWind } from "@tabler/icons-react"; +import { useId } from "react"; +import { Box, Group, HoverCard, Stack, Table, Text } from "@mantine/core"; +import { + IconArrowDownRight, + IconArrowUpRight, + IconDroplets, + IconMapPin, + IconSunrise, + IconSunset, + IconWind, +} from "@tabler/icons-react"; import combineClasses from "clsx"; import dayjs from "dayjs"; @@ -30,6 +39,10 @@ export default function WeatherWidget({ if (!weather) return ; + if (displayMode === "advanced") { + return ; + } + return ( ) : ( - + )} ); @@ -57,7 +69,172 @@ interface WeatherProps extends Pick, "options"> weather: RouterOutputs["widget"]["weather"]["atLocation"]; } -const DailyWeather = ({ options, weather, showDetails = false }: WeatherProps & { showDetails?: boolean }) => { +const AdvancedWeather = ({ options, weather }: WeatherProps) => { + const forecastHeadingId = useId(); + const t = useScopedI18n("widget.weather"); + const tCommon = useScopedI18n("common"); + + return ( + + + + + + + {getPreferredUnit( + weather.current.temperature, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + + + + + + + {options.location.name} + + + + + {t("currentWindSpeed", { + currentWindSpeed: getPreferredWindSpeed(weather.current.windspeed, options.useImperialSpeed), + unit: options.useImperialSpeed + ? tCommon("unit.speed.milesPerHour") + : tCommon("unit.speed.kilometersPerHour"), + })} + + + + + + + {t("advanced.forecast")} + + + + + + {t("advanced.day")} + {t("advanced.conditions")} + {t("advanced.temperature")} + {t("advanced.humidity")} + + {t("advanced.wind", { + unit: options.useImperialSpeed + ? tCommon("unit.speed.milesPerHour") + : tCommon("unit.speed.kilometersPerHour"), + })} + + {t("advanced.sun")} + + + + {weather.daily.length === 0 ? ( + + + + {t("advanced.noForecast")} + + + + ) : ( + weather.daily.map((dayWeather) => ( + + + + + {dayjs(dayWeather.time).format("ddd")} + + + {dayjs(dayWeather.time).format("MMM D")} + + + + + + + + + + + + + + {getPreferredUnit( + dayWeather.maxTemp, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + / + + + + {getPreferredUnit( + dayWeather.minTemp, + options.isFormatFahrenheit, + options.disableTemperatureDecimals, + )} + + + + + + + {dayWeather.humidity === undefined ? "?" : `${dayWeather.humidity}%`} + + + + + + + {getPreferredWindSpeed(dayWeather.maxWindSpeed, options.useImperialSpeed)} /{" "} + {getPreferredWindSpeed(dayWeather.maxWindGusts, options.useImperialSpeed)} + + + + + + + + {getPreferredTime(dayWeather.sunrise)} + + + / + + + + {getPreferredTime(dayWeather.sunset)} + + + + + )) + )} + +
+
+
+ ); +}; + +const DailyWeather = ({ options, weather }: WeatherProps) => { const t = useScopedI18n("widget.weather"); const tCommon = useScopedI18n("common"); @@ -138,39 +315,11 @@ const DailyWeather = ({ options, weather, showDetails = false }: WeatherProps & )} - {showDetails && weather.daily[0] && ( - - )} ); }; -const WeeklyForecast = ({ - options, - weather, - maxDays, - showDetails, -}: WeatherProps & { maxDays: number; showDetails: boolean }) => { +const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: number }) => { return ( <> @@ -202,17 +351,12 @@ const WeeklyForecast = ({ - + ); }; -function Forecast({ - weather, - options, - maxDays, - showDetails, -}: WeatherProps & { maxDays: number; showDetails: boolean }) { +function Forecast({ weather, options, maxDays }: WeatherProps & { maxDays: number }) { const dateFormat = options.dateFormat; return ( @@ -233,11 +377,6 @@ function Forecast({ {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - {showDetails && ( - - {getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - - )}
@@ -276,13 +415,16 @@ export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTe : `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C` : "?"; -export const getVisibleForecastDays = ( - configuredDays: number, - width: number, - height: number, - isAdvanced: boolean, -): number => { - if (isAdvanced) return configuredDays; +export const getPreferredWindSpeed = (value: number | undefined, useImperialSpeed = false): string => + value === undefined ? "?" : (useImperialSpeed ? metricToImperial(value) : value).toFixed(1); + +export const getPreferredTime = (value: string | undefined): string => { + if (!value) return "?"; + const time = dayjs(value); + return time.isValid() ? time.format("HH:mm") : "?"; +}; + +export const getVisibleForecastDays = (configuredDays: number, width: number, height: number): number => { const widthBudget = Math.max(1, Math.floor(width / 58)); const heightBudget = height < 120 ? 3 : configuredDays; return Math.min(configuredDays, widthBudget, heightBudget); From 7b7045202d289b7d83946befcee5d2845ea4a706 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 14:35:01 +0200 Subject: [PATCH 14/75] fix: address widget review findings --- apps/docs/docs/widgets/weather/index.mdx | 1 + .../integration/integration-grid-shared.ts | 1 + packages/translation/src/lang/en.json | 7 ++ .../widgets/src/anchor-note/component.tsx | 10 +- .../beszel-system-stats/component.module.css | 2 +- packages/widgets/src/calendar/component.tsx | 41 +++++-- .../src/common/action-target.module.css | 11 ++ packages/widgets/src/coolify/resource-row.tsx | 3 + .../widgets/src/coolify/servers-section.tsx | 2 + packages/widgets/src/custom-api/component.tsx | 13 ++- .../src/dns-hole/controls/component.tsx | 6 +- packages/widgets/src/docker/component.tsx | 3 + .../system-health.module.css | 2 +- .../src/health-monitoring/system-health.tsx | 3 +- .../src/indexer-manager/component.module.css | 2 +- .../widgets/src/indexer-manager/component.tsx | 3 +- .../media-requests/list/component.module.css | 9 +- .../src/media-requests/list/component.tsx | 7 +- .../media-requests/search-button.module.css | 6 +- .../src/media-requests/stats/component.tsx | 3 +- .../media-transcoding/health-check-status.tsx | 4 +- packages/widgets/src/notebook/notebook.tsx | 45 ++++---- packages/widgets/src/releases/component.tsx | 18 +++- packages/widgets/src/stocks/component.tsx | 6 +- .../widgets/src/weather/component.module.css | 31 ++++++ packages/widgets/src/weather/component.tsx | 102 ++++++++++-------- 26 files changed, 247 insertions(+), 94 deletions(-) create mode 100644 packages/widgets/src/common/action-target.module.css create mode 100644 packages/widgets/src/weather/component.module.css diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index a4b84b5fa..f36dc68ea 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -14,6 +14,7 @@ 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. diff --git a/apps/nextjs/src/components/integration/integration-grid-shared.ts b/apps/nextjs/src/components/integration/integration-grid-shared.ts index 7451af1e7..9b4c6afb6 100644 --- a/apps/nextjs/src/components/integration/integration-grid-shared.ts +++ b/apps/nextjs/src/components/integration/integration-grid-shared.ts @@ -45,6 +45,7 @@ export const categoryTranslationKeys: Record = { smartHomeServer: "integration.category.smartHomeServer", indexerManager: "integration.category.indexerManager", healthMonitoring: "integration.category.healthMonitoring", + beszel: "integration.category.beszel", search: "integration.category.search", mediaTranscoding: "integration.category.mediaTranscoding", networkController: "integration.category.networkController", diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index badfef807..16f33874c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1012,6 +1012,7 @@ "smartHomeServer": "Smart Home", "indexerManager": "Indexer", "healthMonitoring": "Health", + "beszel": "Beszel", "search": "Search", "mediaTranscoding": "Transcoding", "networkController": "Network", @@ -2299,6 +2300,7 @@ "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" }, "option": { @@ -2357,6 +2359,8 @@ } }, "currentWindSpeed": "{currentWindSpeed} {unit}", + "details": "Show weather details", + "detailsFor": "Show weather details for {date}", "advanced": { "forecast": "7-day forecast", "day": "Day", @@ -3684,6 +3688,8 @@ "starsCount": "Stars", "forksCount": "Forks", "issuesCount": "Open Issues", + "expandDetails": "Show details for {name}", + "collapseDetails": "Hide details for {name}", "markViewed": "Mark as viewed", "openProjectPage": "Open Project Page", "openReleasePage": "Open Release Page", @@ -4087,6 +4093,7 @@ "executeSuccess": "Action executed successfully", "executeFailed": "Action failed. Check the widget configuration.", "actions": { + "openJsonViewer": "Open in browser JSON viewer", "pausePolling": "Pause polling", "resumePolling": "Resume polling", "togglePolling": "Pause or resume polling" diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 9268bf6f4..24e4de866 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -18,6 +18,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; +import actionTargetClasses from "../common/action-target.module.css"; import "react-quill-new/dist/quill.snow.css"; import "./anchor-note.css"; @@ -353,7 +354,14 @@ const AnchorNoteWidgetContent = ({ ) : ( - + diff --git a/packages/widgets/src/beszel-system-stats/component.module.css b/packages/widgets/src/beszel-system-stats/component.module.css index 9d8b2087b..9966f37be 100644 --- a/packages/widgets/src/beszel-system-stats/component.module.css +++ b/packages/widgets/src/beszel-system-stats/component.module.css @@ -14,7 +14,7 @@ pointer-events: auto; } -@media (hover: none) { +@media (any-pointer: coarse) { .beszelStatsSystemToggle { opacity: 1; pointer-events: auto; diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index c4cee758a..614916993 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import { ActionIcon, Box, Group, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core"; +import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import { IconChevronLeft, IconChevronRight } from "@tabler/icons-react"; @@ -15,6 +15,7 @@ import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; import { useScopedI18n } from "@homarr/translation/client"; +import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; import { setWidgetRuntimeQueries } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; @@ -34,7 +35,16 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">) if (props.integrationIds.length === 0) { setWidgetRuntimeQueries(props.widgetStateRef, []); - return ; + return ( + + ); } return ; @@ -62,7 +72,7 @@ const FetchCalendar = ({ showUnmonitored: options.showUnmonitored, }; setWidgetRuntimeQueries(widgetStateRef, [getQueryKey(clientApi.widget.calendar.findAllEvents, input, "query")]); - const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); + const { data, isPending } = clientApi.widget.calendar.findAllEvents.useQuery(input); const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); const failedIntegrations = @@ -75,6 +85,7 @@ const FetchCalendar = ({ isEditMode={isEditMode} events={events} failedIntegrations={failedIntegrations} + isPending={isPending} month={month} setMonth={setMonth} options={options} @@ -87,6 +98,7 @@ interface CalendarBaseProps { isEditMode: boolean; events: CalendarEvent[]; failedIntegrations: { integrationId: string; integrationName: string; error: string }[]; + isPending: boolean; month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; @@ -97,6 +109,7 @@ const CalendarBase = ({ isEditMode, events, failedIntegrations, + isPending, month, setMonth, options, @@ -133,6 +146,7 @@ const CalendarBase = ({ void; } -const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month, setMonth }: CalendarAgendaProps) => { +const CalendarAgenda = ({ + events, + failedIntegrations, + isPending, + isEditMode, + locale, + month, + setMonth, +}: CalendarAgendaProps) => { const t = useScopedI18n("widget.calendar.advanced"); const monthLabel = new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month); @@ -243,6 +266,7 @@ const CalendarAgenda = ({ events, failedIntegrations, isEditMode, locale, month, - {t("eventCount", { count: events.length })} + {isPending ? t("loading") : t("eventCount", { count: events.length })}
- {events.length > 0 ? ( + {isPending ? ( +
+ +
+ ) : events.length > 0 ? ( ) : ( diff --git a/packages/widgets/src/common/action-target.module.css b/packages/widgets/src/common/action-target.module.css new file mode 100644 index 000000000..28f6cc672 --- /dev/null +++ b/packages/widgets/src/common/action-target.module.css @@ -0,0 +1,11 @@ +.root { + min-width: 24px; + min-height: 24px; +} + +@media (any-pointer: coarse) { + .root { + min-width: 44px; + min-height: 44px; + } +} diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 4aaa4b625..fba5279ca 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; import { IconFileText, IconLink } from "@tabler/icons-react"; +import actionTargetClasses from "../common/action-target.module.css"; import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; interface ResourceRowProps { @@ -58,6 +59,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow {cleanFqdn(item.fqdn) && ( import("./custom-jsx-display"), { ssr: false }); @@ -343,14 +344,21 @@ function openJsonInBrowser(json: unknown) { } function RawDisplay({ data, displayMode, height }: CustomDisplayProps) { + const t = useScopedI18n("widget.customApi"); const maxHeight = displayMode === "advanced" ? Math.max(160, height - 80) : ((data.maxHeight as number) ?? 300); const jsonString = JSON.stringify(data.data, null, 2); return ( - - openJsonInBrowser(data.data)}> + + openJsonInBrowser(data.data)} + > @@ -595,6 +603,7 @@ function CustomApiWidgetInner({ {dataType !== "actionButton" && ( = ({ {!isEnabled ? ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} @@ -364,6 +366,7 @@ const ControlsCard: React.FC = ({ ) : ( void toggleDns(data.integration.id).catch(() => undefined)} disabled={!controlEnabled} @@ -375,6 +378,7 @@ const ControlsCard: React.FC = ({ )} { setSelectedIntegrationIds([data.integration.id]); @@ -440,7 +444,7 @@ const ControlsCard: React.FC = ({ {layout === "md" && ( - + diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 53a334189..cede6ffe4 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -68,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; +import actionTargetClasses from "../common/action-target.module.css"; import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item"; import { NotebookTextDirection, setTextDirection } from "./text-direction"; @@ -526,18 +527,12 @@ export function Notebook({ )} {canChange && ( - <> + {isEditing && ( )} - + )}
); @@ -746,13 +735,31 @@ const ColorControl = ({ defaultColor, getCurrent, update, icon: Icon, ariaLabel - + - + - + diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx index 2c7470875..3481f6172 100644 --- a/packages/widgets/src/releases/component.tsx +++ b/packages/widgets/src/releases/component.tsx @@ -221,6 +221,10 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }: })} onClick={() => toggleExpandedDisplay(repository)} aria-expanded={isActive} + aria-label={t(isActive ? "collapseDetails" : "expandDetails", { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: repository.name || repository.identifier, + })} > {(options.showDetails || isAdvanced) && ( - + )} {isActive && ( void; } -const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayProps) => { +const DetailsDisplay = ({ repository, isExpanded, toggleExpandedDisplay }: DetailsDisplayProps) => { const t = useScopedI18n("widget.releases"); const formatter = useFormatter(); @@ -355,6 +364,11 @@ const DetailsDisplay = ({ repository, toggleExpandedDisplay }: DetailsDisplayPro toggleExpandedDisplay(repository)} + aria-expanded={isExpanded} + aria-label={t(isExpanded ? "collapseDetails" : "expandDetails", { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + name: repository.name || repository.identifier, + })} > diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index 4fee07497..d83f409d0 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -2,7 +2,7 @@ import { Sparkline } from "@mantine/charts"; import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core"; -import { IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"; +import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; @@ -105,8 +105,10 @@ export default function StockPriceWidget({ options, width, height, displayMode } {stockValuesChange > 0 ? ( - ) : ( + ) : stockValuesChange < 0 ? ( + ) : ( + )} {data.symbol} diff --git a/packages/widgets/src/weather/component.module.css b/packages/widgets/src/weather/component.module.css new file mode 100644 index 000000000..4b7b623be --- /dev/null +++ b/packages/widgets/src/weather/component.module.css @@ -0,0 +1,31 @@ +.detailsButton, +.forecastButton { + color: inherit; + border-radius: var(--mantine-radius-sm); +} + +.detailsButton { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + min-height: 24px; +} + +.forecastButton { + display: block; +} + +.detailsButton:focus-visible, +.forecastButton:focus-visible { + outline: 2px solid var(--mantine-primary-color-filled); + outline-offset: 2px; +} + +@media (any-pointer: coarse) { + .detailsButton, + .forecastButton { + min-width: 44px; + min-height: 44px; + } +} diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 59f16347e..249c09f3c 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useId } from "react"; -import { Box, Group, HoverCard, Stack, Table, Text } from "@mantine/core"; +import { Group, Popover, Stack, Table, Text, UnstyledButton } from "@mantine/core"; import { IconArrowDownRight, IconArrowUpRight, @@ -23,6 +23,7 @@ import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { AnimatedWeatherIcon } from "./animated-icon"; import { WeatherDescription } from "./icon"; +import classes from "./component.module.css"; export default function WeatherWidget({ isEditMode, @@ -56,10 +57,11 @@ export default function WeatherWidget({ ) : ( - + )} ); @@ -69,6 +71,10 @@ interface WeatherProps extends Pick, "options"> weather: RouterOutputs["widget"]["weather"]["atLocation"]; } +interface CompactWeatherProps extends WeatherProps { + isEditMode: boolean; +} + const AdvancedWeather = ({ options, weather }: WeatherProps) => { const forecastHeadingId = useId(); const t = useScopedI18n("widget.weather"); @@ -234,23 +240,23 @@ const AdvancedWeather = ({ options, weather }: WeatherProps) => { ); }; -const DailyWeather = ({ options, weather }: WeatherProps) => { +const DailyWeather = ({ options, weather, isEditMode }: CompactWeatherProps) => { const t = useScopedI18n("widget.weather"); const tCommon = useScopedI18n("common"); return ( <> - - - + + + - - - + + + - - + + {getPreferredUnit( weather.current.temperature, @@ -319,7 +325,9 @@ const DailyWeather = ({ options, weather }: WeatherProps) => { ); }; -const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: number }) => { +const WeeklyForecast = ({ options, weather, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) => { + const t = useScopedI18n("widget.weather"); + return ( <> @@ -332,16 +340,16 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: )} - - - + + + - - - + + + - - + + {getPreferredUnit( weather.current.temperature, @@ -351,35 +359,45 @@ const WeeklyForecast = ({ options, weather, maxDays }: WeatherProps & { maxDays: - + ); }; -function Forecast({ weather, options, maxDays }: WeatherProps & { maxDays: number }) { +function Forecast({ weather, options, isEditMode, maxDays }: CompactWeatherProps & { maxDays: number }) { const dateFormat = options.dateFormat; + const t = useScopedI18n("widget.weather"); return ( {weather.daily.slice(0, maxDays).map((dayWeather, index) => ( - - - + + - {dayjs(dayWeather.time).format("dd")} - - - {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} - - - - + + + {dayjs(dayWeather.time).format("dd")} + + + + {getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} + + + + + - - + + ))} ); From d5c3aebd2665e5215103543a1c8992a9cb5771cc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:10:16 +0200 Subject: [PATCH 15/75] fix: harden widget review follow-ups --- .../src/components/board/items/item-content.tsx | 5 +++-- e2e/custom-widgets-custom-jsx.spec.ts | 13 +++++++------ packages/translation/src/lang/en.json | 12 ++++++++++++ packages/widgets/src/coolify/resource-row.tsx | 7 +++++-- .../widgets/src/coolify/servers-section.tsx | 2 +- packages/widgets/src/custom-api/component.tsx | 10 +++++----- .../network-status/component.tsx | 17 ++++++++++++----- .../widgets/src/paperless-ngx/component.tsx | 7 ++++++- packages/widgets/src/vpn/component.tsx | 5 +++-- packages/widgets/src/vpn/vpn-card.tsx | 12 ++++++++++-- 10 files changed, 64 insertions(+), 26 deletions(-) diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 969195d60..a572cdbad 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -157,8 +157,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { height: advancedRect.height, "--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`, "--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`, - "--focus-scale-x": activeFocus.sourceRect.width / advancedRect.width, - "--focus-scale-y": activeFocus.sourceRect.height / advancedRect.height, + "--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 } diff --git a/e2e/custom-widgets-custom-jsx.spec.ts b/e2e/custom-widgets-custom-jsx.spec.ts index cba6c0d75..c1f7743ba 100644 --- a/e2e/custom-widgets-custom-jsx.spec.ts +++ b/e2e/custom-widgets-custom-jsx.spec.ts @@ -40,16 +40,17 @@ describe("Custom JSX custom widgets", () => { const browser = await chromium.launch(); const context = await browser.newContext(); const page = await context.newPage(); + const destination = "/manage/custom-widgets/new"; try { - await page.goto(`${baseUrl}/auth/login`); + await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`); await page.getByLabel("Username").fill(adminCredentials.username); await page.locator("#password").fill(adminCredentials.password); - await page.locator("css=button[type='submit']").click(); - await page.waitForURL(baseUrl, { timeout: 15_000 }); - - await page.goto(`${baseUrl}/manage/custom-widgets/new`); - await page.waitForURL("**/manage/custom-widgets/new", { timeout: 15_000 }); + await Promise.all([ + page.waitForURL(`**${destination}`, { timeout: 30_000 }), + page.locator("css=button[type='submit']").click(), + ]); + await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 }); await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX"); await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`); await page.getByRole("combobox", { name: "Display Type" }).click(); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 16f33874c..9151de77c 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2567,6 +2567,12 @@ "label": "Show services" } }, + "action": { + "openResource": "Open {name}", + "openLogs": "Open {name} logs", + "showIp": "Show IP address", + "hideIp": "Hide IP address" + }, "empty": { "servers": "No servers found", "applications": "No applications found", @@ -2590,6 +2596,12 @@ "name": "VPN", "description": "Overview of your VPN connections", "serviceUnavailable": "VPN unavailable", + "status": { + "running": "Running", + "notRunning": "Not running", + "unavailable": "Unavailable", + "vpn": "VPN: {status}" + }, "option": {} }, "ups": { diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index fba5279ca..44ca137a1 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -3,6 +3,8 @@ import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; import { IconFileText, IconLink } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + import actionTargetClasses from "../common/action-target.module.css"; import { cleanFqdn, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils"; @@ -25,6 +27,7 @@ interface ResourceRowProps { } export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRowProps) { + const t = useScopedI18n("widget.coolify"); const status = parseStatus(item.status ?? ""); const statusColor = getStatusColor(status); @@ -64,7 +67,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow href={cleanFqdn(item.fqdn)} target="_blank" rel="noopener noreferrer" - aria-label={`Open ${item.name}`} + aria-label={t("action.openResource", { name: item.name })} size="xs" variant="subtle" c="dimmed" @@ -79,7 +82,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow href={logsUrl} target="_blank" rel="noopener noreferrer" - aria-label={`Open ${item.name} logs`} + aria-label={t("action.openLogs", { name: item.name })} size="xs" variant="subtle" c="dimmed" diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 6a5da6ef4..24f82852a 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -42,7 +42,7 @@ export function ServersSection({ ; const dataType = widgetData.type as string | undefined; const canTogglePolling = Boolean(data) && dataType !== "actionButton" && dataType !== "disabled"; - const togglePolling = useCallback(() => setPollingPaused((value) => !value), []); + const togglePolling = useCallback(() => { + if (pollingPaused) void refetch(); + setPollingPaused((value) => !value); + }, [pollingPaused, refetch]); useEffect(() => { if (!widgetStateRef) return; @@ -607,10 +610,7 @@ function CustomApiWidgetInner({ aria-label={pollingLabel} variant="subtle" loading={isFetching} - onClick={() => { - if (pollingPaused) void refetch(); - setPollingPaused((value) => !value); - }} + onClick={togglePolling} > {pollingPaused ? : } diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx index d931df9fb..3f58108be 100644 --- a/packages/widgets/src/network-controller/network-status/component.tsx +++ b/packages/widgets/src/network-controller/network-status/component.tsx @@ -8,7 +8,7 @@ import objectSupport from "dayjs/plugin/objectSupport"; import relativeTime from "dayjs/plugin/relativeTime"; import { clientApi } from "@homarr/api/client"; -import { useI18n } from "@homarr/translation/client"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; import { WifiVariant } from "./variants/wifi-variant"; @@ -25,12 +25,17 @@ export default function NetworkControllerNetworkStatusWidget({ width, height, }: WidgetComponentProps<"networkControllerStatus">) { - const { data: summaries = [], isPending } = clientApi.widget.networkController.summary.useQuery({ + const { + data: summaries, + error, + isPending, + } = clientApi.widget.networkController.summary.useQuery({ integrationIds, }); - const t = useI18n(); + const t = useScopedI18n("widget.networkControllerStatus"); + const tCommon = useScopedI18n("common"); - const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]); + const data = useMemo(() => (summaries ?? []).flatMap(({ summary }) => summary), [summaries]); const isAdvanced = displayMode === "advanced"; const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0); const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0); @@ -38,11 +43,13 @@ export default function NetworkControllerNetworkStatusWidget({ const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0); const useHorizontalStats = !isAdvanced && height < 150 && width >= 200; + if (error && summaries === undefined) throw error; + if (isPending || data.length === 0) { return (
- {isPending ? t("common.action.loading") : t("widget.networkControllerStatus.error.integrationsDisconnected")} + {isPending ? tCommon("action.loading") : t("error.integrationsDisconnected")}
); diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx index e0d08e49e..ae22402d8 100644 --- a/packages/widgets/src/paperless-ngx/component.tsx +++ b/packages/widgets/src/paperless-ngx/component.tsx @@ -79,7 +79,11 @@ export default function PaperlessNgxWidget({ }: WidgetComponentProps<"paperlessNgx">) { const t = useScopedI18n("widget.paperlessNgx"); const tCommon = useScopedI18n("common"); - const { data: stats, isPending } = clientApi.widget.paperlessNgx.getStats.useQuery({ + const { + data: stats, + error, + isPending, + } = clientApi.widget.paperlessNgx.getStats.useQuery({ integrationId: integrationIds[0] ?? "", }); @@ -92,6 +96,7 @@ export default function PaperlessNgxWidget({
); } + if (error && stats === undefined) throw error; if (!stats) return ; const canShowInboxHero = options.showDocumentsInbox && options.showDocumentsTotal; diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx index 295499d78..d9526923c 100644 --- a/packages/widgets/src/vpn/component.tsx +++ b/packages/widgets/src/vpn/component.tsx @@ -3,7 +3,7 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { VpnIntegrationCard } from "./vpn-card"; @@ -21,6 +21,7 @@ export default function VpnWidget({ }); const t = useScopedI18n("widget.vpn"); const tCommon = useScopedI18n("common"); + const locale = useCurrentLocale(); const dense = displayMode === "compact" && (width < 240 || height < 120); if (isPending || integrations.length === 0) { @@ -56,7 +57,7 @@ export default function VpnWidget({ {!result.error && new Date(result.integration.updatedAt).getTime() > 0 && ( - {new Date(result.integration.updatedAt).toLocaleString()} + {new Date(result.integration.updatedAt).toLocaleString(locale)} )}
diff --git a/packages/widgets/src/vpn/vpn-card.tsx b/packages/widgets/src/vpn/vpn-card.tsx index 8ddcfd7fd..aeaa36c0c 100644 --- a/packages/widgets/src/vpn/vpn-card.tsx +++ b/packages/widgets/src/vpn/vpn-card.tsx @@ -102,8 +102,12 @@ function VpnStatusColumn({ compact: boolean; dense: boolean; }) { + const t = useScopedI18n("widget.vpn"); // Connected shows a shield with a check; anything else (stopped or unavailable) shows a shield with a cross. const ShieldIcon = vpnStatus === RUNNING_STATUS ? IconShieldCheck : IconShieldX; + const statusLabel = t( + vpnStatus === RUNNING_STATUS ? "status.running" : vpnStatus ? "status.notRunning" : "status.unavailable", + ); return ( } > DNS - {status || "unavailable"} + {statusLabel} ); } From 54e444d69d0c1f270883358aa8cc715df387099f Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:26:04 +0200 Subject: [PATCH 16/75] fix: close final widget review gaps --- e2e/custom-widgets-custom-jsx.spec.ts | 4 +- packages/translation/src/lang/en.json | 13 ++++- .../widgets/src/coolify/coolify-utils.spec.ts | 29 +++++++++++ packages/widgets/src/coolify/coolify-utils.ts | 29 +++-------- packages/widgets/src/coolify/resource-row.tsx | 52 +++++++++++++++---- .../widgets/src/coolify/servers-section.tsx | 52 +++++++++++-------- 6 files changed, 123 insertions(+), 56 deletions(-) create mode 100644 packages/widgets/src/coolify/coolify-utils.spec.ts diff --git a/e2e/custom-widgets-custom-jsx.spec.ts b/e2e/custom-widgets-custom-jsx.spec.ts index c1f7743ba..51013aea9 100644 --- a/e2e/custom-widgets-custom-jsx.spec.ts +++ b/e2e/custom-widgets-custom-jsx.spec.ts @@ -43,7 +43,9 @@ describe("Custom JSX custom widgets", () => { const destination = "/manage/custom-widgets/new"; try { - await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`); + await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`, { + waitUntil: "networkidle", + }); await page.getByLabel("Username").fill(adminCredentials.username); await page.locator("#password").fill(adminCredentials.password); await Promise.all([ diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 9151de77c..6012f83fb 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2573,6 +2573,16 @@ "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" + }, "empty": { "servers": "No servers found", "applications": "No applications found", @@ -2586,7 +2596,8 @@ "internalServerError": "Failed to fetch Coolify data" }, "server": { - "buildServer": "Build" + "buildServer": "Build", + "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}" }, "instances": { "title": "Instances" diff --git a/packages/widgets/src/coolify/coolify-utils.spec.ts b/packages/widgets/src/coolify/coolify-utils.spec.ts new file mode 100644 index 000000000..0b9345861 --- /dev/null +++ b/packages/widgets/src/coolify/coolify-utils.spec.ts @@ -0,0 +1,29 @@ +import dayjs from "dayjs"; +import frenchLocale from "dayjs/locale/fr"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { formatRelativeTime, getResourceTimestamp } from "./coolify-utils"; + +describe("Coolify time formatting", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-08-02T12:00:00Z")); + dayjs.locale(frenchLocale); + }); + + afterEach(() => { + dayjs.locale("en"); + vi.useRealTimers(); + }); + + it("uses the active Day.js locale for past and future timestamps", () => { + expect(formatRelativeTime("2026-08-02T11:00:00Z")).toBe("il y a une heure"); + expect(formatRelativeTime("2026-08-02T13:00:00Z")).toBe("dans une heure"); + }); + + it("omits missing, invalid, and currently running timestamps", () => { + expect(formatRelativeTime(undefined)).toBeUndefined(); + expect(formatRelativeTime("invalid")).toBeUndefined(); + expect(getResourceTimestamp({ status: "running", updated_at: "2026-08-02T11:00:00Z" }, "service")).toBeUndefined(); + }); +}); diff --git a/packages/widgets/src/coolify/coolify-utils.ts b/packages/widgets/src/coolify/coolify-utils.ts index 7bf4f50cb..89343af93 100644 --- a/packages/widgets/src/coolify/coolify-utils.ts +++ b/packages/widgets/src/coolify/coolify-utils.ts @@ -1,9 +1,14 @@ +import dayjs from "dayjs"; +import relativeTime from "dayjs/plugin/relativeTime"; + import type { CoolifyApplicationWithContext, CoolifyServer, CoolifyServiceWithContext, } from "@homarr/integrations/types"; +dayjs.extend(relativeTime); + export function parseStatus(status: string): string { const firstPart = status.split(":")[0]?.toLowerCase(); if (!firstPart) return "unknown"; @@ -42,26 +47,8 @@ export function getBadgeColor(running: number, total: number): string { export function formatRelativeTime(dateString: string | null | undefined): string | undefined { if (!dateString) return undefined; - const date = new Date(dateString); - if (isNaN(date.getTime())) return undefined; - - const now = new Date(); - const diffMs = now.getTime() - date.getTime(); - const diffSec = Math.floor(diffMs / 1000); - const diffMin = Math.floor(diffSec / 60); - const diffHour = Math.floor(diffMin / 60); - const diffDay = Math.floor(diffHour / 24); - - if (diffDay > 0) { - const remainingHours = diffHour % 24; - return remainingHours > 0 ? `${diffDay}d ${remainingHours}h ago` : `${diffDay}d ago`; - } - if (diffHour > 0) { - const remainingMin = diffMin % 60; - return remainingMin > 0 ? `${diffHour}h ${remainingMin}m ago` : `${diffHour}h ago`; - } - if (diffMin > 0) return `${diffMin}m ago`; - return "just now"; + const date = dayjs(dateString); + return date.isValid() ? date.fromNow() : undefined; } export function getResourceTimestamp( @@ -116,7 +103,7 @@ export function buildServerResourceCounts( } export function createWidgetKey(integrationIds: string[]): string { - return integrationIds.slice().sort().join("-"); + return integrationIds.toSorted().join("-"); } export function createStorageKey(widgetKey: string, integrationId: string, type: "sections" | "show-ip"): string { diff --git a/packages/widgets/src/coolify/resource-row.tsx b/packages/widgets/src/coolify/resource-row.tsx index 44ca137a1..b7a18f05e 100644 --- a/packages/widgets/src/coolify/resource-row.tsx +++ b/packages/widgets/src/coolify/resource-row.tsx @@ -1,7 +1,14 @@ "use client"; -import { ActionIcon, Anchor, Group, Indicator, Stack, Text } from "@mantine/core"; -import { IconFileText, IconLink } from "@tabler/icons-react"; +import { ActionIcon, Anchor, Group, Stack, Text } from "@mantine/core"; +import { + IconCircleCheckFilled, + IconCircleXFilled, + IconFileText, + IconHelpCircleFilled, + IconLink, + IconLoader2, +} from "@tabler/icons-react"; import { useScopedI18n } from "@homarr/translation/client"; @@ -30,6 +37,27 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow const t = useScopedI18n("widget.coolify"); const status = parseStatus(item.status ?? ""); const statusColor = getStatusColor(status); + const isTransitioning = status === "starting" || status === "restarting"; + const StatusIcon = + status === "running" + ? IconCircleCheckFilled + : isTransitioning + ? IconLoader2 + : status === "unknown" + ? IconHelpCircleFilled + : IconCircleXFilled; + const statusLabel = + status === "running" + ? t("status.running") + : status === "stopped" + ? t("status.stopped") + : status === "exited" + ? t("status.exited") + : status === "starting" + ? t("status.starting") + : status === "restarting" + ? t("status.restarting") + : t("status.unknown"); const resourceUrl = item.projectUuid && item.environmentUuid @@ -37,34 +65,38 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow : undefined; const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined; + const publicUrl = cleanFqdn(item.fqdn); + const resourceTimestamp = getResourceTimestamp(item, resourceType); return ( - + {resourceUrl ? ( {item.name} ) : ( - + {item.name} )} - {cleanFqdn(item.fqdn) && ( + {publicUrl && ( )} - {!isTiny && getResourceTimestamp(item, resourceType) && ( + {!isTiny && resourceTimestamp && ( - {getResourceTimestamp(item, resourceType)} + {resourceTimestamp} )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 24f82852a..50496eb6d 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -1,7 +1,7 @@ "use client"; -import { Accordion, ActionIcon, Anchor, Badge, Group, Indicator, Stack, Text } from "@mantine/core"; -import { IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; +import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; +import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; import type { CoolifyServer } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; @@ -32,29 +32,28 @@ export function ServersSection({ return ( - }> - + + } style={{ flex: 1, minWidth: 0 }}> {tCommon("servers")} {onlineServers} / {servers.length} - { - event.stopPropagation(); - onToggleIp(); - }} - > - {showIp ? : } - - - + + + {showIp ? : } + + {servers.length > 0 ? ( @@ -94,18 +93,25 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) const isBuildServer = server.settings?.is_build_server === true; const isOnline = server.is_reachable !== false; const serverUrl = `${baseUrl}/server/${server.uuid}`; + const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled; return ( - + {server.name} @@ -115,7 +121,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) ) : ( - ({counts.apps} apps / {counts.services} svcs) + ({t("server.resourceCounts", { applications: counts.apps, services: counts.services })}) )} From d86293de0fbec5c5302c112d076634e584ce8437 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:32:59 +0200 Subject: [PATCH 17/75] fix: canonicalize widget date locales --- packages/translation/src/client/index.ts | 2 +- .../translation/src/client/use-current-locale.ts | 4 ++++ packages/translation/src/config.spec.ts | 16 ++++++++++++++++ packages/translation/src/config.ts | 8 ++++++++ .../src/archive-team-warrior/component.tsx | 4 ++-- packages/widgets/src/calendar/component.tsx | 6 ++---- .../widgets/src/media-releases/component.tsx | 4 ++-- .../src/smart-home/entity-state/component.tsx | 4 ++-- .../src/speedtest-tracker/latest-result.tsx | 4 ++-- packages/widgets/src/vpn/component.tsx | 4 ++-- 10 files changed, 41 insertions(+), 15 deletions(-) create mode 100644 packages/translation/src/config.spec.ts diff --git a/packages/translation/src/client/index.ts b/packages/translation/src/client/index.ts index 5c6755883..0f2ee83f2 100644 --- a/packages/translation/src/client/index.ts +++ b/packages/translation/src/client/index.ts @@ -6,7 +6,7 @@ import type { SupportedLanguage } from "../config"; import type englishTranslation from "../lang/en.json"; export { useChangeLocale } from "./use-change-locale"; -export { useCurrentLocale } from "./use-current-locale"; +export { useCurrentIntlLocale, useCurrentLocale } from "./use-current-locale"; declare module "next-intl" { interface AppConfig { diff --git a/packages/translation/src/client/use-current-locale.ts b/packages/translation/src/client/use-current-locale.ts index a0e796010..6a8ec03a5 100644 --- a/packages/translation/src/client/use-current-locale.ts +++ b/packages/translation/src/client/use-current-locale.ts @@ -1,3 +1,7 @@ import { useLocale } from "next-intl"; +import { getIntlLocale } from "../config"; + export const useCurrentLocale = useLocale; + +export const useCurrentIntlLocale = () => getIntlLocale(useLocale()); diff --git a/packages/translation/src/config.spec.ts b/packages/translation/src/config.spec.ts new file mode 100644 index 000000000..55c3adcbb --- /dev/null +++ b/packages/translation/src/config.spec.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import { getIntlLocale } from "./config"; + +describe("getIntlLocale", () => { + it("maps Homarr-specific locale keys to canonical Intl locales", () => { + expect(getIntlLocale("cn")).toBe("zh-CN"); + expect(getIntlLocale("zh")).toBe("zh-TW"); + expect(getIntlLocale("cr")).toBe("en"); + }); + + it("preserves locale keys that Intl already understands", () => { + expect(getIntlLocale("fr")).toBe("fr"); + expect(getIntlLocale("de-CH")).toBe("de-CH"); + }); +}); diff --git a/packages/translation/src/config.ts b/packages/translation/src/config.ts index 6acf807d4..d0f399e23 100644 --- a/packages/translation/src/config.ts +++ b/packages/translation/src/config.ts @@ -438,5 +438,13 @@ export type SupportedLanguage = (typeof supportedLanguages)[number]; export const fallbackLocale = "en" satisfies SupportedLanguage; +const intlLocaleOverrides: Partial> = { + cn: "zh-CN", + cr: "en", + zh: "zh-TW", +}; + +export const getIntlLocale = (locale: SupportedLanguage): string => intlLocaleOverrides[locale] ?? locale; + export const isLocaleRTL = (locale: SupportedLanguage) => "isRTL" in localeConfigurations[locale] && localeConfigurations[locale].isRTL; diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 83cf74289..35cb3049e 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from import { clientApi } from "@homarr/api/client"; import { formatByteRate } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; -import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; @@ -47,7 +47,7 @@ const ArchiveTeamWarriorWidgetContent = ({ isAdvanced: boolean; }) => { const t = useScopedI18n("widget.archiveTeamWarrior"); - const locale = useCurrentLocale(); + const locale = useCurrentIntlLocale(); const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); const status = data.status; diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 614916993..ced224eb7 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -1,7 +1,6 @@ "use client"; import { useMemo, useState } from "react"; -import { useParams } from "next/navigation"; import { ActionIcon, Box, Center, Group, Loader, Stack, Text, Tooltip, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; @@ -13,7 +12,7 @@ import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; @@ -115,8 +114,7 @@ const CalendarBase = ({ options, displayMode, }: CalendarBaseProps) => { - const params = useParams(); - const locale = params.locale as string; + const locale = useCurrentIntlLocale(); const { firstDayOfWeek } = useSettings(); const board = useRequiredBoard(); const mantineTheme = useMantineTheme(); diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index e90743f27..c04e155de 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -24,7 +24,7 @@ import { getIconUrl } from "@homarr/definitions"; import type { MediaRelease } from "@homarr/integrations/types"; import { mediaTypeConfigurations } from "@homarr/integrations/types"; import type { TranslationFunction } from "@homarr/translation"; -import { useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import type { TablerIcon } from "@homarr/ui"; import { OverflowBadge } from "@homarr/ui"; @@ -78,7 +78,7 @@ const formatReleaseDate = (value: unknown, locale: string, compact: boolean) => }; const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { - const locale = useCurrentLocale(); + const locale = useCurrentIntlLocale(); const t = useI18n(); const length = formatLength(item.length, item.type, t); const isCompact = !isAdvanced && (width < 340 || height < 180); diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 6aadfba46..385d27205 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -7,7 +7,7 @@ import { IconBinaryTree } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; -import { useCurrentLocale, useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; @@ -20,7 +20,7 @@ export default function SmartHomeEntityStateWidget({ displayMode, }: WidgetComponentProps<"smartHome-entityState">) { const t = useI18n(); - const locale = useCurrentLocale(); + const locale = useCurrentIntlLocale(); // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const integrationId = integrationIds[0]!; diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx index d2851aa43..968db8bbf 100644 --- a/packages/widgets/src/speedtest-tracker/latest-result.tsx +++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx @@ -4,7 +4,7 @@ import { Group, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconArrowDown, IconArrowUp, IconCircleCheck, IconCircleX, IconWaveSine } from "@tabler/icons-react"; import type { SpeedtestTrackerResult } from "@homarr/integrations/types"; -import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { formatResultSpeed } from "./helpers"; import { SectionLabel } from "./section-label"; @@ -12,7 +12,7 @@ import { SpeedStatCard } from "./speed-stat-card"; export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) { const t = useScopedI18n("widget.speedtestTracker"); - const locale = useCurrentLocale(); + const locale = useCurrentIntlLocale(); const timestamp = Intl.DateTimeFormat(locale, { month: "short", day: "numeric", diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx index d9526923c..399264bbe 100644 --- a/packages/widgets/src/vpn/component.tsx +++ b/packages/widgets/src/vpn/component.tsx @@ -3,7 +3,7 @@ import { Center, Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; -import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { VpnIntegrationCard } from "./vpn-card"; @@ -21,7 +21,7 @@ export default function VpnWidget({ }); const t = useScopedI18n("widget.vpn"); const tCommon = useScopedI18n("common"); - const locale = useCurrentLocale(); + const locale = useCurrentIntlLocale(); const dense = displayMode === "compact" && (width < 240 || height < 120); if (isPending || integrations.length === 0) { From d97f1bd34b32e0f7e3199721a2d8cf3a0a6ee4ee Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:42:08 +0200 Subject: [PATCH 18/75] fix: align widget accessibility and locales --- .../widgets/src/anchor-note/component.tsx | 5 +++-- .../src/dns-hole/summary/component.tsx | 19 ++++--------------- .../src/immich/album-carousel/component.tsx | 5 +++-- .../widgets/src/immich/albums-query.spec.ts | 1 + .../src/immich/server-stats/component.tsx | 11 ++++++----- .../widgets/src/media-releases/component.tsx | 7 ++++--- .../execute-automation/component.tsx | 5 +++-- packages/widgets/src/stocks/component.tsx | 10 ++++++---- .../src/tracearr/recent-activity-section.tsx | 5 +++-- .../src/tracearr/violations-section.tsx | 5 +++-- packages/widgets/src/umami/umami-content.tsx | 19 ++++++++++--------- 11 files changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 24e4de866..945a2de55 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -14,7 +14,7 @@ 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"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; @@ -180,6 +180,7 @@ const AnchorNoteWidgetContent = ({ isAdvanced, }: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); + const locale = useCurrentIntlLocale(); const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, @@ -327,7 +328,7 @@ const AnchorNoteWidgetContent = ({ )} - {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })} + {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString(locale) })} )} diff --git a/packages/widgets/src/dns-hole/summary/component.tsx b/packages/widgets/src/dns-hole/summary/component.tsx index a1ec1f1df..86c03dd7c 100644 --- a/packages/widgets/src/dns-hole/summary/component.tsx +++ b/packages/widgets/src/dns-hole/summary/component.tsx @@ -2,19 +2,7 @@ import { useMemo } from "react"; import type { BoxProps } from "@mantine/core"; -import { - Avatar, - AvatarGroup, - Badge, - Card, - Flex, - Group, - SimpleGrid, - Stack, - Text, - Tooltip, - TooltipFloating, -} from "@mantine/core"; +import { Avatar, AvatarGroup, Badge, Card, Flex, Group, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { useElementSize } from "@mantine/hooks"; import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react"; @@ -177,10 +165,11 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => { const value = item.value(data, width <= 64 ? "sm" : "md"); return ( - + { - + ); }; diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index fa7bd4d0b..61c8c57e2 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -14,7 +14,7 @@ import { } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useI18n, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; @@ -139,6 +139,7 @@ function Carousel({ setPaused, }: CarouselProps) { const t = useScopedI18n("widget.immich-albumCarousel"); + const locale = useCurrentIntlLocale(); const reduceMotion = useReducedMotion(); useEffect(() => { @@ -214,7 +215,7 @@ function Carousel({ )} - {new Date(currentAsset.fileModifiedAt).toLocaleDateString()} + {new Date(currentAsset.fileModifiedAt).toLocaleDateString(locale)} {safeCurrentIndex + 1} / {assets.length} diff --git a/packages/widgets/src/immich/albums-query.spec.ts b/packages/widgets/src/immich/albums-query.spec.ts index a3dd73171..c52187716 100644 --- a/packages/widgets/src/immich/albums-query.spec.ts +++ b/packages/widgets/src/immich/albums-query.spec.ts @@ -27,6 +27,7 @@ vi.mock("@homarr/api/client", () => ({ })); vi.mock("@homarr/translation/client", () => ({ + useCurrentIntlLocale: () => "en", useI18n: () => (key: string) => key, useScopedI18n: () => (key: string) => key, })); diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx index b17ce1de5..9a09ffb3a 100644 --- a/packages/widgets/src/immich/server-stats/component.tsx +++ b/packages/widgets/src/immich/server-stats/component.tsx @@ -7,7 +7,7 @@ import { getQueryKey } from "@trpc/react-query"; import { clientApi } from "@homarr/api/client"; import { formatBytes } from "@homarr/common"; -import { useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; import type { WidgetComponentProps } from "../../definition"; @@ -25,6 +25,7 @@ export default function ImmichServerStatsWidget({ widgetStateRef, }: WidgetComponentProps<"immich-serverStats">) { const t = useI18n(); + const locale = useCurrentIntlLocale(); const input = { integrationId: integrationIds[0] ?? "" }; const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS }; const isAdvanced = displayMode === "advanced"; @@ -51,7 +52,7 @@ export default function ImmichServerStatsWidget({ } label={t("widget.immich-serverStats.users")} - value={stats.userCount} + value={stats.userCount.toLocaleString(locale)} dense={statsLayout.dense} /> )} @@ -59,7 +60,7 @@ export default function ImmichServerStatsWidget({ } label={t("widget.immich-serverStats.photos")} - value={stats.photoCount} + value={stats.photoCount.toLocaleString(locale)} dense={statsLayout.dense} /> )} @@ -67,7 +68,7 @@ export default function ImmichServerStatsWidget({ } label={t("widget.immich-serverStats.videos")} - value={stats.videoCount} + value={stats.videoCount.toLocaleString(locale)} dense={statsLayout.dense} /> )} @@ -107,7 +108,7 @@ export default function ImmichServerStatsWidget({ {album.albumName} - {album.assetCount.toLocaleString()} + {album.assetCount.toLocaleString(locale)} diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index c04e155de..c2ac70573 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -12,7 +12,7 @@ import { SimpleGrid, Stack, Text, - TooltipFloating, + Tooltip, UnstyledButton, } from "@mantine/core"; import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react"; @@ -88,10 +88,11 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource)); return ( - { )} - + ); }; diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx index 98964991d..87ab84265 100644 --- a/packages/widgets/src/smart-home/execute-automation/component.tsx +++ b/packages/widgets/src/smart-home/execute-automation/component.tsx @@ -19,7 +19,7 @@ import { IconAutomation, IconCheck } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; -import { useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; @@ -61,6 +61,7 @@ export default function SmartHomeTriggerAutomationWidget({ }, [canInteract, integrationId, isEditMode, isPending, mutateAsync, options.automationId]); const t = useI18n(); + const locale = useCurrentIntlLocale(); useRegisterSpotlightContextActions( `smartHome-automation-${options.automationId}`, [ @@ -150,7 +151,7 @@ export default function SmartHomeTriggerAutomationWidget({ {lastExecutedAt ? t("widget.smartHome-executeAutomation.advanced.lastExecuted", { - time: lastExecutedAt.toLocaleTimeString(), + time: lastExecutedAt.toLocaleTimeString(locale), }) : t("widget.smartHome-executeAutomation.advanced.automationId", { id: options.automationId, diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx index d83f409d0..6fb401185 100644 --- a/packages/widgets/src/stocks/component.tsx +++ b/packages/widgets/src/stocks/component.tsx @@ -5,7 +5,7 @@ import { Flex, Stack, Text, Title, useMantineTheme } from "@mantine/core"; import { IconMinus, IconTrendingDown, IconTrendingUp } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; @@ -73,6 +73,8 @@ export function getStockSummary(priceHistory: number[], previousClose: number): export default function StockPriceWidget({ options, width, height, displayMode }: WidgetComponentProps<"stockPrice">) { const t = useScopedI18n("widget.stockPrice"); + const locale = useCurrentIntlLocale(); + const numberFormatter = new Intl.NumberFormat(locale); const theme = useMantineTheme(); const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options); @@ -120,15 +122,15 @@ export default function StockPriceWidget({ options, width, height, displayMode } - {new Intl.NumberFormat().format(round(summary.currentPrice))} + {numberFormatter.format(round(summary.currentPrice))} {layout.showChange && ( - {new Intl.NumberFormat().format(stockValuesChange)} + {numberFormatter.format(stockValuesChange)} {stockValuesChangePercentage === null ? null - : ` (${stockValuesChange > 0 ? "+" : ""}${new Intl.NumberFormat().format(stockValuesChangePercentage)}%)`} + : ` (${stockValuesChange > 0 ? "+" : ""}${numberFormatter.format(stockValuesChangePercentage)}%)`} )} diff --git a/packages/widgets/src/tracearr/recent-activity-section.tsx b/packages/widgets/src/tracearr/recent-activity-section.tsx index 982101350..994ff89a2 100644 --- a/packages/widgets/src/tracearr/recent-activity-section.tsx +++ b/packages/widgets/src/tracearr/recent-activity-section.tsx @@ -1,10 +1,11 @@ import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core"; import type { TracearrHistorySession } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; export function RecentActivityList({ sessions }: { sessions: TracearrHistorySession[] }) { const t = useScopedI18n("widget.tracearr"); + const locale = useCurrentIntlLocale(); return ( @@ -42,7 +43,7 @@ export function RecentActivityList({ sessions }: { sessions: TracearrHistorySess {session.watched ? t("recentActivity.watched") : t("recentActivity.partial")} - {new Date(session.startedAt).toLocaleDateString()} + {new Date(session.startedAt).toLocaleDateString(locale)} diff --git a/packages/widgets/src/tracearr/violations-section.tsx b/packages/widgets/src/tracearr/violations-section.tsx index 6d052dd59..5c96a5c83 100644 --- a/packages/widgets/src/tracearr/violations-section.tsx +++ b/packages/widgets/src/tracearr/violations-section.tsx @@ -2,10 +2,11 @@ import { Avatar, Badge, Group, Paper, Stack, Text } from "@mantine/core"; import { IconAlertTriangle } from "@tabler/icons-react"; import type { TracearrViolation } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; export function ViolationsList({ violations }: { violations: TracearrViolation[] }) { const t = useScopedI18n("widget.tracearr"); + const locale = useCurrentIntlLocale(); return ( @@ -53,7 +54,7 @@ export function ViolationsList({ violations }: { violations: TracearrViolation[] {violation.severity} - {new Date(violation.createdAt).toLocaleDateString()} + {new Date(violation.createdAt).toLocaleDateString(locale)} diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 1179fb4f6..510d5749c 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -6,7 +6,7 @@ import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme import { clientApi } from "@homarr/api/client"; import { formatDuration } from "@homarr/common"; import type { UmamiVisitorStats } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { UmamiEventsContent } from "./umami-events-content"; import { UmamiTopPagesContent, UmamiTopReferrersContent } from "./umami-top-list"; @@ -44,6 +44,7 @@ export function UmamiContent({ }: UmamiContentProps) { const t = useScopedI18n("widget.umami"); const tCommon = useScopedI18n("common"); + const locale = useCurrentIntlLocale(); const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; @@ -217,8 +218,8 @@ export function UmamiContent({ {viewMode === "events" - ? (multiEventTotal ?? 0).toLocaleString() - : (visitorStats.eventCount ?? 0).toLocaleString()} + ? (multiEventTotal ?? 0).toLocaleString(locale) + : (visitorStats.eventCount ?? 0).toLocaleString(locale)} {t("event.label")} @@ -229,7 +230,7 @@ export function UmamiContent({ - {visitorStats.totalVisitors.toLocaleString()} + {visitorStats.totalVisitors.toLocaleString(locale)} {t("visitors")} @@ -239,7 +240,7 @@ export function UmamiContent({ ) : ( - {visitorStats.totalVisitors.toLocaleString()} + {visitorStats.totalVisitors.toLocaleString(locale)} {t("visitors")} @@ -250,18 +251,18 @@ export function UmamiContent({ {showSecondaryStats && ( - ● {activeVisitors.toLocaleString()} {t("active")} + ● {activeVisitors.toLocaleString(locale)} {t("active")} - {visitorStats.totalPageviews.toLocaleString()} {t("pageviews")} + {visitorStats.totalPageviews.toLocaleString(locale)} {t("pageviews")} - {visitorStats.totalVisits.toLocaleString()} {t("visits")} + {visitorStats.totalVisits.toLocaleString(locale)} {t("visits")} {showDetailedStats && ( <> - {visitorStats.bounceRate}% {t("bounceRate")} + {visitorStats.bounceRate.toLocaleString(locale)}% {t("bounceRate")} {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")} From ed8ef15f127e243bfa222873daed3b746227acde Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 16:48:34 +0200 Subject: [PATCH 19/75] fix: localize widget chart formatting --- .../src/speedtest-tracker/recent-results.tsx | 73 +++++++++++-------- packages/widgets/src/umami/umami-content.tsx | 2 +- .../src/umami/umami-events-content.tsx | 5 +- packages/widgets/src/umami/umami-top-list.tsx | 6 +- packages/widgets/src/umami/umami-utils.ts | 6 +- 5 files changed, 52 insertions(+), 40 deletions(-) diff --git a/packages/widgets/src/speedtest-tracker/recent-results.tsx b/packages/widgets/src/speedtest-tracker/recent-results.tsx index 5d2328563..174a4c890 100644 --- a/packages/widgets/src/speedtest-tracker/recent-results.tsx +++ b/packages/widgets/src/speedtest-tracker/recent-results.tsx @@ -8,7 +8,7 @@ import { ReferenceLine, XAxis } from "recharts"; import { useRequiredBoard } from "@homarr/boards/context"; import type { SpeedtestTrackerResult } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { SectionLabel } from "./section-label"; @@ -40,17 +40,17 @@ function buildXAxisTicks(data: { ts: number }[]): XAxisTicks { return { midnightTs: foundMidnight, - xTicks: Array.from(tickSet).sort((tsA, tsB) => tsA - tsB), + xTicks: Array.from(tickSet).toSorted((tsA, tsB) => tsA - tsB), topDateTicks: topTicks, }; } -function makeXTickRenderer(midnightTs: number | null) { +function makeXTickRenderer(midnightTs: number | null, locale: string) { return ({ x, y, payload }: { x: number | string; y: number | string; payload: { value: number } }) => { const date = new Date(payload.value); - const timeStr = date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + const timeStr = date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit" }); const isMidnight = midnightTs !== null && payload.value === midnightTs; - const dateStr = isMidnight ? date.toLocaleDateString([], { month: "short", day: "numeric" }) : null; + const dateStr = isMidnight ? date.toLocaleDateString(locale, { month: "short", day: "numeric" }) : null; return ( @@ -67,23 +67,29 @@ function makeXTickRenderer(midnightTs: number | null) { }; } -function renderTopDateTick(props: { - x: number | string; - y: number | string; - payload: { value: number }; - index: number; -}) { - const { x, y, payload, index } = props; - const date = new Date(payload.value); - const dateStr = date.toLocaleDateString([], { weekday: "short", month: "short", day: "numeric" }); - const anchor = index === 0 ? "start" : "middle"; - return ( - - - {dateStr} - - - ); +function makeTopDateTickRenderer(locale: string) { + return ({ + x, + y, + payload, + index, + }: { + x: number | string; + y: number | string; + payload: { value: number }; + index: number; + }) => { + const date = new Date(payload.value); + const dateStr = date.toLocaleDateString(locale, { weekday: "short", month: "short", day: "numeric" }); + const anchor = index === 0 ? "start" : "middle"; + return ( + + + {dateStr} + + + ); + }; } function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step: number }[]) { @@ -94,10 +100,10 @@ function buildYAxisConfig(maxVal: number, stepOptions: { threshold: number; step return { ticks, domain: [0, roundedMax] as [number, number] }; } -function formatTooltipDate(label: number | string | undefined): string { +function formatTooltipDate(label: number | string | undefined, locale: string): string { const timestamp = typeof label === "number" ? label : Number(label); if (Number.isNaN(timestamp)) return ""; - return new Date(timestamp).toLocaleString([], { + return new Date(timestamp).toLocaleString(locale, { month: "short", day: "numeric", hour: "2-digit", @@ -157,11 +163,12 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul const board = useRequiredBoard(); const theme = useMantineTheme(); const t = useScopedI18n("widget.speedtestTracker"); + const locale = useCurrentIntlLocale(); const data = useMemo( () => - [...results] - .sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) + results + .toSorted((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) .filter((result) => (result.download_bits ?? 0) > 0) .map((result) => ({ ts: result.created_at.getTime(), @@ -184,7 +191,8 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul ); const { midnightTs, xTicks, topDateTicks } = useMemo(() => buildXAxisTicks(data), [data]); - const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]); + const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]); + const renderTopDateTick = useMemo(() => makeTopDateTickRenderer(locale), [locale]); if (data.length === 0) return null; @@ -230,7 +238,7 @@ function SpeedHistoryChart({ results, height }: { results: SpeedtestTrackerResul if (!active || !payload?.length) return null; return ( `${val} Mbps`} /> @@ -269,11 +277,12 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult const board = useRequiredBoard(); const theme = useMantineTheme(); const t = useScopedI18n("widget.speedtestTracker"); + const locale = useCurrentIntlLocale(); const data = useMemo( () => - [...results] - .sort((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) + results + .toSorted((resultA, resultB) => resultA.created_at.getTime() - resultB.created_at.getTime()) .map((result) => result.ping === null ? null @@ -298,7 +307,7 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult ); const { midnightTs, xTicks } = useMemo(() => buildXAxisTicks(data), [data]); - const renderXTick = useMemo(() => makeXTickRenderer(midnightTs), [midnightTs]); + const renderXTick = useMemo(() => makeXTickRenderer(midnightTs, locale), [locale, midnightTs]); if (data.length === 0) return null; @@ -341,7 +350,7 @@ function PingHistoryChart({ results, height }: { results: SpeedtestTrackerResult if (!active || !payload?.length) return null; return ( `${val} ms`} /> diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 510d5749c..9eb6d8cdf 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -91,7 +91,7 @@ export function UmamiContent({ const { visitorStats } = firstResult; const chartData = visitorStats.dataPoints.map((point: UmamiVisitorStats["dataPoints"][number]) => ({ - label: formatXLabel(point.timestamp, timeFrame), + label: formatXLabel(point.timestamp, timeFrame, locale), visitors: point.visitors, ...(point.events !== undefined ? { events: point.events } : {}), })); diff --git a/packages/widgets/src/umami/umami-events-content.tsx b/packages/widgets/src/umami/umami-events-content.tsx index e503df97a..a1af745e2 100644 --- a/packages/widgets/src/umami/umami-events-content.tsx +++ b/packages/widgets/src/umami/umami-events-content.tsx @@ -5,7 +5,7 @@ import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { UmamiEventSeries } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils"; @@ -27,6 +27,7 @@ export function UmamiEventsContent({ showXAxis, }: UmamiEventsContentProps) { const t = useScopedI18n("widget.umami"); + const locale = useCurrentIntlLocale(); const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; @@ -59,7 +60,7 @@ export function UmamiEventsContent({ ); const chartData = allTimestamps.map((timestamp) => { - const row: Record = { label: formatXLabel(timestamp, timeFrame) }; + const row: Record = { label: formatXLabel(timestamp, timeFrame, locale) }; for (const serie of series) { row[serie.eventName] = byEvent.get(serie.eventName)?.get(timestamp) ?? 0; } diff --git a/packages/widgets/src/umami/umami-top-list.tsx b/packages/widgets/src/umami/umami-top-list.tsx index 3ce4b8730..3d17d257a 100644 --- a/packages/widgets/src/umami/umami-top-list.tsx +++ b/packages/widgets/src/umami/umami-top-list.tsx @@ -4,7 +4,7 @@ import { Group, ScrollArea, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import type { UmamiMetricItem } from "@homarr/integrations/types"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import { umamiQueryOptions } from "./umami-utils"; @@ -48,6 +48,8 @@ function UmamiTopList({ heading: string; emptyLabel: string; }) { + const locale = useCurrentIntlLocale(); + return ( @@ -64,7 +66,7 @@ function UmamiTopList({ {item.x || emptyLabel} - {item.y.toLocaleString()} + {item.y.toLocaleString(locale)} ))} diff --git a/packages/widgets/src/umami/umami-utils.ts b/packages/widgets/src/umami/umami-utils.ts index bca9d247b..dd64a6e64 100644 --- a/packages/widgets/src/umami/umami-utils.ts +++ b/packages/widgets/src/umami/umami-utils.ts @@ -6,17 +6,17 @@ export const umamiQueryOptions = { refetchInterval: 60_000 } as const; export const EVENT_COLORS = ["blue.5", "orange.5", "green.5", "red.5", "violet.5", "teal.5", "yellow.5", "pink.5"]; -export function formatXLabel(timestamp: string, timeFrame: string): string { +export function formatXLabel(timestamp: string, timeFrame: string, locale: string): string { const date = new Date(timestamp); switch (timeFrame) { case "today": case "24h": - return `${date.getHours().toString().padStart(2, "0")}:00`; + return date.toLocaleTimeString(locale, { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }); case "7d": case "30d": case "month": case "lastMonth": - return date.toLocaleDateString(undefined, { month: "short", day: "numeric" }); + return date.toLocaleDateString(locale, { month: "short", day: "numeric" }); default: return timestamp; } From f0d8e97f8d06fd131acdb41b8b9750ba7153e95b Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 17:25:02 +0200 Subject: [PATCH 20/75] fix: address final widget review --- .../widgets/src/audio-stats/component.tsx | 34 ++++++++++--------- .../src/smart-home/entity-state/component.tsx | 17 ++++++++-- packages/widgets/src/umami/umami-content.tsx | 9 +++-- 3 files changed, 40 insertions(+), 20 deletions(-) diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index fe78ea689..eb9adb1a5 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -8,9 +8,9 @@ import { formatBytes, formatDuration } from "@homarr/common"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { WidgetEmptyState } from "../common/empty-state"; -import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; import { setWidgetRuntimeQueries } from "../definition"; +import { NoIntegrationDataError } from "../errors/no-data-integration"; import { AudioStatsContent } from "./audio-stats-content"; export default function AudioStatsWidget({ @@ -22,17 +22,22 @@ export default function AudioStatsWidget({ widgetStateRef, }: WidgetComponentProps<"audioStats">) { const statsInput = { integrationId: integrationIds[0] ?? "" }; - const { data: response } = clientApi.widget.audioStats.getStats.useQuery(statsInput); - const streamsInput = { integrationIds, showOnlyPlaying: false }; + const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput); + const streamsInput = { integrationIds: [statsInput.integrationId], showOnlyPlaying: true }; const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome"; - const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(streamsInput, { - enabled: streamsEnabled, - }); + const { data: streamResults, error: streamsError } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( + streamsInput, + { enabled: streamsEnabled }, + ); setWidgetRuntimeQueries(widgetStateRef, [ getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"), ...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []), ]); + if (statsError && response === undefined) throw statsError; + if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError; + const currentStreams = streamResults ?? []; + if (streamsEnabled && currentStreams.some(({ error }) => Boolean(error))) throw new NoIntegrationDataError(); if (!response) return ; const summary = ( @@ -46,22 +51,17 @@ export default function AudioStatsWidget({ ); if (displayMode === "compact") return summary; - const sessions = streamResults.flatMap((result) => + const sessions = currentStreams.flatMap((result) => result.sessions.map((session) => ({ integrationId: result.integrationId, session })), ); - const hasStreamFailures = streamResults.some(({ error }) => Boolean(error)); const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; + const advancedGridColumns = width >= 760 ? 2 : 1; return ( - {hasStreamFailures && ( - - - - )}
{summary}
{response.kind === "navidrome" ? ( - = 760 ? 2 : 1} spacing="xs"> + {sessions.map(({ integrationId, session }) => { const currentlyPlaying = session.currentlyPlaying; if (!currentlyPlaying) return null; @@ -75,13 +75,15 @@ export default function AudioStatsWidget({ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
- {session.sessionName} + + {session.sessionName} +
); })} ) : ( - = 760 ? 2 : 1} spacing="xs"> + {(audiobookStats?.libraries ?? []).map((library) => ( diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 385d27205..94950135c 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -6,6 +6,7 @@ import { IconBinaryTree } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; +import { showErrorNotification } from "@homarr/notifications"; import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; @@ -29,18 +30,27 @@ export default function SmartHomeEntityStateWidget({ entityId: options.entityId, integrationId, }; - const { data: entity } = clientApi.widget.smartHome.entityDetails.useQuery(input); + const { + data: entity, + isPending: isEntityPending, + error: entityError, + } = clientApi.widget.smartHome.entityDetails.useQuery(input); const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId); const utils = clientApi.useUtils(); const { mutate, isPending, error } = clientApi.widget.smartHome.switchEntity.useMutation({ onSettled: () => void utils.widget.smartHome.entityDetails.invalidate(input), + onError: () => + showErrorNotification({ + title: t("common.error"), + message: t("widget.smartHome-entityState.error.toggleFailed"), + }), }); const apiUnit = entity?.attributes.unit_of_measurement; const unit = options.entityUnit || (typeof apiUnit === "string" ? apiUnit : ""); const attribute = unit.length > 0 ? ` ${unit}` : ""; - const isActionable = options.clickable && canInteract && !isPending; + const isActionable = options.clickable && canInteract && entity !== undefined && !isEntityPending && !isPending; const handleClick = useCallback(() => { if (isEditMode) { @@ -77,6 +87,9 @@ export default function SmartHomeEntityStateWidget({ ], [handleClick, isActionable, options.displayName, options.entityId], ); + + if (entityError && entity === undefined) throw entityError; + const isTiny = width < 128 || height < 96; const advancedAttributes = [ { key: "unit" as const, value: entity?.attributes.unit_of_measurement }, diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index 9eb6d8cdf..08d782df9 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -48,7 +48,11 @@ export function UmamiContent({ const { colorScheme } = useMantineColorScheme(); const tickColor = colorScheme === "dark" ? "#c1c2c5" : "#495057"; - const { data: results = [], isPending: isStatsPending } = clientApi.widget.umami.getVisitorStats.useQuery( + const { + data: results, + isPending: isStatsPending, + error: statsError, + } = clientApi.widget.umami.getVisitorStats.useQuery( { integrationIds, websiteId, timeFrame, eventName }, umamiQueryOptions, ); @@ -68,7 +72,7 @@ export function UmamiContent({ ? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0) : undefined; - const firstResult = results[0]; + const firstResult = results?.[0]; if (isStatsPending) { return ( @@ -78,6 +82,7 @@ export function UmamiContent({ ); } + if (statsError && results === undefined) throw statsError; if (!firstResult) { return ( From 5010ecf1886155537b47740928c2fdb78facbcd7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 17:34:52 +0200 Subject: [PATCH 21/75] fix: harden widget error feedback --- .../widgets/smart-home-entity-state/index.mdx | 1 + packages/translation/src/lang/en.json | 1 + packages/widgets/src/audio-stats/component.tsx | 16 +++++++++++----- .../src/smart-home/entity-state/component.tsx | 11 +++++++++-- .../widgets/src/smart-home/entity-state/index.ts | 8 +++++++- 5 files changed, 29 insertions(+), 8 deletions(-) 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 28c83d3aa..9cd6d19a8 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -45,6 +45,7 @@ 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. diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 6012f83fb..e7e7e407a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2185,6 +2185,7 @@ } }, "error": { + "loadFailed": "Unable to load entity", "toggleFailed": "Unable to update entity" } }, diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index eb9adb1a5..2fdb6484e 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; import { getQueryKey } from "@trpc/react-query"; import { clientApi } from "@homarr/api/client"; @@ -67,7 +67,7 @@ export default function AudioStatsWidget({ if (!currentlyPlaying) return null; return ( - + {currentlyPlaying.name} @@ -75,9 +75,15 @@ export default function AudioStatsWidget({ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
- - {session.sessionName} - + + + {session.sessionName} + +
); })} diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 94950135c..e3f776f66 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -50,7 +50,9 @@ export default function SmartHomeEntityStateWidget({ const apiUnit = entity?.attributes.unit_of_measurement; const unit = options.entityUnit || (typeof apiUnit === "string" ? apiUnit : ""); const attribute = unit.length > 0 ? ` ${unit}` : ""; - const isActionable = options.clickable && canInteract && entity !== undefined && !isEntityPending && !isPending; + const isActionable = + options.clickable && canInteract && entity !== undefined && !entityError && !isEntityPending && !isPending; + const queryErrorLabel = entityError ? t("widget.smartHome-entityState.error.loadFailed") : undefined; const handleClick = useCallback(() => { if (isEditMode) { @@ -108,7 +110,7 @@ export default function SmartHomeEntityStateWidget({ onClick={handleClick} disabled={!isActionable} aria-label={`${displayName}: ${state}${attribute}`} - aria-description={error?.message} + aria-description={queryErrorLabel ?? (error ? t("widget.smartHome-entityState.error.toggleFailed") : undefined)} w="100%" h="100%" styles={{ @@ -127,6 +129,11 @@ export default function SmartHomeEntityStateWidget({ {displayName} + {queryErrorLabel && ( + + {queryErrorLabel} + + )} {displayMode === "advanced" && entity && ( <> diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts index 649116bce..93a12a872 100644 --- a/packages/widgets/src/smart-home/entity-state/index.ts +++ b/packages/widgets/src/smart-home/entity-state/index.ts @@ -1,4 +1,4 @@ -import { IconBinaryTree } from "@tabler/icons-react"; +import { IconBinaryTree, IconServerOff } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; @@ -22,4 +22,10 @@ export const { definition, componentLoader } = createWidgetDefinition("smartHome })); }, supportedIntegrations: getIntegrationKindsByCategory("smartHomeServer"), + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.smartHome-entityState.error.loadFailed"), + }, + }, }).withDynamicImport(() => import("./component")); From fd024e32d2343365be48bdd6a054fd13814fe756 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 17:39:28 +0200 Subject: [PATCH 22/75] fix: simplify widget feedback --- packages/widgets/src/audio-stats/component.tsx | 14 ++++---------- .../src/smart-home/entity-state/component.tsx | 13 +++---------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 2fdb6484e..e3b447ab9 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -1,6 +1,6 @@ "use client"; -import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core"; +import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { getQueryKey } from "@trpc/react-query"; import { clientApi } from "@homarr/api/client"; @@ -75,15 +75,9 @@ export default function AudioStatsWidget({ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
- - - {session.sessionName} - - + + {session.sessionName} + ); })} diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index e3f776f66..179ab3c3e 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback } from "react"; -import { Badge, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { Badge, Center, Group, Stack, Text, UnstyledButton } from "@mantine/core"; import { IconBinaryTree } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -38,7 +38,7 @@ export default function SmartHomeEntityStateWidget({ const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId); const utils = clientApi.useUtils(); - const { mutate, isPending, error } = clientApi.widget.smartHome.switchEntity.useMutation({ + const { mutate, isPending } = clientApi.widget.smartHome.switchEntity.useMutation({ onSettled: () => void utils.widget.smartHome.entityDetails.invalidate(input), onError: () => showErrorNotification({ @@ -110,7 +110,7 @@ export default function SmartHomeEntityStateWidget({ onClick={handleClick} disabled={!isActionable} aria-label={`${displayName}: ${state}${attribute}`} - aria-description={queryErrorLabel ?? (error ? t("widget.smartHome-entityState.error.toggleFailed") : undefined)} + aria-description={queryErrorLabel} w="100%" h="100%" styles={{ @@ -153,13 +153,6 @@ export default function SmartHomeEntityStateWidget({ )} - {error && ( - - - {t("widget.smartHome-entityState.error.toggleFailed")} - - - )}
From f9aae23f4690c1723913d3e6037eed7a7c090124 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 17:41:27 +0200 Subject: [PATCH 23/75] fix: keep session labels readable --- packages/widgets/src/audio-stats/component.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index e3b447ab9..08127b39e 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -75,7 +75,14 @@ export default function AudioStatsWidget({ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
- + {session.sessionName}
From 96e92485a5fd45f3648cfac0453e614779c414ba Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sun, 2 Aug 2026 18:07:21 +0200 Subject: [PATCH 24/75] fix: align responsive widget actions --- .../src/smart-home/entity-state/component.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx index 179ab3c3e..38ea95bbb 100644 --- a/packages/widgets/src/smart-home/entity-state/component.tsx +++ b/packages/widgets/src/smart-home/entity-state/component.tsx @@ -8,7 +8,7 @@ import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { showErrorNotification } from "@homarr/notifications"; import { useRegisterSpotlightContextActions } from "@homarr/spotlight"; -import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../../definition"; @@ -20,7 +20,8 @@ export default function SmartHomeEntityStateWidget({ height, displayMode, }: WidgetComponentProps<"smartHome-entityState">) { - const t = useI18n(); + const t = useScopedI18n("widget.smartHome-entityState"); + const tCommon = useScopedI18n("common"); const locale = useCurrentIntlLocale(); // It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx // eslint-disable-next-line @typescript-eslint/no-non-null-assertion @@ -42,8 +43,8 @@ export default function SmartHomeEntityStateWidget({ onSettled: () => void utils.widget.smartHome.entityDetails.invalidate(input), onError: () => showErrorNotification({ - title: t("common.error"), - message: t("widget.smartHome-entityState.error.toggleFailed"), + title: tCommon("error"), + message: t("error.toggleFailed"), }), }); @@ -51,14 +52,16 @@ export default function SmartHomeEntityStateWidget({ const unit = options.entityUnit || (typeof apiUnit === "string" ? apiUnit : ""); const attribute = unit.length > 0 ? ` ${unit}` : ""; const isActionable = - options.clickable && canInteract && entity !== undefined && !entityError && !isEntityPending && !isPending; - const queryErrorLabel = entityError ? t("widget.smartHome-entityState.error.loadFailed") : undefined; + !isEditMode && + options.clickable && + canInteract && + entity !== undefined && + !entityError && + !isEntityPending && + !isPending; + const queryErrorLabel = entityError ? t("error.loadFailed") : undefined; const handleClick = useCallback(() => { - if (isEditMode) { - return; - } - if (!isActionable) { return; } @@ -67,7 +70,7 @@ export default function SmartHomeEntityStateWidget({ entityId: options.entityId, integrationId, }); - }, [integrationId, isActionable, isEditMode, mutate, options.entityId]); + }, [integrationId, isActionable, mutate, options.entityId]); useRegisterSpotlightContextActions( `smartHome-entityState-${options.entityId}`, @@ -115,7 +118,7 @@ export default function SmartHomeEntityStateWidget({ h="100%" styles={{ root: { - cursor: isActionable && !isEditMode ? "pointer" : "initial", + cursor: isActionable ? "pointer" : "initial", pointerEvents: isEditMode ? "none" : undefined, }, }} @@ -139,15 +142,15 @@ export default function SmartHomeEntityStateWidget({ {advancedAttributes.map(({ key, value }) => ( - {t(`widget.smartHome-entityState.advanced.attribute.${key}`)}: {String(value)} + {t(`advanced.attribute.${key}`)}: {String(value)} ))} - {t("widget.smartHome-entityState.advanced.entityId", { id: entity.entity_id })} + {t("advanced.entityId", { id: entity.entity_id })} - {t("widget.smartHome-entityState.advanced.lastUpdated", { + {t("advanced.lastUpdated", { date: new Date(entity.last_updated).toLocaleString(locale), })} From 09e9cd438820edb59e63374cd9223c6c089a3618 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 12:15:12 +0200 Subject: [PATCH 25/75] fix widget focus interactions --- .../docs/widgets/beszel-system-grid/index.mdx | 4 --- .../widgets/beszel-system-table/index.mdx | 8 ++--- apps/docs/docs/widgets/media-server/index.mdx | 1 + .../advanced-focus/advanced-focus.module.css | 2 +- .../board/advanced-focus/context.tsx | 2 +- .../components/board/advanced-focus/wheel.ts | 30 +++++++++++++++++++ .../components/board/items/item-content.tsx | 23 ++++++++++++-- .../board/items/widget-context-menu.tsx | 4 ++- e2e/board-advanced-interactions.spec.ts | 12 ++++++++ .../media-server/media-server-types.ts | 2 +- packages/translation/src/lang/en.json | 1 + packages/widgets/src/app/index.ts | 1 + .../widgets/src/audio-stats/component.tsx | 2 +- .../widgets/src/beszel-system-grid/index.ts | 1 + .../widgets/src/beszel-system-table/index.ts | 1 + .../widgets/src/beszel/_shared/disk-usage.tsx | 3 +- .../widgets/src/beszel/_shared/tooltip.tsx | 9 +++--- packages/widgets/src/definition.spec.ts | 29 +++++++++++++++++- packages/widgets/src/definition.ts | 3 ++ packages/widgets/src/index.tsx | 1 + .../widgets/src/media-server/component.tsx | 8 ++--- 21 files changed, 119 insertions(+), 28 deletions(-) create mode 100644 apps/nextjs/src/components/board/advanced-focus/wheel.ts diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx index fd4232524..993974914 100644 --- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx @@ -4,8 +4,6 @@ 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"; @@ -36,8 +34,6 @@ 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-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index dafd02004..b09edf530 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -4,8 +4,6 @@ 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"; @@ -17,14 +15,14 @@ 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. -In compact mode, select a system row to open the complete statistics view. In advanced mode, selecting a row focuses an inline one-hour view with system, filesystem, network, and Docker charts. +Select a system row to open the complete statistics view. ### Interactions - **Reorder columns** by hovering a header to reveal its grab handle, then dragging it - **Resize columns** by dragging a header's right edge - **Sort systems** by selecting a sortable column header -- **Open or focus system statistics** by selecting a row, depending on the display mode +- **Open system statistics** by selecting a row ### Supported Integrations @@ -37,8 +35,6 @@ In compact mode, select a system row to open the complete statistics view. In ad ]} /> - - ### Adding the widget diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index 3749cd6ae..d0acb93b2 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -26,6 +26,7 @@ It shows playback progress, pause state, time remaining, and whether each sessio Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. 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 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 index 60cc31954..c108d72c8 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -35,7 +35,7 @@ .surfaceHostAdvanced { position: fixed; inset: 0; - z-index: calc(var(--mantine-z-index-modal) - 1); + z-index: calc(var(--mantine-z-index-popover) - 1); width: auto; height: auto; pointer-events: none; diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 3f38bdc44..4a5935efe 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -201,7 +201,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { data-advanced-focus-overlay backgroundOpacity={0.38} blur={0} - zIndex="calc(var(--mantine-z-index-modal) - 2)" + zIndex="calc(var(--mantine-z-index-popover) - 2)" className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`} aria-hidden /> diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts new file mode 100644 index 000000000..4cf5bc36d --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -0,0 +1,30 @@ +const canScrollVertically = (element: HTMLElement, delta: number) => { + if (element.scrollHeight <= element.clientHeight) return false; + + const overflowY = getComputedStyle(element).overflowY; + if (overflowY !== "auto" && overflowY !== "scroll") return false; + + return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight; +}; + +export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => { + if (delta === 0) return false; + + let current = target instanceof HTMLElement ? target : null; + while (current && root.contains(current)) { + if (canScrollVertically(current, delta)) { + current.scrollTop += delta; + return true; + } + if (current === root) break; + current = current.parentElement; + } + + const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find( + (element) => canScrollVertically(element, delta), + ); + if (!viewport) return false; + + viewport.scrollTop += delta; + return true; +}; diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index a572cdbad..0eb52868d 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react"; +import type { CSSProperties, KeyboardEvent, MutableRefObject, WheelEvent } from "react"; import { useEffect, useRef } from "react"; import { ActionIcon, Badge, Box, Card } from "@mantine/core"; import { useElementSize, useViewportSize } from "@mantine/hooks"; @@ -12,13 +12,20 @@ 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 { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import type { WidgetDefinition } from "@homarr/widgets"; +import { + loadWidgetDynamic, + reduceWidgetOptionsWithDefaultValues, + supportsAdvancedFocus as definitionSupportsAdvancedFocus, + widgetImports, +} from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import { useAdvancedFocus } from "../advanced-focus/context"; import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry"; +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"; @@ -48,7 +55,9 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const widgetStateRef = useRef | null>(null); const closeButtonRef = useRef(null); const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); - const supportsAdvancedFocus = item.kind !== "app"; + const supportsAdvancedFocus = definitionSupportsAdvancedFocus( + widgetImports[item.kind].definition as WidgetDefinition, + ); const advancedViewId = `advanced-focus-${item.id}`; const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const isAdvanced = activeFocus !== null; @@ -81,6 +90,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { event.preventDefault(); openAdvanced(true); }; + const handleWheel = (event: WheelEvent) => { + if (!isAdvanced || !event.shiftKey) return; + const delta = event.deltaY || event.deltaX; + if (!redirectShiftWheel(event.currentTarget, event.target, delta)) return; + event.preventDefault(); + }; return ( <> @@ -128,6 +143,8 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { id={isAdvanced ? advancedViewId : undefined} role={isAdvanced ? "region" : undefined} aria-label={isAdvanced ? advancedViewLabel : undefined} + data-advanced-focus-surface={isAdvanced || undefined} + onWheel={handleWheel} radius={board.itemRadius} p={isAdvanced ? undefined : 0} h={isAdvanced ? undefined : "100%"} 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 24b6f0103..2619371d2 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -33,6 +33,7 @@ import { getWidgetQueryKeys, getWidgetRuntimeQueries, reduceWidgetOptionsWithDefaultValues, + supportsAdvancedFocus, widgetImports, } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; @@ -70,6 +71,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); + const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition as WidgetDefinition); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); const { open: openAdvancedFocus } = useAdvancedFocus(); @@ -250,7 +252,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: > {children} - {item.kind !== "app" && ( + {canOpenAdvancedFocus && ( <> { await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount( 0, ); + await advancedSurface.evaluate((element) => { + const probe = document.createElement("div"); + probe.dataset.shiftScrollProbe = "true"; + probe.style.cssText = "height:80px;overflow-y:auto"; + probe.innerHTML = '
'; + element.append(probe); + }); + const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe]"); + await page.keyboard.down("Shift"); + await scrollProbe.dispatchEvent("wheel", { shiftKey: true, deltaX: 120, deltaY: 0 }); + await page.keyboard.up("Shift"); + expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); await page.keyboard.press("Escape"); await expect(advancedSurface).toBeHidden(); await expect(widget).toBeFocused(); diff --git a/packages/integrations/src/interfaces/media-server/media-server-types.ts b/packages/integrations/src/interfaces/media-server/media-server-types.ts index c9a287155..42b6f0615 100644 --- a/packages/integrations/src/interfaces/media-server/media-server-types.ts +++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts @@ -1,7 +1,7 @@ export interface StreamSession { sessionId: string; sessionName: string; - user: { + user?: { userId: string; username: string; profilePictureUrl: string | null; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index e7e7e407a..f019eab5f 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2891,6 +2891,7 @@ "items": { "currentlyPlaying": "Currently playing", "user": "User", + "unknownUser": "Unknown user", "name": "Name", "id": "Id", "status": "Status", diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index 2b03f0e99..7b8416ca9 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -15,6 +15,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("app", { icon: IconApps, + supportsAdvancedFocus: false, queryKeys: [[["app", "byId"]], [["widget", "app", "ping"]]], createOptions(settings) { return optionsBuilder.from( diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 08127b39e..1c6266b8e 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -72,7 +72,7 @@ export default function AudioStatsWidget({ {currentlyPlaying.name} - {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username} + {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user?.username ?? "—"} query.path.at(-1) === "getSystems" diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx index 9aa2a14ad..826d5c3c8 100644 --- a/packages/widgets/src/beszel/_shared/disk-usage.tsx +++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx @@ -1,4 +1,4 @@ -import { Box, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core"; +import { Box, getDefaultZIndex, Group, HoverCard, Progress, Stack, Text, UnstyledButton } from "@mantine/core"; import type { BeszelSystemRow } from "./types"; import { thresholdColor } from "./colors"; @@ -28,6 +28,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = {formatPercent(system.disk)} { const mouseRef = useRef({ x: 0, y: 0 }); - const [pos, setPos] = useState({ x: 0, y: 0 }); + const [pos, setPos] = useState({ x: 0, y: 0, isAdvanced: false }); const rafRef = useRef(0); const tooltipRef = useRef(null); @@ -72,9 +72,10 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: const el = tooltipRef.current; const mx = mouseRef.current.x; const my = mouseRef.current.y; + const isAdvanced = Boolean(document.elementFromPoint(mx, my)?.closest("[data-advanced-focus-surface]")); if (!el) { - setPos({ x: mx + MARGIN, y: my }); + setPos({ x: mx + MARGIN, y: my, isAdvanced }); return; } @@ -89,7 +90,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: if (y < MARGIN) y = MARGIN; if (y + height > vh - MARGIN) y = vh - MARGIN - height; - setPos({ x, y }); + setPos({ x, y, isAdvanced }); }); }, []); @@ -122,7 +123,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: position: "fixed", left: pos.x, top: pos.y, - zIndex: TOOLTIP_Z_INDEX, + zIndex: pos.isAdvanced ? getDefaultZIndex("popover") : TOOLTIP_Z_INDEX, pointerEvents: "none", }; diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts index 59810c9c4..94089b161 100644 --- a/packages/widgets/src/definition.spec.ts +++ b/packages/widgets/src/definition.spec.ts @@ -3,7 +3,15 @@ import { getQueryKey } from "@trpc/react-query"; import { clientApi } from "@homarr/api/client"; -import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition"; +import { definition as appDefinition } from "./app"; +import { definition as beszelSystemGridDefinition } from "./beszel-system-grid"; +import { definition as beszelSystemTableDefinition } from "./beszel-system-table"; +import { + getWidgetQueryKeys, + getWidgetRuntimeQueries, + setWidgetRuntimeQueries, + supportsAdvancedFocus, +} from "./definition"; const togglePolling = () => undefined; @@ -36,3 +44,22 @@ describe("getWidgetQueryKeys", () => { expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual([{ path: ["widget", "calendar", "findAllEvents"], input }]); }); }); + +describe("supportsAdvancedFocus", () => { + const definition = { supportsAdvancedFocus: undefined } as Parameters[0]; + + test("defaults to enabled", () => { + expect(supportsAdvancedFocus(definition)).toBe(true); + }); + + test("allows widgets to opt out", () => { + expect(supportsAdvancedFocus({ ...definition, supportsAdvancedFocus: false })).toBe(false); + }); + + test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])( + "keeps non-enhanced widgets compact", + (widgetDefinition) => { + expect(supportsAdvancedFocus(widgetDefinition)).toBe(false); + }, + ); +}); diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts index f2c432f29..2153efda5 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -86,6 +86,7 @@ export const createWidgetDefinition = , "kind">) => WidgetContextMenuAction[]; } +export const supportsAdvancedFocus = (definition: WidgetDefinition) => definition.supportsAdvancedFocus !== false; + export const getWidgetQueryKeys = (definition: { kind: string; queryKey?: QueryKey; diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index dd870cf22..6b420f413 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -78,6 +78,7 @@ export { getWidgetQueryKeys, getWidgetRuntimeQueries, normalizeWidgetQuery, + supportsAdvancedFocus, widgetQueryValueEquals, } from "./definition"; export type { WidgetComponentProps }; diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index 862376d1b..2d9697aeb 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -81,10 +81,10 @@ export default function MediaServerWidget({ Cell: ({ row }) => ( - + - {row.original.user.username} + {row.original.user?.username ?? t("items.unknownUser")} (({ innerProps }) => { itemKey={t("user")} value={ - {" "} - {innerProps.item.user.username} + {" "} + {innerProps.item.user?.username ?? t("unknownUser")} } /> From a2f5668c44e49074a0c07b4a9f27a042b617ced5 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 12:21:31 +0200 Subject: [PATCH 26/75] fix focus scroll layering --- .../advanced-focus/advanced-focus.module.css | 2 +- .../board/advanced-focus/context.tsx | 2 +- .../components/board/items/item-content.tsx | 27 +++++++++++++------ e2e/board-advanced-interactions.spec.ts | 21 +++++++++++++-- .../media-server/media-server-types.ts | 2 +- .../widgets/src/beszel/_shared/disk-usage.tsx | 2 +- .../widgets/src/beszel/_shared/tooltip.tsx | 4 +-- packages/widgets/src/definition.spec.ts | 10 ------- 8 files changed, 44 insertions(+), 26 deletions(-) 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 index c108d72c8..60cc31954 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -35,7 +35,7 @@ .surfaceHostAdvanced { position: fixed; inset: 0; - z-index: calc(var(--mantine-z-index-popover) - 1); + z-index: calc(var(--mantine-z-index-modal) - 1); width: auto; height: auto; pointer-events: none; diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 4a5935efe..3f38bdc44 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -201,7 +201,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { data-advanced-focus-overlay backgroundOpacity={0.38} blur={0} - zIndex="calc(var(--mantine-z-index-popover) - 2)" + zIndex="calc(var(--mantine-z-index-modal) - 2)" className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`} aria-hidden /> diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 0eb52868d..32e71fe0a 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,4 +1,4 @@ -import type { CSSProperties, KeyboardEvent, MutableRefObject, WheelEvent } from "react"; +import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react"; import { useEffect, useRef } from "react"; import { ActionIcon, Badge, Box, Card } from "@mantine/core"; import { useElementSize, useViewportSize } from "@mantine/hooks"; @@ -54,6 +54,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const [isEditMode] = useEditMode(); const widgetStateRef = useRef | null>(null); const closeButtonRef = useRef(null); + const surfaceRef = useRef(null); const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); const supportsAdvancedFocus = definitionSupportsAdvancedFocus( widgetImports[item.kind].definition as WidgetDefinition, @@ -90,12 +91,22 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { event.preventDefault(); openAdvanced(true); }; - const handleWheel = (event: WheelEvent) => { - if (!isAdvanced || !event.shiftKey) return; - const delta = event.deltaY || event.deltaX; - if (!redirectShiftWheel(event.currentTarget, event.target, delta)) return; - event.preventDefault(); - }; + useEffect(() => { + const surface = surfaceRef.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]); return ( <> @@ -140,11 +151,11 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { )} { probe.dataset.shiftScrollProbe = "true"; probe.style.cssText = "height:80px;overflow-y:auto"; probe.innerHTML = '
'; + probe.addEventListener("wheel", (event) => event.stopPropagation()); element.append(probe); }); const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe]"); await page.keyboard.down("Shift"); - await scrollProbe.dispatchEvent("wheel", { shiftKey: true, deltaX: 120, deltaY: 0 }); + const pixelEventCancelled = await scrollProbe.evaluate( + (element) => + !element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), + ), + ); + expect(pixelEventCancelled).toBe(true); + await scrollProbe.evaluate((element) => { + element.scrollTop = 0; + }); + const lineEventCancelled = await scrollProbe.evaluate( + (element) => + !element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }), + ), + ); await page.keyboard.up("Shift"); - expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThan(0); + expect(lineEventCancelled).toBe(true); + expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48); await page.keyboard.press("Escape"); await expect(advancedSurface).toBeHidden(); await expect(widget).toBeFocused(); diff --git a/packages/integrations/src/interfaces/media-server/media-server-types.ts b/packages/integrations/src/interfaces/media-server/media-server-types.ts index 42b6f0615..c9a287155 100644 --- a/packages/integrations/src/interfaces/media-server/media-server-types.ts +++ b/packages/integrations/src/interfaces/media-server/media-server-types.ts @@ -1,7 +1,7 @@ export interface StreamSession { sessionId: string; sessionName: string; - user?: { + user: { userId: string; username: string; profilePictureUrl: string | null; diff --git a/packages/widgets/src/beszel/_shared/disk-usage.tsx b/packages/widgets/src/beszel/_shared/disk-usage.tsx index 826d5c3c8..934981daf 100644 --- a/packages/widgets/src/beszel/_shared/disk-usage.tsx +++ b/packages/widgets/src/beszel/_shared/disk-usage.tsx @@ -28,7 +28,7 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap = {formatPercent(system.disk)}
{ const mouseRef = useRef({ x: 0, y: 0 }); @@ -123,7 +123,7 @@ const PortalTooltipContent = ({ active, label, payload, formatter, showTotal }: position: "fixed", left: pos.x, top: pos.y, - zIndex: pos.isAdvanced ? getDefaultZIndex("popover") : TOOLTIP_Z_INDEX, + zIndex: pos.isAdvanced ? getDefaultZIndex("modal") : BACKGROUND_TOOLTIP_Z_INDEX, pointerEvents: "none", }; diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts index 94089b161..314be143f 100644 --- a/packages/widgets/src/definition.spec.ts +++ b/packages/widgets/src/definition.spec.ts @@ -46,16 +46,6 @@ describe("getWidgetQueryKeys", () => { }); describe("supportsAdvancedFocus", () => { - const definition = { supportsAdvancedFocus: undefined } as Parameters[0]; - - test("defaults to enabled", () => { - expect(supportsAdvancedFocus(definition)).toBe(true); - }); - - test("allows widgets to opt out", () => { - expect(supportsAdvancedFocus({ ...definition, supportsAdvancedFocus: false })).toBe(false); - }); - test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])( "keeps non-enhanced widgets compact", (widgetDefinition) => { From 20dbdcedf24c4054ca9c406d27276ffc4c2d9468 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 12:25:56 +0200 Subject: [PATCH 27/75] fix focused pane scrolling --- .../src/components/board/advanced-focus/wheel.ts | 14 ++++++++++---- e2e/board-advanced-interactions.spec.ts | 15 ++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts index 4cf5bc36d..285fb22ec 100644 --- a/apps/nextjs/src/components/board/advanced-focus/wheel.ts +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -1,9 +1,11 @@ -const canScrollVertically = (element: HTMLElement, delta: number) => { +const isVerticalScrollContainer = (element: HTMLElement) => { if (element.scrollHeight <= element.clientHeight) return false; const overflowY = getComputedStyle(element).overflowY; - if (overflowY !== "auto" && overflowY !== "scroll") return false; + return overflowY === "auto" || overflowY === "scroll"; +}; +const canScrollVertically = (element: HTMLElement, delta: number) => { return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight; }; @@ -11,17 +13,21 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (delta === 0) return false; let current = target instanceof HTMLElement ? target : null; + let startedInScrollContainer = false; while (current && root.contains(current)) { - if (canScrollVertically(current, delta)) { + const isCurrentScrollContainer = isVerticalScrollContainer(current); + startedInScrollContainer ||= isCurrentScrollContainer; + if (isCurrentScrollContainer && canScrollVertically(current, delta)) { current.scrollTop += delta; return true; } if (current === root) break; current = current.parentElement; } + if (startedInScrollContainer) return false; const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find( - (element) => canScrollVertically(element, delta), + (element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta), ); if (!viewport) return false; diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 17f07f5cd..0b3f12bbb 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -122,8 +122,12 @@ describe("Board advanced interactions", () => { probe.innerHTML = '
'; probe.addEventListener("wheel", (event) => event.stopPropagation()); element.append(probe); + + const unrelated = probe.cloneNode(true) as HTMLDivElement; + unrelated.dataset.shiftScrollProbe = "unrelated"; + element.append(unrelated); }); - const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe]"); + const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe='true']"); await page.keyboard.down("Shift"); const pixelEventCancelled = await scrollProbe.evaluate( (element) => @@ -144,6 +148,15 @@ describe("Board advanced interactions", () => { await page.keyboard.up("Shift"); expect(lineEventCancelled).toBe(true); expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48); + await scrollProbe.evaluate((element) => { + element.scrollTop = element.scrollHeight; + element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), + ); + }); + expect( + await advancedSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), + ).toBe(0); await page.keyboard.press("Escape"); await expect(advancedSurface).toBeHidden(); await expect(widget).toBeFocused(); From 3c99c75ad757e0a48d476a5c54eddfdceac88755 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 12:51:10 +0200 Subject: [PATCH 28/75] fix SVG focus scrolling --- .../src/components/board/advanced-focus/wheel.ts | 6 +++--- e2e/board-advanced-interactions.spec.ts | 16 +++++++++------- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts index 285fb22ec..193fa5dde 100644 --- a/apps/nextjs/src/components/board/advanced-focus/wheel.ts +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -1,18 +1,18 @@ -const isVerticalScrollContainer = (element: HTMLElement) => { +const isVerticalScrollContainer = (element: Element) => { if (element.scrollHeight <= element.clientHeight) return false; const overflowY = getComputedStyle(element).overflowY; return overflowY === "auto" || overflowY === "scroll"; }; -const canScrollVertically = (element: HTMLElement, delta: number) => { +const canScrollVertically = (element: Element, delta: number) => { return delta < 0 ? element.scrollTop > 0 : element.scrollTop + element.clientHeight < element.scrollHeight; }; export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null, delta: number) => { if (delta === 0) return false; - let current = target instanceof HTMLElement ? target : null; + let current = target instanceof Element ? target : null; let startedInScrollContainer = false; while (current && root.contains(current)) { const isCurrentScrollContainer = isVerticalScrollContainer(current); diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 0b3f12bbb..8ffd0c237 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -119,7 +119,7 @@ describe("Board advanced interactions", () => { const probe = document.createElement("div"); probe.dataset.shiftScrollProbe = "true"; probe.style.cssText = "height:80px;overflow-y:auto"; - probe.innerHTML = '
'; + probe.innerHTML = '
'; probe.addEventListener("wheel", (event) => event.stopPropagation()); element.append(probe); @@ -139,12 +139,14 @@ describe("Board advanced interactions", () => { await scrollProbe.evaluate((element) => { element.scrollTop = 0; }); - const lineEventCancelled = await scrollProbe.evaluate( - (element) => - !element.dispatchEvent( - new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }), - ), - ); + const lineEventCancelled = await scrollProbe + .locator("[data-shift-scroll-target='true']") + .evaluate( + (element) => + !element.dispatchEvent( + new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }), + ), + ); await page.keyboard.up("Shift"); expect(lineEventCancelled).toBe(true); expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48); From 294217fcbaae7b3e4a1df07145926432406f3ed7 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 13:09:56 +0200 Subject: [PATCH 29/75] fix board E2E login race --- e2e/board-advanced-interactions.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 8ffd0c237..51c06c96a 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -327,8 +327,9 @@ const openBoardAsync = async ( await page.goto(`${baseUrl}/auth/login`); await page.getByLabel("Username").fill(credentials.username); await page.locator("#password").fill(credentials.password); + const loginRedirect = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" }); await page.locator("css=button[type='submit']").click(); - await page.waitForURL(baseUrl, { timeout: 15_000 }); + await loginRedirect; await page.goto(`${baseUrl}/boards/${boardName}`); await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({ timeout: 15_000, From 6e15f8bcf82c56c482b18b0b28aab0165a65337a Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 13:24:13 +0200 Subject: [PATCH 30/75] fix context menu keyboard focus --- .../board/sections/gridstack/board-empty-space-context-menu.tsx | 2 ++ e2e/board-advanced-interactions.spec.ts | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) 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 index f1dfc2678..b2a31eadc 100644 --- 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 @@ -182,6 +182,7 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp { if (!opened) closeMenu(); }} @@ -194,6 +195,7 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp } onClick={() => { const placement = menu?.placement; diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 51c06c96a..a8b40af5b 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -255,8 +255,6 @@ describe("Board advanced interactions", () => { await expect(grid).toHaveAttribute("aria-label", "Add item here"); await grid.press("Shift+F10"); - await expect(addHereMenuItem).toBeVisible(); - await page.keyboard.press("ArrowDown"); await expect(addHereMenuItem).toBeFocused(); await page.keyboard.press("Enter"); await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); From 80d46b0d852f25ce7a11ba365e295e02159f3db4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 13:28:12 +0200 Subject: [PATCH 31/75] restore context menu focus --- .../sections/gridstack/board-empty-space-context-menu.tsx | 3 +++ e2e/board-advanced-interactions.spec.ts | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) 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 index b2a31eadc..ac1673369 100644 --- 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 @@ -101,6 +101,7 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp if (!placement) return; event.preventDefault(); event.stopPropagation(); + returnFocusRef.current = event.currentTarget; setMenu({ x: event.clientX, y: event.clientY, placement }); }; @@ -108,11 +109,13 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp if (!enabled || event.pointerType !== "touch") return; const placement = resolvePlacement(event.target, event.pageX, event.pageY); if (!placement) return; + const returnFocus = event.currentTarget; clearLongPress(); longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY }; touchGuardElement.current = event.currentTarget; touchGuardElement.current.classList.add(suppressTouchCalloutClass); longPressTimer.current = setTimeout(() => { + returnFocusRef.current = returnFocus; setMenu({ x: event.clientX, y: event.clientY, placement }); navigator.vibrate?.(20); }, 500); diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index a8b40af5b..c37e34e2b 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -265,11 +265,14 @@ describe("Board advanced interactions", () => { await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 }); await page.keyboard.press("Escape"); await expect(addHereMenuItem).toBeHidden(); + await expect(grid).toBeFocused(); await rightClickEmptyGridSpaceAsync(grid); await expect(addHereMenuItem).toBeVisible(); + await addHereMenuItem.click(); + await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); await page.keyboard.press("Escape"); - await expect(addHereMenuItem).toBeHidden(); + await expect(grid).toBeFocused(); } finally { await context.close(); } From 65ff0ecb340810d0d0c9f9e6551fd401d7be2cd1 Mon Sep 17 00:00:00 2001 From: ajnart Date: Mon, 3 Aug 2026 14:26:56 +0200 Subject: [PATCH 32/75] docs: add widget modernization audit --- MANTINE.md | 474 +++++++++++++++++++++++++++++++++++++++++++++++++++++ REVIEW.md | 263 +++++++++++++++++++++++++++++ 2 files changed, 737 insertions(+) create mode 100644 MANTINE.md create mode 100644 REVIEW.md diff --git a/MANTINE.md b/MANTINE.md new file mode 100644 index 000000000..a87cbbd48 --- /dev/null +++ b/MANTINE.md @@ -0,0 +1,474 @@ +# Mantine UI - LLM Documentation + +This index lists Mantine documentation pages formatted for LLMs. +Each link points to a standalone Markdown file under the /llms path. + +For a single consolidated file with all content, use: +- https://mantine.dev/llms-full.txt + +## FAQ + +- [Are Mantine components accessible?](https://mantine.dev/llms/q-are-mantine-components-accessible.md): Learn about Mantine components accessibility features +- [Browser zooms in when input is focused. What should I do?](https://mantine.dev/llms/q-browser-zooms-on-focus.md): Use meta tag to disable browser scaling or increase input size +- [Can I have color schemes other than light and dark?](https://mantine.dev/llms/q-light-dark-is-not-enough.md): Learn about the difference between color scheme and theme +- [Can I have different primary color for light and dark color schemes?](https://mantine.dev/llms/q-primary-virtual-color.md): Learn how to use virtual color with primary color in theme object +- [Can I remove MultiSelect placeholder when the component has selected values?](https://mantine.dev/llms/q-multiselect-value-placeholder.md): Learn why MultiSelect placeholder is not removed when values are selected and how to remove it with CSS +- [Can I use an array of strings as a list in use-form?](https://mantine.dev/llms/q-list-of-strings-in-use-form.md): Learn about use-form lists limitations +- [Can I use Mantine components as server components?](https://mantine.dev/llms/q-server-components.md): Learn about use client directive and server components usage +- [Can I use Mantine with Astro?](https://mantine.dev/llms/q-can-i-use-mantine-with-astro.md): No, Astro does not support React context +- [Can I use Mantine with Create React App (CRA)?](https://mantine.dev/llms/q-can-i-use-mantine-with-cra.md): Learn how to use Mantine without postcss-preset-mantine or how to eject CRA and customize webpack.config.js and use postcss-preset-mantine +- [Can I use Mantine with Emotion/styled-components/tailwindcss?](https://mantine.dev/llms/q-third-party-styles.md): Learn about limitations of third-party styles +- [Can I use Mantine with Vue/Svelte/Angular/etc.?](https://mantine.dev/llms/q-vue-svelte-angular.md): No, Mantine is a React library and does not support other frameworks/libraries +- [Can I use nested inline styles with Mantine components?](https://mantine.dev/llms/q-nested-inline-styles.md): Nested styles are supported only in CSS files +- [Can I use PostCSS function in inline styles?](https://mantine.dev/llms/q-postcss-fns-inline.md): Learn where PostCSS functions can be used in Mantine +- [Can I use private CSS variables to style components?](https://mantine.dev/llms/q-private-css-variables.md): No, it is not safe and will not work with future versions of Mantine. +- [Can I use SegmentedControl with empty value?](https://mantine.dev/llms/q-segmented-control-no-value.md): SegmentedControl cannot be used without a value +- [How can I add dynamic CSS styles?](https://mantine.dev/llms/q-dynamic-css-styles.md): Use data attributes, CSS variables or inline styles +- [How can I add fuzzy search to Select component?](https://mantine.dev/llms/q-select-fuzzy.md): Learn how to integrate third-party fuzzy search libraries with Mantine Select component +- [How can I add hover styles to an element?](https://mantine.dev/llms/q-how-to-add-hover-styles.md): Learn how to add hover classes to an element with CSS modules, &:hover or @mixin hover +- [How can I apply styles to all Mantine components?](https://mantine.dev/llms/q-apply-styles-to-all.md): Learn how to use attribute selector to apply styles to all Mantine components +- [How can I change body background color?](https://mantine.dev/llms/q-body-background.md): Use CSS to change body background color +- [How can I change component color prop value depending on the color scheme?](https://mantine.dev/llms/q-color-scheme-color.md): Learn how to use CSS variables resolver to change color value depending on the color scheme +- [How can I change input focus styles?](https://mantine.dev/llms/q-input-focus-styles.md): Learn how to use Styles API with Mantine inputs +- [How can I change Tabs border color?](https://mantine.dev/llms/q-tabs-border-color.md): Learn how to use Styles API with Tabs component +- [How can I contribute to the library?](https://mantine.dev/llms/q-how-can-i-contribute.md): There are multiple ways to contribute even without writing code +- [How can I disable all inputs/input groups inside a form?](https://mantine.dev/llms/q-disable-all-inputs-in-form.md): Learn how to disable all inputs/input groups inside a form with the unstyled Fieldset component +- [How can I display different elements in light and dark color schemes?](https://mantine.dev/llms/q-light-dark-elements.md): Learn how to hide/show elements based on color scheme +- [How can I focus the first input with error with use-form?](https://mantine.dev/llms/q-focus-first-input-with-error.md): Learn how to handle focus with use-form hook +- [How can I get current color scheme value in JavaScript?](https://mantine.dev/llms/q-how-to-get-color-scheme-value-in-js.md): How to use useMantineColorScheme and useComputedColorScheme hooks to get current color scheme value in JavaScript +- [How can I lint CSS files?](https://mantine.dev/llms/q-how-to-setup-stylelint.md): Learn how to set up Stylelint for Mantine postcss syntax +- [How can I load fonts in Next.js?](https://mantine.dev/llms/q-next-load-fonts.md): A guide to load custom fonts in Next.js with CSS and next/font package +- [How can I load fonts in React Router?](https://mantine.dev/llms/q-react-router-load-fonts.md): A guide to load custom fonts in React Router +- [How can I load fonts in Vite?](https://mantine.dev/llms/q-vite-load-fonts.md): A guide to load custom fonts in Vite +- [How can I lock scroll in my application?](https://mantine.dev/llms/q-how-to-lock-scroll.md): Use react-remove-scroll library to lock scroll in your application +- [How can I submit a template to Mantine documentation?](https://mantine.dev/llms/q-submit-template.md): Learn how to create and submit a template to Mantine documentation +- [How can I test Modal/Drawer/Popover components?](https://mantine.dev/llms/q-portals-testing.md): Learn how to use react-testing-library to test components that use portals and transitions. +- [How can I test Select/MultiSelect components?](https://mantine.dev/llms/q-combobox-testing.md): Learn how to use react-testing-library to test Select and MultiSelect components. +- [How can I upload files from Dropzone component?](https://mantine.dev/llms/q-dropzone-upload.md): Learn how to process files dropped into Dropzone component +- [How is that thing done on the mantine.dev website?](https://mantine.dev/llms/q-how-that-thing-is-done.md): Learn how various elements are implemented on the mantine.dev website +- [How to align input with a button in a flex container?](https://mantine.dev/llms/q-align-input-button.md): Learn how to align Mantine inputs with buttons using Group component +- [How to call a function when Modal/Drawer closes and animation completes?](https://mantine.dev/llms/q-how-to-call-function-when-modal-closes.md): How to use transitionProps in Modal/Drawer components +- [How to change background color for FiveM?](https://mantine.dev/llms/q-fivem-background-color.md): Unset body background color and color-scheme styles for FiveM +- [How to change input placeholder color?](https://mantine.dev/llms/q-inputs-placeholder-color.md): Learn how to change placeholder color with Styles API +- [How to integrate custom input with use-form hook?](https://mantine.dev/llms/q-custom-input-use-form.md): Learn how to add use-form support for custom inputs +- [How to prevent Modal from closing?](https://mantine.dev/llms/q-how-to-prevent-modal-from-closing.md): Learn how to prevent Modal from closing when the user clicks outside of it or presses the Escape key +- [How to scroll to the top of the form if the form is submitted with errors?](https://mantine.dev/llms/q-scroll-to-the-top-of-the-form.md): Learn how to call a function when the form is submitted with errors +- [How to update Mantine dependencies?](https://mantine.dev/llms/q-how-to-update-dependencies.md): Learn how to update @mantine/*, @mantinex/* and postcss-preset-mantine dependencies manually or with ncu script +- [How to use Dropzone with @mantine/form?](https://mantine.dev/llms/q-how-to-use-dropzone-with-form.md): Learn how to use Dropzone with @mantine/form to handle file selection state +- [How to use Mantine template on GitHub?](https://mantine.dev/llms/q-templates-usage.md): Learn how to create a new repository based on Mantine template +- [I get hydration warning about data-mantine-color-scheme attribute, what does it mean?](https://mantine.dev/llms/q-color-scheme-hydration-warning.md): Learn how hydration works and how to fix warnings +- [Is there a comparison with other libraries?](https://mantine.dev/llms/q-other-libs.md): Comparison with other libraries is not provided, you are trusted to make your own decision based on documentation examples +- [Is there a floating action button component?](https://mantine.dev/llms/q-floating-action-button.md): No, but you can build it with Affix and ActionIcon components +- [Is there DataGrid component that I can use with Mantine?](https://mantine.dev/llms/q-data-grid-i-need.md): A list of community packages that provide DataGrid component that can be used with Mantine. +- [It is not possible to pinch to zoom when a Modal is opened. What should I do?](https://mantine.dev/llms/q-pinch-to-zoom-modal.md): Use removeScrollProps to configure react-remove-scroll options +- [MantineProvider was not found in component tree. What should I do?](https://mantine.dev/llms/q-mantine-provider-missing.md): Learn how to resolve MantineProvider issues +- [My buttons are transparent and the background is visible only on hover, what is wrong?](https://mantine.dev/llms/q-transparent-buttons.md): You have installed a third-party library that overrides Mantine styles +- [My Popover dropdown closes when I click on the dropdown of a nested Popover](https://mantine.dev/llms/q-nested-popover-closes.md): Popover dropdown is closed when it detects click-outside events +- [My styles are broken with disabled JavaScript. What should I do?](https://mantine.dev/llms/q-disabled-js.md): Learn how to use Mantine without JavaScript enabled +- [My styles are overridden by Mantine components styles, what should I do?](https://mantine.dev/llms/q-styles-order.md): Learn how to use CSS layers to control styles order and prevent Mantine components from overriding your styles +- [Native browser validation does not work in some components, what should I do?](https://mantine.dev/llms/q-native-required.md): Learn why native browser validation does not work in some components +- [The use-local-storage hook returns the real value only after mounting. Is it a bug?](https://mantine.dev/llms/q-local-storage-effect.md): Learn how Mantine retrieves local storage value +- [What is the difference between searchable Select and Autocomplete?](https://mantine.dev/llms/q-select-autocomplete-difference.md): Searchable Select and Autocomplete are similar components, but they serve different purposes. +- [Where can I find the roadmap?](https://mantine.dev/llms/q-roadmap.md): The roadmap is private and only available to the team. +- [Why are my Carousel slides in vertical orientation?](https://mantine.dev/llms/q-carousel-missing-styles.md): You forgot to import carousel styles +- [Why are my notifications displayed at the wrong position?](https://mantine.dev/llms/q-notifications-missing-styles.md): Because you did not import styles +- [Why can I not use one polymorphic component in the component prop of another polymorphic component?](https://mantine.dev/llms/q-polymorphic-in-polymorphic.md): Learn how polymorphic components types work +- [Why can I not use value/label data structure with Autocomplete/TagsInput?](https://mantine.dev/llms/q-autocomplete-value-label.md): Learn the difference between Autocomplete and Select components +- [Why can VSCode not autoimport the Text component?](https://mantine.dev/llms/q-why-vscode-cannot-autoimport-text.md): It is confused by the native Text constructor +- [Why do I see color scheme flickering on page load?](https://mantine.dev/llms/q-color-scheme-flickering.md): Color scheme flickering is caused by incorrect usage of ColorSchemeScript +- [Why do my dates components look broken?](https://mantine.dev/llms/q-dates-missing-styles.md): Because you did not import styles +- [Why does FileButton not work in Menu?](https://mantine.dev/llms/q-file-button-in-menu.md): Learn how to use FileButton in Menu component +- [Why is it required to have 10 shades per color?](https://mantine.dev/llms/q-ten-shades-per-color.md): Learn how Mantine uses theme colors +- [Why is my screen completely empty after I](https://mantine.dev/llms/q-notifications-empty-screen.md): You have used Notifications component incorrectly + +## Core + +- [Accordion](https://mantine.dev/llms/core-accordion.md): Divide content into collapsible sections +- [ActionIcon](https://mantine.dev/llms/core-action-icon.md): Icon button +- [Affix](https://mantine.dev/llms/core-affix.md): Renders children inside portal at fixed position +- [Alert](https://mantine.dev/llms/core-alert.md): Attract user attention with important static message +- [AlphaSlider](https://mantine.dev/llms/core-alpha-slider.md): Slider component for selecting alpha channel in color pickers (0 – 1) +- [Anchor](https://mantine.dev/llms/core-anchor.md): Display link with theme styles +- [AngleSlider](https://mantine.dev/llms/core-angle-slider.md): Pick angle value between 0 and 360 +- [AppShell](https://mantine.dev/llms/core-app-shell.md): Responsive shell for your application with header, navbar, aside and footer +- [AspectRatio](https://mantine.dev/llms/core-aspect-ratio.md): Maintain responsive consistent width/height ratio +- [Autocomplete](https://mantine.dev/llms/core-autocomplete.md): Autocomplete user input with any list of options +- [Avatar](https://mantine.dev/llms/core-avatar.md): Display user profile image, initials or fallback icon +- [BackgroundImage](https://mantine.dev/llms/core-background-image.md): Displays image as background +- [Badge](https://mantine.dev/llms/core-badge.md): Display badge, pill or tag +- [Blockquote](https://mantine.dev/llms/core-blockquote.md): Blockquote with optional cite +- [Box](https://mantine.dev/llms/core-box.md): Base component for all Mantine components +- [Breadcrumbs](https://mantine.dev/llms/core-breadcrumbs.md): Separates list of react nodes with given separator +- [Burger](https://mantine.dev/llms/core-burger.md): Open/close navigation button +- [Button](https://mantine.dev/llms/core-button.md): Button component to render button or link +- [Card](https://mantine.dev/llms/core-card.md): Card with sections +- [Cascader](https://mantine.dev/llms/core-cascader.md): Select a path from hierarchical data with cascading columns +- [Center](https://mantine.dev/llms/core-center.md): Centers content vertically and horizontally +- [Checkbox](https://mantine.dev/llms/core-checkbox.md): Capture boolean input from user +- [Chip](https://mantine.dev/llms/core-chip.md): Pick one or multiple values with inline controls +- [CloseButton](https://mantine.dev/llms/core-close-button.md): Button with close icon +- [Code](https://mantine.dev/llms/core-code.md): Inline and block code +- [Collapse](https://mantine.dev/llms/core-collapse.md): Animate presence with slide down/up transition +- [ColorInput](https://mantine.dev/llms/core-color-input.md): Capture color from user +- [ColorPicker](https://mantine.dev/llms/core-color-picker.md): Pick colors in hex(a), rgb(a), hsl(a) and hsv(a) formats +- [ColorSwatch](https://mantine.dev/llms/core-color-swatch.md): Displays color +- [Combobox](https://mantine.dev/llms/core-combobox.md): Create custom select, autocomplete or multiselect inputs +- [ComboboxPopover](https://mantine.dev/llms/core-combobox-popover.md): Add combobox dropdown to any button element +- [Container](https://mantine.dev/llms/core-container.md): Center content with padding and max-width +- [CopyButton](https://mantine.dev/llms/core-copy-button.md): Copies given text to clipboard +- [CorePackage](https://mantine.dev/llms/core-package.md) +- [DataList](https://mantine.dev/llms/core-data-list.md): Display label-value pairs as a description list +- [Dialog](https://mantine.dev/llms/core-dialog.md): Display a fixed overlay dialog at any side of the screen +- [Divider](https://mantine.dev/llms/core-divider.md): Horizontal line with optional label or vertical divider +- [Drawer](https://mantine.dev/llms/core-drawer.md): Display overlay area at any side of the screen +- [EmptyState](https://mantine.dev/llms/core-empty-state.md): Display a placeholder for no data, empty results and first-run states +- [Fieldset](https://mantine.dev/llms/core-fieldset.md): Group related elements in a form +- [FileButton](https://mantine.dev/llms/core-file-button.md): Open file picker with a button click +- [FileInput](https://mantine.dev/llms/core-file-input.md): Capture files from user +- [Flex](https://mantine.dev/llms/core-flex.md): Compose elements in a flex container +- [FloatingIndicator](https://mantine.dev/llms/core-floating-indicator.md): Display a floating indicator over a group of elements +- [FloatingWindow](https://mantine.dev/llms/core-floating-window.md): Draggable floating area +- [FocusTrap](https://mantine.dev/llms/core-focus-trap.md): Trap focus at child node +- [Grid](https://mantine.dev/llms/core-grid.md): Responsive 12 columns grid system +- [Group](https://mantine.dev/llms/core-group.md): Compose elements and components in a horizontal flex container +- [Highlight](https://mantine.dev/llms/core-highlight.md): Highlight given part of a string with mark +- [HoverCard](https://mantine.dev/llms/core-hover-card.md): Display popover section when target element is hovered +- [HueSlider](https://mantine.dev/llms/core-hue-slider.md): Slider component for selecting hue channel in color pickers (0 – 360 degrees) +- [Image](https://mantine.dev/llms/core-image.md): Image with optional fallback +- [Indicator](https://mantine.dev/llms/core-indicator.md): Display element at the corner of another element +- [Input](https://mantine.dev/llms/core-input.md): Base component to create custom inputs +- [JsonInput](https://mantine.dev/llms/core-json-input.md): Capture json data from user +- [Kbd](https://mantine.dev/llms/core-kbd.md): Display keyboard key +- [List](https://mantine.dev/llms/core-list.md): Display ordered or unordered list +- [Loader](https://mantine.dev/llms/core-loader.md): Indicate loading state +- [LoadingOverlay](https://mantine.dev/llms/core-loading-overlay.md): An overlay with centered loader +- [Mark](https://mantine.dev/llms/core-mark.md): Highlight part of the text +- [Marquee](https://mantine.dev/llms/core-marquee.md): Create continuous scrolling animation for content +- [MaskInput](https://mantine.dev/llms/core-mask-input.md): Input with mask pattern for formatted text entry +- [Menu](https://mantine.dev/llms/core-menu.md): Combine a list of secondary actions into single interactive area +- [Menubar](https://mantine.dev/llms/core-menubar.md): Desktop application style menubar with a row of menu triggers +- [Modal](https://mantine.dev/llms/core-modal.md): An accessible overlay dialog +- [MultiSelect](https://mantine.dev/llms/core-multi-select.md): Custom searchable multi select +- [NativeSelect](https://mantine.dev/llms/core-native-select.md): Native select element based on Input +- [NavLink](https://mantine.dev/llms/core-nav-link.md): Navigation link +- [Notification](https://mantine.dev/llms/core-notification.md): Show dynamic notifications and alerts to user, part of notifications system +- [NumberFormatter](https://mantine.dev/llms/core-number-formatter.md): Format number with thousands/decimal separators and suffix/prefix +- [NumberInput](https://mantine.dev/llms/core-number-input.md): Capture number from user +- [OverflowList](https://mantine.dev/llms/core-overflow-list.md): Hide items that do not fit in a container and display them as a single collapsed item +- [Overlay](https://mantine.dev/llms/core-overlay.md): Overlays parent element with div element with any color and opacity +- [Pagination](https://mantine.dev/llms/core-pagination.md): Display active page and navigate between multiple pages +- [Paper](https://mantine.dev/llms/core-paper.md): Renders white or dark background depending on color scheme +- [PasswordInput](https://mantine.dev/llms/core-password-input.md): Capture password data from user +- [Pill](https://mantine.dev/llms/core-pill.md): Removable and non-removable tags +- [PillsInput](https://mantine.dev/llms/core-pills-input.md): Base component for custom tags inputs and multi selects +- [PinInput](https://mantine.dev/llms/core-pin-input.md): Capture pin code or one time password from the user +- [Popover](https://mantine.dev/llms/core-popover.md): Display popover section relative to given target element +- [Portal](https://mantine.dev/llms/core-portal.md): Renders component outside of parent element tree +- [Progress](https://mantine.dev/llms/core-progress.md): Give user feedback for status of the task +- [Radio](https://mantine.dev/llms/core-radio.md): Wrapper for input type radio +- [RangeSlider](https://mantine.dev/llms/core-range-slider.md): RangeSlider component +- [Rating](https://mantine.dev/llms/core-rating.md): Pick and display rating +- [RingProgress](https://mantine.dev/llms/core-ring-progress.md): Give user feedback for status of the task with circle diagram +- [RollingNumber](https://mantine.dev/llms/core-rolling-number.md): Animate value changes with rolling digits +- [ScrollArea](https://mantine.dev/llms/core-scroll-area.md): Area with custom scrollbars +- [Scroller](https://mantine.dev/llms/core-scroller.md): Horizontal scroll container with navigation controls +- [SegmentedControl](https://mantine.dev/llms/core-segmented-control.md): A linear set of two or more segments +- [Select](https://mantine.dev/llms/core-select.md): Custom searchable select +- [SemiCircleProgress](https://mantine.dev/llms/core-semi-circle-progress.md): Represent progress with semi circle diagram +- [SimpleGrid](https://mantine.dev/llms/core-simple-grid.md): Responsive grid in which each item takes equal amount of space +- [Skeleton](https://mantine.dev/llms/core-skeleton.md): Indicate content loading state +- [Slider](https://mantine.dev/llms/core-slider.md): Slider component +- [Space](https://mantine.dev/llms/core-space.md): Add horizontal or vertical spacing from theme +- [Splitter](https://mantine.dev/llms/core-splitter.md): Resizable split pane layout +- [Spoiler](https://mantine.dev/llms/core-spoiler.md): Hide long sections of content under a spoiler +- [Stack](https://mantine.dev/llms/core-stack.md): Compose elements and components in a vertical flex container +- [Stepper](https://mantine.dev/llms/core-stepper.md): Display content divided into a steps sequence +- [Switch](https://mantine.dev/llms/core-switch.md): Capture boolean input from user +- [Table](https://mantine.dev/llms/core-table.md): Render table with theme styles +- [TableOfContents](https://mantine.dev/llms/core-table-of-contents.md): Renders a list of headings on the page and tracks current heading visible in the viewport +- [Tabs](https://mantine.dev/llms/core-tabs.md): Switch between different views +- [TagsInput](https://mantine.dev/llms/core-tags-input.md): Capture a list of values from user with free input and suggestions +- [Text](https://mantine.dev/llms/core-text.md): Display text +- [Textarea](https://mantine.dev/llms/core-textarea.md): Autosize or regular textarea +- [TextInput](https://mantine.dev/llms/core-text-input.md): Capture string input from user +- [ThemeIcon](https://mantine.dev/llms/core-theme-icon.md): Render icon inside element with theme colors +- [Timeline](https://mantine.dev/llms/core-timeline.md): Display list of events in chronological order +- [Title](https://mantine.dev/llms/core-title.md): h1-h6 heading +- [Tooltip](https://mantine.dev/llms/core-tooltip.md): Renders tooltip at given element on mouse over or other event +- [Transition](https://mantine.dev/llms/core-transition.md): Animate presence of component with pre-made animations +- [Tree](https://mantine.dev/llms/core-tree.md): Display a Tree structure +- [TreeSelect](https://mantine.dev/llms/core-tree-select.md): Select with hierarchical tree data +- [Typography](https://mantine.dev/llms/core-typography.md): Styles provider for html content +- [UnstyledButton](https://mantine.dev/llms/core-unstyled-button.md): Unstyled polymorphic button +- [VisuallyHidden](https://mantine.dev/llms/core-visually-hidden.md): Hide element visually but keep it accessible for screen readers + +## Hooks + +- [HooksPackage](https://mantine.dev/llms/hooks-package.md) +- [use-element-size](https://mantine.dev/llms/hooks-use-element-size.md): Returns element width and height and observes changes with ResizeObserver +- [useClickOutside](https://mantine.dev/llms/hooks-use-click-outside.md) +- [useClipboard](https://mantine.dev/llms/hooks-use-clipboard.md) +- [useCollapse](https://mantine.dev/llms/hooks-use-collapse.md) +- [useColorScheme](https://mantine.dev/llms/hooks-use-color-scheme.md) +- [useCounter](https://mantine.dev/llms/hooks-use-counter.md) +- [useDebouncedCallback](https://mantine.dev/llms/hooks-use-debounced-callback.md) +- [useDebouncedState](https://mantine.dev/llms/hooks-use-debounced-state.md) +- [useDebouncedValue](https://mantine.dev/llms/hooks-use-debounced-value.md) +- [useDidUpdate](https://mantine.dev/llms/hooks-use-did-update.md) +- [useDisclosure](https://mantine.dev/llms/hooks-use-disclosure.md) +- [useDocumentTitle](https://mantine.dev/llms/hooks-use-document-title.md) +- [useDocumentVisibility](https://mantine.dev/llms/hooks-use-document-visibility.md) +- [useDrag](https://mantine.dev/llms/hooks-use-drag.md) +- [useEventListener](https://mantine.dev/llms/hooks-use-event-listener.md) +- [useEyeDropper](https://mantine.dev/llms/hooks-use-eye-dropper.md) +- [useFavicon](https://mantine.dev/llms/hooks-use-favicon.md) +- [useFetch](https://mantine.dev/llms/hooks-use-fetch.md) +- [useFileDialog](https://mantine.dev/llms/hooks-use-file-dialog.md) +- [useFloatingWindow](https://mantine.dev/llms/hooks-use-floating-window.md) +- [useFocusReturn](https://mantine.dev/llms/hooks-use-focus-return.md) +- [useFocusTrap](https://mantine.dev/llms/hooks-use-focus-trap.md) +- [useFocusWithin](https://mantine.dev/llms/hooks-use-focus-within.md) +- [useForceUpdate](https://mantine.dev/llms/hooks-use-force-update.md) +- [useFullscreen](https://mantine.dev/llms/hooks-use-fullscreen.md) +- [useHash](https://mantine.dev/llms/hooks-use-hash.md) +- [useHeadroom](https://mantine.dev/llms/hooks-use-headroom.md) +- [useHotkeys](https://mantine.dev/llms/hooks-use-hotkeys.md) +- [useHover](https://mantine.dev/llms/hooks-use-hover.md) +- [useId](https://mantine.dev/llms/hooks-use-id.md) +- [useIdle](https://mantine.dev/llms/hooks-use-idle.md) +- [useInputState](https://mantine.dev/llms/hooks-use-input-state.md) +- [useIntersection](https://mantine.dev/llms/hooks-use-intersection.md) +- [useInterval](https://mantine.dev/llms/hooks-use-interval.md) +- [useInViewport](https://mantine.dev/llms/hooks-use-in-viewport.md) +- [useIsFirstRender](https://mantine.dev/llms/hooks-use-is-first-render.md) +- [useIsomorphicEffect](https://mantine.dev/llms/hooks-use-isomorphic-effect.md) +- [useListState](https://mantine.dev/llms/hooks-use-list-state.md) +- [useLocalStorage](https://mantine.dev/llms/hooks-use-local-storage.md) +- [useLogger](https://mantine.dev/llms/hooks-use-logger.md) +- [useLongPress](https://mantine.dev/llms/hooks-use-long-press.md) +- [useMap](https://mantine.dev/llms/hooks-use-map.md) +- [useMask](https://mantine.dev/llms/hooks-use-mask.md) +- [useMediaQuery](https://mantine.dev/llms/hooks-use-media-query.md) +- [useMergedRef](https://mantine.dev/llms/hooks-use-merged-ref.md) +- [useMounted](https://mantine.dev/llms/hooks-use-mounted.md) +- [useMouse](https://mantine.dev/llms/hooks-use-mouse.md) +- [useMove](https://mantine.dev/llms/hooks-use-move.md) +- [useMutationObserver](https://mantine.dev/llms/hooks-use-mutation-observer.md) +- [useNetwork](https://mantine.dev/llms/hooks-use-network.md) +- [useOrientation](https://mantine.dev/llms/hooks-use-orientation.md) +- [useOs](https://mantine.dev/llms/hooks-use-os.md) +- [usePageLeave](https://mantine.dev/llms/hooks-use-page-leave.md) +- [usePagination](https://mantine.dev/llms/hooks-use-pagination.md) +- [usePrevious](https://mantine.dev/llms/hooks-use-previous.md) +- [useQueue](https://mantine.dev/llms/hooks-use-queue.md) +- [useRadialMove](https://mantine.dev/llms/hooks-use-radial-move.md) +- [useReducedMotion](https://mantine.dev/llms/hooks-use-reduced-motion.md) +- [useResizeObserver](https://mantine.dev/llms/hooks-use-resize-observer.md) +- [useRovingIndex](https://mantine.dev/llms/hooks-use-roving-index.md) +- [useScrollDirection](https://mantine.dev/llms/hooks-use-scroll-direction.md) +- [useScroller](https://mantine.dev/llms/hooks-use-scroller.md) +- [useScrollIntoView](https://mantine.dev/llms/hooks-use-scroll-into-view.md) +- [useScrollSpy](https://mantine.dev/llms/hooks-use-scroll-spy.md) +- [useSelection](https://mantine.dev/llms/hooks-use-selection.md) +- [useSet](https://mantine.dev/llms/hooks-use-set.md) +- [useSetState](https://mantine.dev/llms/hooks-use-set-state.md) +- [useShallowEffect](https://mantine.dev/llms/hooks-use-shallow-effect.md) +- [useSplitter](https://mantine.dev/llms/hooks-use-splitter.md) +- [useStateHistory](https://mantine.dev/llms/hooks-use-state-history.md) +- [useTextSelection](https://mantine.dev/llms/hooks-use-text-selection.md) +- [useThrottledCallback](https://mantine.dev/llms/hooks-use-throttled-callback.md) +- [useThrottledState](https://mantine.dev/llms/hooks-use-throttled-state.md) +- [useThrottledValue](https://mantine.dev/llms/hooks-use-throttled-value.md) +- [useTimeout](https://mantine.dev/llms/hooks-use-timeout.md) +- [useToggle](https://mantine.dev/llms/hooks-use-toggle.md) +- [useUncontrolled](https://mantine.dev/llms/hooks-use-uncontrolled.md) +- [useValidatedState](https://mantine.dev/llms/hooks-use-validated-state.md) +- [useViewportSize](https://mantine.dev/llms/hooks-use-viewport-size.md) +- [useWindowEvent](https://mantine.dev/llms/hooks-use-window-event.md) +- [useWindowScroll](https://mantine.dev/llms/hooks-use-window-scroll.md) + +## Form + +- [Form context](https://mantine.dev/llms/form-create-form-context.md): Add context support to use-form with createFormContext +- [formActions](https://mantine.dev/llms/form-actions.md) +- [formAllInputs](https://mantine.dev/llms/form-all-inputs.md) +- [formErrors](https://mantine.dev/llms/form-errors.md) +- [formGetInputProps](https://mantine.dev/llms/form-get-input-props.md) +- [formNested](https://mantine.dev/llms/form-nested.md) +- [FormPackage](https://mantine.dev/llms/form-package.md) +- [formRecipes](https://mantine.dev/llms/form-recipes.md) +- [formSchemaValidation](https://mantine.dev/llms/form-schema-validation.md) +- [formStatus](https://mantine.dev/llms/form-status.md) +- [formUncontrolled](https://mantine.dev/llms/form-uncontrolled.md) +- [formValidation](https://mantine.dev/llms/form-validation.md) +- [formValidators](https://mantine.dev/llms/form-validators.md) +- [formValues](https://mantine.dev/llms/form-values.md) +- [use-field](https://mantine.dev/llms/form-use-field.md): use-field hook – manage single field state +- [use-form](https://mantine.dev/llms/form-use-form.md): Manage form state + +## Dates + +- [Calendar](https://mantine.dev/llms/dates-calendar.md): Base component for custom date pickers +- [DateInput](https://mantine.dev/llms/dates-date-input.md): Free form date input +- [DatePicker](https://mantine.dev/llms/dates-date-picker.md): Inline date, multiple dates and dates range picker +- [DatePickerInput](https://mantine.dev/llms/dates-date-picker-input.md): Date, multiple dates and dates range picker input +- [DateTimePicker](https://mantine.dev/llms/dates-date-time-picker.md): Capture datetime from the user +- [GettingStartedDates](https://mantine.dev/llms/dates-getting-started.md) +- [InlineDateTimePicker](https://mantine.dev/llms/dates-inline-date-time-picker.md): Inline date and time picker with range support +- [MiniCalendar](https://mantine.dev/llms/dates-mini-calendar.md): Compact calendar to display a small number of days in a row +- [MonthPicker](https://mantine.dev/llms/dates-month-picker.md): Inline month, multiple months and months range picker +- [MonthPickerInput](https://mantine.dev/llms/dates-month-picker-input.md): Month, multiple months and months range picker input +- [TimeGrid](https://mantine.dev/llms/dates-time-grid.md): Captures time value from the user with a predefined set of options +- [TimeInput](https://mantine.dev/llms/dates-time-input.md): Capture time from the user +- [TimePicker](https://mantine.dev/llms/dates-time-picker.md): Captures time value from the user +- [TimeValue](https://mantine.dev/llms/dates-time-value.md): Display a formatted time value +- [YearPicker](https://mantine.dev/llms/dates-year-picker.md): Inline year, multiple years and years range picker +- [YearPickerInput](https://mantine.dev/llms/dates-year-picker-input.md): Inline year, multiple years and years range picker + +## Charts + +- [AreaChart](https://mantine.dev/llms/charts-area-chart.md): Area chart component with stacked, percent and split variants +- [BarChart](https://mantine.dev/llms/charts-bar-chart.md): Bar chart component with stacked and percent variants +- [BarsList](https://mantine.dev/llms/charts-bars-list.md): Display a list of bars with names and values +- [BubbleChart](https://mantine.dev/llms/charts-bubble-chart.md): Bubble chart component +- [BulletChart](https://mantine.dev/llms/charts-bullet-chart.md): Compact KPI chart with value, target, and qualitative ranges +- [CompositeChart](https://mantine.dev/llms/charts-composite-chart.md): Composed chart with support for Area, Bar and Line charts +- [DonutChart](https://mantine.dev/llms/charts-donut-chart.md): Donut chart component +- [FunnelChart](https://mantine.dev/llms/charts-funnel-chart.md): Funnel chart component +- [GettingStartedCharts](https://mantine.dev/llms/charts-getting-started.md) +- [Heatmap](https://mantine.dev/llms/charts-heatmap.md): Heatmap chart component +- [LineChart](https://mantine.dev/llms/charts-line-chart.md): Line chart component +- [PieChart](https://mantine.dev/llms/charts-pie-chart.md): Pie chart component +- [RadarChart](https://mantine.dev/llms/charts-radar-chart.md): Radar chart component +- [RadialBarChart](https://mantine.dev/llms/charts-radial-bar-chart.md): Radial bar chart component +- [SankeyChart](https://mantine.dev/llms/charts-sankey-chart.md): Sankey diagram component +- [ScatterChart](https://mantine.dev/llms/charts-scatter-chart.md): Scatter chart component +- [Sparkline](https://mantine.dev/llms/charts-sparkline.md): Simplified area chart to show trends +- [SunburstChart](https://mantine.dev/llms/charts-sunburst-chart.md): Sunburst chart component for hierarchical data +- [Treemap](https://mantine.dev/llms/charts-treemap.md): Treemap chart component + +## Schedule + +- [AgendaView](https://mantine.dev/llms/schedule-agenda-view.md): Vertical list view of events grouped by date +- [DayView](https://mantine.dev/llms/schedule-day-view.md): Standalone schedule day view component +- [GettingStartedSchedule](https://mantine.dev/llms/schedule-getting-started.md) +- [MobileMonthView](https://mantine.dev/llms/schedule-mobile-month-view.md): Mobile-optimized schedule month view component +- [MonthView](https://mantine.dev/llms/schedule-month-view.md): Standalone schedule month view component +- [RecurringEventsSchedule](https://mantine.dev/llms/schedule-recurring-events.md) +- [ResourcesDayView](https://mantine.dev/llms/schedule-resources-day-view.md): Day view with resource columns for scheduling across resources +- [ResourcesMonthView](https://mantine.dev/llms/schedule-resources-month-view.md): Month view with resource lanes for scheduling across resources +- [ResourcesSchedule](https://mantine.dev/llms/schedule-resources-schedule.md): Schedule wrapper component that combines resource day, week and month views +- [ResourcesWeekView](https://mantine.dev/llms/schedule-resources-week-view.md): Week view with resource rows for scheduling across resources +- [Schedule](https://mantine.dev/llms/schedule-schedule.md): Schedule component with day, week, month and year views +- [ScheduleEventsData](https://mantine.dev/llms/schedule-events-data.md) +- [WeekView](https://mantine.dev/llms/schedule-week-view.md): Standalone schedule week view component +- [YearView](https://mantine.dev/llms/schedule-year-view.md): Standalone schedule year view component + +## Guides + +- [ControlledVsUncontrolled](https://mantine.dev/llms/guides-controlled-vs-uncontrolled.md) +- [CustomComponents](https://mantine.dev/llms/guides-custom-components.md) +- [EightToNine](https://mantine.dev/llms/guides-8x-to-9x.md) +- [FunctionsReference](https://mantine.dev/llms/guides-functions-reference.md) +- [Gatsby](https://mantine.dev/llms/guides-gatsby.md) +- [Icons](https://mantine.dev/llms/guides-icons.md) +- [JavaScript](https://mantine.dev/llms/guides-javascript.md) +- [Jest](https://mantine.dev/llms/guides-jest.md) +- [LLMDocumentation](https://mantine.dev/llms/guides-llms.md) +- [NextJs](https://mantine.dev/llms/guides-next.md) +- [Polymorphic](https://mantine.dev/llms/guides-polymorphic.md) +- [ReactRouter](https://mantine.dev/llms/guides-react-router.md) +- [Redwood](https://mantine.dev/llms/guides-redwood.md) +- [SevenToEight](https://mantine.dev/llms/guides-7x-to-8x.md) +- [SixToSeven](https://mantine.dev/llms/guides-6x-to-7x.md) +- [Storybook](https://mantine.dev/llms/guides-storybook.md) +- [Tiptap3Migration](https://mantine.dev/llms/guides-tiptap-3-migration.md) +- [TypeScript](https://mantine.dev/llms/guides-typescript.md) +- [Vite](https://mantine.dev/llms/guides-vite.md) +- [Vitest](https://mantine.dev/llms/guides-vitest.md) + +## Theming + +- [Colors](https://mantine.dev/llms/theming-colors.md) +- [ColorSchemes](https://mantine.dev/llms/theming-color-schemes.md) +- [DefaultProps](https://mantine.dev/llms/theming-default-props.md) +- [MantineProvider](https://mantine.dev/llms/theming-mantine-provider.md) +- [ThemeObject](https://mantine.dev/llms/theming-theme-object.md) +- [Typography](https://mantine.dev/llms/theming-typography.md): Styles provider for html content + +## Styles + +- [ColorFunctions](https://mantine.dev/llms/styles-color-functions.md) +- [CSSFilesList](https://mantine.dev/llms/styles-css-files-list.md) +- [CSSModules](https://mantine.dev/llms/styles-css-modules.md) +- [CssVariables](https://mantine.dev/llms/styles-css-variables.md) +- [CSSVariablesList](https://mantine.dev/llms/styles-css-variables-list.md) +- [DataAttributes](https://mantine.dev/llms/styles-data-attributes.md) +- [Emotion](https://mantine.dev/llms/styles-emotion.md) +- [GlobalStyles](https://mantine.dev/llms/styles-global-styles.md) +- [MantineStyles](https://mantine.dev/llms/styles-mantine-styles.md) +- [PostCSSPreset](https://mantine.dev/llms/styles-postcss-preset.md) +- [Rem](https://mantine.dev/llms/styles-rem.md) +- [ResponsiveStyles](https://mantine.dev/llms/styles-responsive.md) +- [Rtl](https://mantine.dev/llms/styles-rtl.md) +- [StyleProp](https://mantine.dev/llms/styles-style.md) +- [StyleProps](https://mantine.dev/llms/styles-style-props.md) +- [StylesApi](https://mantine.dev/llms/styles-styles-api.md) +- [StylesOverview](https://mantine.dev/llms/styles-styles-overview.md) +- [StylesPerformance](https://mantine.dev/llms/styles-styles-performance.md) +- [UnstyledComponents](https://mantine.dev/llms/styles-unstyled.md) +- [UsageWithSass](https://mantine.dev/llms/styles-sass.md) +- [VanillaExtract](https://mantine.dev/llms/styles-vanilla-extract.md) +- [VariantsAndSizes](https://mantine.dev/llms/styles-variants-sizes.md) + +## X + +- [Carousel](https://mantine.dev/llms/x-carousel.md): Embla based carousel component +- [CodeHighlight](https://mantine.dev/llms/x-code-highlight.md): Highlight code with shiki or highlight.js +- [Dropzone](https://mantine.dev/llms/x-dropzone.md): Capture files from user with drag and drop +- [ExtensionsPackage](https://mantine.dev/llms/x-extensions.md) +- [Modals manager](https://mantine.dev/llms/x-modals.md): Centralized modals manager with option to handle state of multi-step modals +- [NavigationProgress](https://mantine.dev/llms/x-nprogress.md): Navigation progress bar +- [Notifications system](https://mantine.dev/llms/x-notifications.md): Mantine notifications system +- [Rich text editor](https://mantine.dev/llms/x-tiptap.md): Tiptap based rich text editor +- [Spotlight](https://mantine.dev/llms/x-spotlight.md): Command center for your application + +## Other + +- [About](https://mantine.dev/llms/about.md) +- [AllReleases](https://mantine.dev/llms/changelog-all-releases.md) +- [BrowserSupport](https://mantine.dev/llms/browser-support.md) +- [Changelog700](https://mantine.dev/llms/changelog-7-0-0.md) +- [Changelog710](https://mantine.dev/llms/changelog-7-1-0.md) +- [Changelog7100](https://mantine.dev/llms/changelog-7-10-0.md) +- [Changelog7110](https://mantine.dev/llms/changelog-7-11-0.md) +- [Changelog7120](https://mantine.dev/llms/changelog-7-12-0.md) +- [Changelog7130](https://mantine.dev/llms/changelog-7-13-0.md) +- [Changelog7140](https://mantine.dev/llms/changelog-7-14-0.md) +- [Changelog7150](https://mantine.dev/llms/changelog-7-15-0.md) +- [Changelog7160](https://mantine.dev/llms/changelog-7-16-0.md) +- [Changelog7170](https://mantine.dev/llms/changelog-7-17-0.md) +- [Changelog720](https://mantine.dev/llms/changelog-7-2-0.md) +- [Changelog730](https://mantine.dev/llms/changelog-7-3-0.md) +- [Changelog740](https://mantine.dev/llms/changelog-7-4-0.md) +- [Changelog750](https://mantine.dev/llms/changelog-7-5-0.md) +- [Changelog760](https://mantine.dev/llms/changelog-7-6-0.md) +- [Changelog770](https://mantine.dev/llms/changelog-7-7-0.md) +- [Changelog780](https://mantine.dev/llms/changelog-7-8-0.md) +- [Changelog790](https://mantine.dev/llms/changelog-7-9-0.md) +- [Changelog800](https://mantine.dev/llms/changelog-8-0-0.md) +- [Changelog810](https://mantine.dev/llms/changelog-8-1-0.md) +- [Changelog820](https://mantine.dev/llms/changelog-8-2-0.md) +- [Changelog830](https://mantine.dev/llms/changelog-8-3-0.md) +- [Changelog900](https://mantine.dev/llms/changelog-9-0-0.md) +- [Changelog910](https://mantine.dev/llms/changelog-9-1-0.md) +- [Changelog920](https://mantine.dev/llms/changelog-9-2-0.md) +- [Changelog930](https://mantine.dev/llms/changelog-9-3-0.md) +- [Changelog940](https://mantine.dev/llms/changelog-9-4-0.md) +- [Changelog950](https://mantine.dev/llms/changelog-9-5-0.md) +- [Contribute](https://mantine.dev/llms/contribute.md) +- [GettingStarted](https://mantine.dev/llms/getting-started.md) +- [NotFound](https://mantine.dev/llms/404.md) +- [OxcConfig](https://mantine.dev/llms/oxc-config-mantine.md) +- [PreviousChangelogs](https://mantine.dev/llms/changelog-previous-versions.md) +- [Support](https://mantine.dev/llms/support.md) diff --git a/REVIEW.md b/REVIEW.md new file mode 100644 index 000000000..d78b4fc5e --- /dev/null +++ b/REVIEW.md @@ -0,0 +1,263 @@ +# PR #6502 widget-modernization ultra-review + +## Executive result + +**Merge recommendation: changes required.** No P0 was found, but the current branch still has five P1 issues: a pre-existing Custom API authorization/redirect weakness exposed by this subsystem review, an inaccessible manual advanced-focus interaction, a likely fixed-position/transform geometry defect, systemic false empty states for failed queries, and unvalidated integration-provided navigation URLs. Eight P2 issues cover raw error disclosure, unsafe runtime-ref interfaces, localization, responsive/theming drift, test coverage, and inaccurate PR copy. + +Audit health (Impeccable rubric): accessibility 1/4, performance 2/4, responsive design 2/4, theming 2/4, anti-patterns 3/4; **10/20 (Acceptable, significant work required)**. The UI does not look generically AI-generated; its problem is inconsistent technical behavior beneath an otherwise coherent Mantine vocabulary. + +## Reviewed revision and environment + +| Item | Value | +| --- | --- | +| PR | #6502, `feat: modernize dashboard widgets` | +| Reviewed head | `96e92485a5fd45f3648cfac0453e614779c414ba` | +| Base branch / fetched base | `dev` / `d58a47b776a55d04dee2220f59d072b9af8bbed3` | +| Merge base | `d58a47b776a55d04dee2220f59d072b9af8bbed3` | +| Diff | 359 files, +15,721/-4,478 | +| PR state | open, non-draft, `BLOCKED`; no approval decision | +| Hosted checks at audit start | Fast gate, Container and E2E, CodeQL, JS/TS analysis, Actions analysis, title, auto-fix: green | +| Inline threads | 34 total, 7 unresolved; only 2 remain substantively valid | +| Mantine | repository lock: `@mantine/core`, hooks, charts and dates 9.4.1 | +| Local tools | Node 24.16.0 and pnpm 9.5.0; repository requires Node >=24.18.0 and pnpm >=11.15.1 | + +The former red baseline `7b7045202d289b7d83946befcee5d2845ea4a706` had a green Fast gate and a failed Container/E2E run (`30748255760`, job `91498308523`). The latest head has a green Container/E2E job (`30756067198`, job `91519039504`). This proves the current suite passed once; it does not satisfy the requested three-run flake characterization. + +## PR-description traceability + +| Claim | Result | Evidence / correction | +| --- | --- | --- | +| “all 54 widget renderers with compact and advanced modes” | **Inaccurate** | 54 `WidgetKind` values exist. `displayMode="advanced"` reaches 53; `app` is deliberately excluded in `item-content.tsx:51` and documented as compact-only. Change copy to “53 widget renderers; app tiles remain compact,” or implement app advanced mode. | +| advanced focus: hover, keyboard, touch, geometry | **Partial** | Entry paths, Escape ownership, reduced motion, close focus, touch button and zero-dimension guards exist. WID-002/003 remain. | +| richer widget context actions | **Partial** | `Menu.ContextMenu` is a good Mantine choice and query scope is item-aware. Dispatch still crosses `any`; Custom API action authorization is not represented. | +| standardized query identity/cache/status | **Partial** | Query keys, matchers, runtime capture, recovery and tests exist. Thirty-nine of 53 renderer query calls do not read errors; runtime ref registration is render-time mutable state. | +| safe polling | **Mostly confirmed** | defaults are limited to `widget` namespace; Custom API resume immediately refetches; per-widget intervals exist. State/ref typing still needs redesign. | +| partial-failure rendering | **Partial** | settlement helper and several routers are strong; many renderer-level total failures still appear empty/disconnected. | +| bounded integration responses | **Mostly confirmed** | common bounded reader, Custom API 2 MiB, timetable 512 KiB, Traefik bounds and Beszel queue bounds exist. Custom action execution remains outside the hardened fetch path. | +| timetable hardening | **Confirmed** | board/item permission checks, saved-value comparison, public-address resolution, private ranges, per-request DNS re-resolution, pinned dispatcher, redirect rejection, timeouts, response bounds and schemas are present. | +| precise placement | **Mostly confirmed** | mouse, touch tolerance, keyboard, occupied-cell validation, dynamic bounds, nearest fit and immediate persistence exist. Failure/race/live responsive coverage remains insufficient. | +| widget catalog documentation | **Partial** | all kinds have pages and shared interactions; wording overclaims focus behavior and validation counts are stale. | +| validation: 40 files/162 tests, 6/6 E2E | **Stale** | current diff contains 57 changed unit-spec files (including three E2E specs); repository has 42 widget spec files and six E2E specs. Update from the exact final run. | +| independent reviews “pass” | **Not supported** | unresolved valid findings, current P1s, and unavailable live QA contradict an unqualified pass. | + +## Mantine suitability ledger + +Legend: **U** already used appropriately; **C** consolidation/replacement candidate; **R** relevant but current implementation can stay; **N** not relevant to widget runtime; **D** dependency/version addition. Exact 9.4.1 package archives were inspected in `/tmp`; live docs/Context7 include 9.5.0. + +### Core catalog + +- **U:** ActionIcon, Alert, Anchor, Avatar, Badge, Box, Button, Card, Center, Code, Divider, Flex, Grid, Group, Image, Loader, Menu (`Menu.ContextMenu`), Overlay, Paper, Popover, Portal, Progress, RingProgress, ScrollArea, SimpleGrid, Skeleton, Stack, Table, Tabs, Text, ThemeIcon, Title, Tooltip, UnstyledButton. +- **C:** Accordion (dense integration sections), DataList (semantic label/value stats), EmptyState (replace bespoke empty state), FocusTrap and Modal compound API (WID-002), LoadingOverlay (background refetch without deleting content), NumberFormatter, OverflowList/Scroller (responsive action and chip rows), Table.ScrollContainer, Timeline (activity/feed histories), VisuallyHidden and Kbd, CloseButton, Dialog/HoverCard/Notification where persistent errors/actions need semantics. +- **R:** Affix, AppShell, AspectRatio, BackgroundImage, Breadcrumbs, Burger, Checkbox, Chip, Collapse, ColorInput/ColorPicker/ColorSwatch/AlphaSlider/AngleSlider/HueSlider, Combobox/ComboboxPopover, Container, CopyButton, Drawer, Fieldset, FileButton/FileInput, FloatingIndicator/FloatingWindow, Highlight, Indicator, Input/JsonInput, List, Mark, Marquee, MaskInput, Menubar, MultiSelect/NativeSelect/NumberInput/PasswordInput/PillsInput/PinInput/Radio/RangeSlider/Rating/SegmentedControl/Select/Slider/Switch/TagsInput/Textarea/TextInput, Pagination, Pill, SemiCircleProgress, Space, Splitter, Spoiler, Stepper, Transition, Tree/TreeSelect, Typography. Use only where an existing widget requirement matches; do not churn working layouts. +- **N:** Alpha/color/angle primitives outside configuration forms, AppShell/navigation primitives, TableOfContents, CorePackage documentation. +- **D (9.5, not 9.4.1):** Cascader. **RollingNumber and Splitter are present in 9.4.1.** + +### Hooks catalog + +- **C:** `useFocusReturn`, `useFocusTrap`, `useReducedMotion`, `useRovingIndex`, `useLongPress`, `useScroller`, `useElementSize`, `useViewportSize`, `useHotkeys`, `useMediaQuery`, `useResizeObserver`. +- **U/R:** focus-within, hover, disclosure, click-outside, merged-ref, interval/timeout, local-storage, event/window listeners, throttled/debounced values and list/set/map/state helpers are available in 9.4.1 and should replace local mechanics only when behavior and tests improve. +- **N:** document title/favicon/hash/headroom/page-leave/OS/eye-dropper/fullscreen/network/orientation and other page-level utilities have no direct widget-remediation need. Every hook listed under `## Hooks` in `MANTINE.md` is covered by this rule; no hook requires 9.5 for the recommendations above. + +### Form, dates, charts, schedule, extensions, theme and styles + +- **Form:** existing `@homarr/form` should remain the abstraction over all cataloged form APIs. No widget-runtime replacement; use current validation/status APIs for widget editors (**R**). +- **Dates:** all catalog entries are in installed 9.4.1 dates. `Calendar`, `MiniCalendar`, and `TimeValue` are candidates; other picker/input entries are editor-only (**R**). Importing dates directly into `@homarr/widgets` requires adding that workspace dependency (**D**). +- **Charts:** 9.4.1 exports Area, Bar, BarsList, Bubble, Composite, Donut, Funnel, Heatmap, Line, Pie, Radar, RadialBar, Sankey, Scatter, Sparkline and Treemap (**C/R**). Prefer Sparkline for tiny trends, BarsList for ranked values, Heatmap for calendar/activity density, Composite for mixed analytics. **BulletChart and SunburstChart are 9.5-only (D)**. Replacement requires accessibility and bundle comparison, not blanket Recharts removal. +- **Schedule:** all `AgendaView`, day/week/month/year/resource views and Schedule pages require new `@mantine/schedule` dependency (**D**). AgendaView is the only plausible calendar-widget candidate; its weight and semantics must beat the existing grouped list before adoption. +- **Guides/FAQ/Other:** server components, portal testing, scroll lock, accessibility, nested popovers, zoom, responsive styling and version changelogs are relevant references (**R**); framework/setup/contribution/old migration entries are **N**. The 9.5 changelog is informational only. +- **Theming/styles:** Colors, ColorSchemes, DefaultProps, MantineProvider, ThemeObject, CSS variables, data attributes, CSS Modules, responsive/RTL/style props/Styles API/performance are **U/R**. WID-009 requires shared layer/color tokens. Emotion, Sass, Vanilla Extract and unrelated global setup are **N**. +- **Extensions:** existing modals, notifications, spotlight and rich-text abstractions are **U**; Carousel could consolidate Immich only after behavior parity; Dropzone/CodeHighlight/NProgress are **N** for this audit. + +## Complete 54-widget coverage matrix + +“Test” means a focused widget-local spec exists, not that all required states are covered. Every row still requires the common contract suite in WID-013. + +| WidgetKind | Family | Advanced | Focused test | Principal audit gap | +| --- | --- | --- | --- | --- | +| clock | basic | yes | no | query error, locale-format options | +| weather | basic/feed | yes | yes | query error; dayjs locale/time patterns | +| app | basic/link | **no** | no | PR wording; URL contract | +| iframe | custom content | yes | yes | manual focus/iframe trapping; URL privacy thread now fixed | +| video | custom content | yes | no | media/focus/live behavior | +| notebook | notes | yes (inner) | yes | editor focus inside manual surface | +| anchorNote | notes | yes | no | query error, save/error regression | +| dnsHoleSummary | network | yes | no | total query error, hard-coded chart colors | +| dnsHoleControls | network/security | yes | no | total error and permission matrix | +| smartHome-entityState | smart home | yes | no | raw state in aria; action permissions | +| smartHome-executeAutomation | smart home | yes | no | mutation/permission/edit-mode E2E | +| stockPrice | analytics | yes | yes | total error | +| mediaServer | media | yes | no | total error and table a11y | +| calendar | calendar | yes | yes | total error; fixed HH:mm; schedule tradeoff | +| downloads | media | yes | yes | four-state/component coverage, arbitrary z-index | +| mediaRequests-requestList | media | yes | no | total error; external URLs/actions | +| mediaRequests-requestStats | media | yes | no | total error; links/colors | +| mediaTranscoding | media | yes | yes | total error and responsive table | +| mediaMissing | media | yes | yes | total error and external URL | +| minecraftServerStatus | infrastructure | yes | no | total error/malformed payload | +| networkControllerSummary | networking | yes | no | total error; WAN/unknown localization | +| networkControllerStatus | networking | yes | no | responsive rule duplication | +| rssFeed | feed | yes | yes | strong stale/partial state; locale relative time | +| bookmarks | basic/link | yes | yes | recomputation; URL contract | +| indexerManager | media | yes | no | total error; runtime action ref | +| healthMonitoring | infrastructure | yes | yes | integration query error | +| releases | infrastructure/feed | yes | yes | secret/item authorization needs E2E | +| mediaReleases | media | yes | no | total error; external URLs | +| dockerContainers | infrastructure | yes | yes | total error; z-index/table semantics | +| firewall | networking/security | yes | yes | partial-query matrix; loose router generics | +| notifications | infrastructure | yes | no | total error; untrusted href | +| systemResources | infrastructure | yes | yes | total error; previous network thread fixed | +| coolify | infrastructure | yes | yes | total error; URL validation | +| systemDisks | infrastructure | yes | yes | total error | +| timetable | calendar | yes | no | backend strong; component locale/live tests absent | +| immich-serverStats | media | yes | yes | two ignored query errors | +| immich-albumCarousel | media | yes | yes (query) | query error and nested overlay/focus E2E | +| paperlessNgx | analytics | yes | no | former error thread fixed, no regression spec | +| patchmon | infrastructure | yes | yes | total error | +| bazarr | media | yes | yes | total error | +| tracearr | analytics | yes | yes | total error | +| speedtestTracker | analytics | yes | yes | total error | +| uptimeKuma | infrastructure | yes | yes | total error | +| audioStats | media | yes | yes | good dual-error handling; partial-state tests needed | +| umami | analytics | yes | no | runtime query registration; chart a11y | +| vpn | networking/security | yes | no | total error; status/permission matrix | +| archiveTeamWarrior | infrastructure | yes | yes | suspense path; docs present | +| ups | infrastructure | yes | no | total error | +| beszelSystemTable | monitoring | yes | yes | nested chart/focus and live subscription | +| beszelSystemGrid | monitoring | yes | no | live/error UI; no focused renderer test | +| beszelAlerts | monitoring | yes | no | partial error and status semantics | +| beszelSystemStats | monitoring | yes | yes | runtime refs, locale tooltip | +| traefik | networking | yes | no | total error; bounded backend positive | +| customApi | custom content/security | yes | yes | WID-001/007/008; polling resume fixed | + +## Severity-ranked findings + +### WID-001 — P1 confirmed, pre-existing: Custom API actions lack resource authorization and hardened fetch policy + +- **Location/claim:** `packages/api/src/router/custom-widget/custom-widget-router.ts:57-102,392-451`; “harden Custom API” and action-permission semantics. +- **Evidence/reproduction:** any authenticated account can call `customWidget.all`, learn every definition ID and URL, then call `customWidget.execute({definitionId})`. The mutation verifies neither board nor item nor an action permission. It decrypts administrator-provided secrets, uses `new URL()` only, and `redirect: "follow"`. The PR hardened `widget.customApi.getData`, but not this sibling execution path. +- **Impact/rule:** unauthorized side effects against internal services and authenticated redirect chains; broken object-level authorization. A view-only account can invoke administrator-created controls outside any board context. +- **Correction:** require `{boardId,itemId,definitionId}`; load the item and verify it is `customApi`, belongs to the board, references the definition, and the caller has an explicit execute policy. Until that policy exists, restrict to admin. Share `validateCustomApiUrl`, timeout, redirect rejection, bounded disposal and redacted errors with getData. Compatibility: old direct callers must send item/board context. +- **Tests:** procedure tests for anonymous, unrelated authenticated, view-only, modifier, admin, mismatched item/definition, disabled definition, redirects, credential URL, timeout and secret non-disclosure; E2E action button across roles. + +### WID-002 — P1 confirmed, PR-introduced: manual advanced focus is not an accessible modal + +- **Location:** `advanced-focus/context.tsx:194-209`, `items/item-content.tsx:113-189`. +- **Evidence:** manual mode renders an Overlay plus a `role="region"` Card. There is no FocusTrap, `aria-modal`, background inertness, scroll lock or backdrop dismissal. Only the close button is initially focused; Tab can leave the surface and activate dimmed board controls. +- **Impact/rule:** keyboard/screen-reader users lose the interaction boundary; WCAG 2.1.2, 2.4.3 and ARIA dialog pattern. Iframes/editors make escape especially likely. +- **Correction/Mantine:** render manual mode through `Modal.Root` compound parts (or ModalBase), using its portal, FocusTrap, scroll lock, Escape/return-focus and semantic content. Preserve preview mode as non-modal. Configure nested overlays and `removeScrollProps`; keep a 44px `Modal.CloseButton` and explicit title via VisuallyHidden if needed. +- **Tests:** RTL portal/focus-cycle/return tests; Playwright Tab/Shift+Tab, nested Menu/Select/Popover/editor/iframe, backdrop and every close path; reduced motion and 200% zoom. + +### WID-003 — P1 probable, PR-introduced: fixed advanced surface remains under transformed Gridstack ancestry + +- **Location:** `advanced-focus.module.css:35-42`; Card remains inside the Gridstack item while only the backdrop is portaled. +- **Evidence:** CSS fixed descendants establish their containing block at a transformed ancestor. Gridstack uses transforms for positioned items, so viewport-derived `left/top` can be interpreted relative to the item on affected layouts. A Portal wrapper exists only for Overlay. +- **Impact:** off-screen, clipped or incorrectly scaled focus surface across breakpoints/remounts. +- **Correction:** portal the focused surface itself (naturally achieved by WID-002) and calculate geometry in the same viewport coordinate system. Do not compensate with transform-specific offsets. +- **Tests:** real Gridstack at every layout/viewport, scrolled page, header, dynamic section and resize/remount; assert bounding rect within viewport and finite scale. + +### WID-004 — P1 confirmed, regression-exposed/systemic: query failures still become valid empty/disconnected UI + +- **Location:** 39 of 53 renderer query calls do not read `error`, including bookmarks, weather, calendar, media server, system resources, Coolify, DNS Hole, Tracearr, notifications, Immich, VPN and others listed in the matrix. Query defaults do not set `throwOnError` (`trpc.tsx:69-93`). +- **Evidence:** TanStack Query returns `data=undefined` on total failure. Components default to `[]`, `null`, or empty branches, so the board ErrorBoundary is never reached. Network Controller and Paperless were fixed, proving the pattern; it was not completed. +- **Impact:** operators are told “empty” or “disconnected” when monitoring failed, violating the product’s truthful-state requirement. +- **Correction/Mantine:** introduce a shared `WidgetQueryState` contract distinguishing initial Skeleton, background LoadingOverlay, stale Alert, partial warning, true EmptyState and terminal error. Either explicitly throw only when no usable cached data or render a shared error Alert. Preserve stale data. +- **Tests:** parameterized contract tests for every queried widget: initial, success-empty, total failure, stale+failure, partial failure, refetch, remount/persisted cache and sibling isolation. + +### WID-005 — P1 confirmed, PR-introduced/systemic: integration-provided links bypass a shared URL policy + +- **Location:** examples include `media-missing/component.tsx:291`, `notifications/component.tsx:77`, media requests list/stats, Coolify resource/server links, releases, bookmarks and iframe. Only RSS and custom JSX visibly use explicit protocol sanitizers. +- **Evidence:** dynamic strings are assigned directly to `href`/new-tab controls. Integration APIs and remote feeds are trust boundaries; no renderer-wide `http/https` guard is enforced. +- **Impact:** unsafe schemes, credential-bearing URLs, internal detail disclosure and inconsistent opener behavior. +- **Correction:** add one typed application URL helper returning a sanitized URL/undefined, with allowed schemes per use case, credential stripping/rejection, consistent `noopener noreferrer`, and disabled non-link presentation. Validate again server-side for action endpoints. +- **Tests:** `javascript:`, `data:`, malformed, credential, relative, http/https and missing URLs for each shared link primitive. + +### WID-006 — P2 confirmed, PR-introduced: runtime query/action state mutates untyped refs during render + +- **Location:** `definition.ts:121-162`; runtime registration calls in media missing/transcoding, audio, calendar, Umami, Beszel and Immich; `widget-context-menu.tsx:157-174`. +- **Evidence:** `setWidgetRuntimeQueries` mutates a shared object during render; concurrent/aborted renders can publish uncommitted query identity. Action dispatch casts the definition to `Record` and `any`, then casts its result back. +- **Correction:** make definition generics preserve kind/options/action types end-to-end; replace stringly opaque ref slots with a typed per-item registration API updated in a committed effect (or use `useSyncExternalStore`). Separate query registration from imperative actions. +- **Tests:** StrictMode, interrupted/remount, options change and cleanup; compile-time `tsd`/type assertions rejecting wrong action props. + +### WID-007 — P2 confirmed, regression-exposed: raw backend errors are exposed to users and assistive text + +- **Location:** Custom API UI `component.tsx:561-576`; API `custom-api.ts:79-85`; several partial fallbacks use `error.message`. +- **Impact:** internal hostnames, request paths and implementation details can appear in tooltips/aria labels and logs. +- **Correction/Mantine:** return stable error codes plus redacted public messages; keep detailed causes server-side with correlation IDs. Render localized Alert/Notification details, never raw throwable text. +- **Tests:** errors containing credentials, query strings, private hosts and multiline backend bodies must not reach tRPC payload, DOM, accessibility tree or notifications. + +### WID-008 — P2 confirmed, PR-introduced/regression-exposed: locale handling remains incomplete + +- **Locations:** Beszel chart/tooltip dayjs formats, calendar event `HH:mm`, downloads `YYYY-MM-DD HH:mm`, timetable `HH:mm:ss`, weather day/month/time formats; `_inputs/widget-location-input.tsx:199` hardcodes `Intl.NumberFormat("en")`. +- **Also:** `beszel/_shared/disk-usage.tsx:41`, stats loading aria label, Network Controller `WAN`/`unknown` are English/raw accessible strings. +- **Correction:** centralize app-locale date/time/number/duration/status helpers; load canonical dayjs locale only at the boundary; use localized translation keys for aria/status. NumberFormatter is suitable for simple display but application helpers should own locale. +- **Tests:** at least `en-US`, `fr-FR`, `de-DE`, RTL locale, 12/24-hour preference and long translations, including accessibility names. + +### WID-009 — P2 confirmed: layer and color tokens are inconsistent + +- **Locations:** downloads z-index 1000 and rgba row colors; item title z-index 4; item menu/dynamic menu 10; media server white rgba; Indexer Manager hex colors; DNS Hole rgba palette; Umami hex tick colors. +- **Correction:** define shared semantic layer tokens relative to Mantine CSS z-index variables and semantic status/chart tokens respecting light/dark/forced colors. Do not replace intentional service branding. +- **Tests:** light/dark/forced-colors with nested overlays and tooltip ordering. + +### WID-010 — P2 confirmed: responsive decisions are duplicated and under-tested + +- **Location:** Network Controller Status `component.tsx:44,59-78` (open valid thread); Bookmark `component.tsx:94` recomputation (valid but low-impact thread); similar width/height literals across renderers. +- **Correction:** small typed breakpoint lookup helpers per family, memoized only for genuinely expensive layout. Prefer container-query Grid/SimpleGrid where structural. Do not cargo-cult `useMemo` for a cheap pure calculation; benchmark Bookmark before accepting the review suggestion. +- **Tests:** boundary tables for minimum/typical/wide/short/advanced sizes and 200% zoom. + +### WID-011 — P2 confirmed: PR/docs overstate behavior and validation + +- **Location:** PR body, board/getting-started copy “advanced preview in place,” validation numbers, and unqualified review pass. +- **Correction:** state 53 advanced renderers plus compact-only app; call manual focus an overlay only after WID-002/003; publish exact final-SHA check/test counts and disclose unsupported/live-untested states. + +### WID-012 — P2 confirmed: focused automated coverage is far below the 54-widget/data-state claim + +- **Evidence:** 42 widget-local spec files are helper-heavy; 20+ renderers have no focused spec. Six E2E files exist, but only board interactions and Custom JSX directly exercise this modernization. The earlier Custom JSX login wait failed once and current exact-route wait remains. +- **Correction:** add shared renderer contract harness plus family E2Es. Replace Custom JSX’s exact post-submit URL race with a shared login helper that waits for authenticated session/stable destination, then run it three times in CI. + +### WID-013 — P2 confirmed: open review-thread state no longer matches code + +| Thread | Classification | +| --- | --- | +| iframe complete URL | already fixed/false positive at current line: only host is rendered | +| system resources network gate | already fixed, outdated | +| RSS total error | already fixed: no-data error throws; stale data retained | +| bookmarks memoization | valid P3/P2-low; benchmark first | +| network-controller total error | already fixed, outdated | +| network-controller breakpoint consolidation | valid P2 maintainability | +| Paperless total error | already fixed, outdated | + +Resolve the five stale threads with evidence; address or explicitly disposition the two live suggestions. Re-run review because CodeRabbit was paused during active development. + +## Systemic remediation roadmap + +1. **Security/authorization:** WID-001 and WID-005; add negative-role/protocol tests before merge. +2. **Interaction accessibility:** replace manual focus with portaled Mantine modal semantics (WID-002/003), then test nested widget content. +3. **Truthful data state:** build the shared query-state contract and migrate all 39 ambiguous sites; keep valid stale data and partial results. +4. **Typed interfaces:** replace `widgetStateRef` slot mutation and `any` context dispatch with committed typed registrations. +5. **Widget families:** media/downloads, infrastructure, networking/security, smart-home, analytics/calendar/feed, then Beszel; apply URL, state, locale, responsive and a11y contracts in that order. +6. **Design-system cleanup:** semantic layers/colors and restrained Mantine consolidation. Do not add Schedule or upgrade to 9.5 without a separate dependency/bundle decision. +7. **Docs/PR:** correct renderer count, behavior and exact validation evidence. +8. **Final proof:** frozen install on supported Node/pnpm; format, lint, full typecheck, affected and full unit tests, production and docs builds; Custom JSX three times; complete production-container E2E; desktop/tablet/mobile keyboard/touch/light/dark/reduced-motion/zoom/a11y-tree QA. Push nothing until all refer to one unchanged SHA; close every review thread and obtain required approval. + +## Positive findings + +- Timetable is the strongest security work in the PR: saved resource authorization, private-range rejection, DNS rebinding resistance through pinned lookup, redirect rejection, bounds and schemas are coherently tested. +- Bounded response helpers, Beszel bounded queue/cancellation, and partial-integration settlement materially reduce failure blast radius. +- RSS preserves stale data and distinguishes partial feed failure; Network Controller and Paperless now correctly escalate total failures. +- Item-scoped query matchers and tests address sibling-widget refresh isolation; global polling defaults are restricted to the widget namespace. +- Touch long-press has movement tolerance; focus scale guards prevent Infinity/NaN; reduced-motion and remount cleanup are present. +- Existing Homarr theme, Mantine components and shared UI vocabulary are generally preserved. Most proposed Mantine work is consolidation, not redesign. + +## Validation evidence and uncertainty + +- `MANTINE.md` was fetched from `https://mantine.dev/llms.txt` and passed byte-for-byte `cmp`; `llms-full.txt` stayed in `/tmp`. +- Context7 current Mantine docs were queried for overlay/focus, responsive/data-state/accessibility, charts and Schedule. Static 9.4.1 archives verified exact exports; Cascader, BulletChart and SunburstChart are 9.5-only. +- Local frozen install could not start because host Node/pnpm are below repository engine requirements. No dependencies, source files or machine toolchain were changed. +- Docker API access is denied, so production-container E2E could not be rerun locally. Hosted exact-head Fast gate and Container/E2E are green. +- `agent-browser` is not installed. Temporary `npx` execution was rejected by the environment’s security reviewer, so live exploratory QA/a11y-tree inspection was not performed. +- External integrations were not available; malformed/partial/permission combinations are static-analysis conclusions unless a focused test already exists. +- This review intentionally changes no production code and makes no commit, push, thread resolution or PR edit. + +Finalization must re-query the PR head/checks/threads. If the head differs from the reviewed SHA, this report is a snapshot and the delta requires review before merge. + +**Final refresh (2026-08-03):** head remained `96e92485a5fd45f3648cfac0453e614779c414ba`; all reported hosted checks remained successful; merge state remained `BLOCKED` with no approval decision; unresolved threads remained 7 of 34. The branch did not change during the audit. From 61a2f8cb6c254ead5f5a6205e8fa9189c87b0afc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 17:18:22 +0200 Subject: [PATCH 33/75] feat: modernize widget experience --- .../advanced/keyboard-shortcuts/index.mdx | 24 +- .../after-the-installation.mdx | 8 +- apps/docs/docs/management/boards/index.mdx | 6 +- .../docs/management/custom-widgets/index.mdx | 10 + apps/docs/docs/widgets/bazarr/index.mdx | 2 +- .../widgets/beszel-system-stats/index.mdx | 2 +- apps/docs/docs/widgets/coolify/index.mdx | 2 +- apps/docs/docs/widgets/custom-api/index.mdx | 1 + .../docs/widgets/dns-hole-controls/index.mdx | 4 +- .../docs/widgets/dns-hole-summary/index.mdx | 4 +- apps/docs/docs/widgets/iframe/index.mdx | 2 +- .../docs/widgets/indexer-manager/index.mdx | 2 +- .../docs/widgets/media-request-list/index.mdx | 2 +- .../widgets/media-request-stats/index.mdx | 4 +- .../widgets/minecraft-server-status/index.mdx | 2 +- .../network-controller-summary/index.mdx | 4 +- apps/docs/docs/widgets/notebook/index.mdx | 2 +- .../docs/docs/widgets/notifications/index.mdx | 2 +- .../docs/docs/widgets/paperless-ngx/index.mdx | 2 +- apps/docs/docs/widgets/patchmon/index.mdx | 2 +- apps/docs/docs/widgets/releases/index.mdx | 2 +- apps/docs/docs/widgets/rss-feed/index.mdx | 2 +- apps/docs/docs/widgets/stock-price/index.mdx | 4 +- apps/docs/docs/widgets/system-disks/index.mdx | 2 +- apps/docs/docs/widgets/video/index.mdx | 2 +- apps/docs/docs/widgets/vpn/index.mdx | 2 +- .../src/components/widgets/interactions.tsx | 71 ++-- .../custom-widgets/_custom-widget-actions.tsx | 4 +- .../custom-widgets/_custom-widget-form.tsx | 10 +- .../custom-widgets/_custom-widget-preview.tsx | 6 +- .../advanced-focus/advanced-focus.module.css | 40 +- .../board/advanced-focus/context.tsx | 44 +- .../board/advanced-focus/escape.spec.ts | 20 +- .../components/board/advanced-focus/escape.ts | 12 +- .../advanced-focus/manual-surface.spec.ts | 107 +++++ .../board/advanced-focus/manual-surface.tsx | 101 +++++ .../components/board/advanced-focus/wheel.ts | 5 +- .../components/board/items/item-content.tsx | 246 +++++++----- .../src/components/board/items/item-menu.tsx | 2 +- .../board/items/item-select-modal.tsx | 4 +- .../board/items/widget-context-menu.tsx | 51 +-- .../items/widget-hover-overlay.module.css | 11 +- .../category/category-menu-actions.tsx | 11 +- .../board/sections/dynamic/dynamic-menu.tsx | 2 +- e2e/board-advanced-interactions.spec.ts | 170 +++++--- e2e/custom-widgets-custom-jsx.spec.ts | 75 ++-- e2e/shared/login.ts | 51 +++ .../custom-widget/custom-widget-access.ts | 45 +++ .../custom-widget/custom-widget-router.ts | 207 +++++----- .../src/router/custom-widget/display-data.ts | 26 +- packages/api/src/router/location.ts | 7 +- .../api/src/router/test/custom-widget.spec.ts | 376 ++++++++++++++++++ packages/api/src/router/test/location.spec.ts | 36 ++ .../partial-integration-failures.spec.ts | 108 ++++- .../__tests__/custom-api-security.spec.ts | 2 + packages/api/src/router/widgets/beszel.ts | 25 +- packages/api/src/router/widgets/calendar.ts | 4 +- .../src/router/widgets/custom-api-security.ts | 46 +++ packages/api/src/router/widgets/custom-api.ts | 90 ++--- packages/api/src/router/widgets/firewall.ts | 4 +- .../api/src/router/widgets/indexer-manager.ts | 4 +- .../api/src/router/widgets/media-organizer.ts | 4 +- .../api/src/router/widgets/media-requests.ts | 6 +- .../api/src/router/widgets/media-server.ts | 4 +- .../api/src/router/widgets/notifications.ts | 4 +- packages/api/src/router/widgets/releases.ts | 24 +- packages/api/src/router/widgets/vpn.ts | 4 +- packages/api/src/settle-integrations.ts | 4 + .../api/src/test/settle-integrations.spec.ts | 13 +- packages/common/src/url.ts | 35 ++ .../actions/items/children-action-item.tsx | 13 +- .../actions/items/group-action-item.tsx | 13 +- .../widget-custom-widget-select-input.tsx | 6 +- .../src/_inputs/widget-location-input.tsx | 10 +- ...widget-multiReleasesRepositories-input.tsx | 12 +- .../src/advanced-focus-support.spec.ts | 42 ++ .../widgets/src/anchor-note/component.tsx | 5 +- packages/widgets/src/app/component.tsx | 174 ++++---- .../widgets/src/app/ping/ping-indicator.tsx | 4 +- .../widgets/src/audio-stats/component.tsx | 15 +- packages/widgets/src/bazarr/component.tsx | 27 +- packages/widgets/src/bazarr/index.ts | 1 + packages/widgets/src/bazarr/layout.spec.ts | 4 +- .../widgets/src/beszel-alerts/component.tsx | 7 +- .../src/beszel-system-grid/component.tsx | 23 +- .../src/beszel-system-stats/component.tsx | 79 ++-- .../widgets/src/beszel-system-stats/index.ts | 1 + .../src/beszel-system-table/component.tsx | 98 +---- packages/widgets/src/beszel/_shared/chart.tsx | 57 +-- .../widgets/src/beszel/_shared/stats-view.tsx | 47 ++- .../widgets/src/beszel/_shared/tooltip.tsx | 11 +- packages/widgets/src/bookmarks/component.tsx | 283 ++++++------- packages/widgets/src/bookmarks/layout.spec.ts | 2 +- .../src/calendar/calendar-event-list.tsx | 72 ++-- packages/widgets/src/calendar/component.tsx | 32 +- packages/widgets/src/clock/component.tsx | 23 +- .../src/common/application-url.spec.ts | 30 ++ .../widgets/src/common/application-url.ts | 1 + packages/widgets/src/common/locale.spec.ts | 35 ++ packages/widgets/src/common/locale.ts | 61 +++ .../src/common/query-state-indicator.spec.ts | 56 +++ .../src/common/query-state-indicator.tsx | 38 ++ .../widgets/src/common/query-state.spec.ts | 39 ++ packages/widgets/src/common/query-state.ts | 22 + packages/widgets/src/coolify/component.tsx | 34 +- packages/widgets/src/coolify/index.ts | 1 + .../widgets/src/coolify/instance-card.tsx | 22 +- packages/widgets/src/coolify/resource-row.tsx | 16 +- .../widgets/src/coolify/servers-section.tsx | 8 +- .../src/coolify/single-instance-layout.tsx | 31 +- .../src/coolify/use-advanced-open-sections.ts | 29 -- packages/widgets/src/custom-api/component.tsx | 77 ++-- .../src/custom-api/custom-jsx-display.spec.ts | 10 + .../src/custom-api/custom-jsx-display.tsx | 29 +- packages/widgets/src/custom-api/index.ts | 32 +- .../widgets/src/custom-api/jsx-whitelist.ts | 20 +- packages/widgets/src/definition.spec.ts | 24 +- packages/widgets/src/definition.ts | 67 ++-- .../src/dns-hole/controls/component.tsx | 18 +- .../widgets/src/dns-hole/controls/index.ts | 1 + .../src/dns-hole/summary/component.tsx | 52 ++- .../widgets/src/dns-hole/summary/index.ts | 1 + packages/widgets/src/docker/component.tsx | 5 +- packages/widgets/src/downloads/component.tsx | 29 +- packages/widgets/src/errors/component.spec.ts | 61 +++ packages/widgets/src/errors/component.tsx | 5 +- packages/widgets/src/firewall/component.tsx | 46 ++- .../src/firewall/failure-state.spec.ts | 10 + .../cluster/cluster-health.tsx | 31 +- .../cluster/resource-table.tsx | 24 +- .../src/health-monitoring/component.tsx | 3 +- .../src/health-monitoring/system-health.tsx | 7 +- packages/widgets/src/iframe/component.tsx | 31 +- packages/widgets/src/iframe/index.ts | 46 ++- .../src/immich/album-carousel/component.tsx | 57 ++- .../src/immich/album-carousel/index.ts | 109 +++-- .../widgets/src/immich/albums-query.spec.ts | 63 ++- .../immich/server-stats/component.module.css | 5 - .../src/immich/server-stats/component.tsx | 76 ++-- packages/widgets/src/index.tsx | 4 + .../src/indexer-manager/component.module.css | 13 + .../widgets/src/indexer-manager/component.tsx | 123 +++--- packages/widgets/src/indexer-manager/index.ts | 25 +- .../widgets/src/media-missing/component.tsx | 53 +-- .../widgets/src/media-releases/component.tsx | 49 ++- .../src/media-requests/list/component.tsx | 61 +-- .../widgets/src/media-requests/list/index.ts | 39 +- .../src/media-requests/stats/component.tsx | 109 +++-- .../widgets/src/media-requests/stats/index.ts | 9 +- .../widgets/src/media-server/component.tsx | 14 +- .../src/media-transcoding/component.tsx | 90 +++-- .../src/media-transcoding/layout.spec.ts | 19 +- .../src/minecraft/server-status/component.tsx | 29 +- .../src/minecraft/server-status/index.ts | 1 + .../network-status/component.tsx | 24 +- .../network-status/layout.spec.ts | 49 +++ .../network-status/layout.ts | 42 ++ .../network-controller/summary/component.tsx | 90 ++++- .../src/network-controller/summary/index.ts | 1 + packages/widgets/src/notebook/index.ts | 1 + packages/widgets/src/notebook/notebook.tsx | 26 +- .../widgets/src/notifications/component.tsx | 134 ++++--- packages/widgets/src/notifications/index.ts | 1 + .../widgets/src/paperless-ngx/component.tsx | 18 +- packages/widgets/src/paperless-ngx/index.ts | 1 + packages/widgets/src/patchmon/component.tsx | 34 +- packages/widgets/src/patchmon/index.ts | 1 + packages/widgets/src/releases/component.tsx | 30 +- packages/widgets/src/releases/index.ts | 1 + packages/widgets/src/rssFeed/component.tsx | 128 +++--- .../widgets/src/rssFeed/feed-display.spec.ts | 20 +- packages/widgets/src/rssFeed/index.ts | 1 + packages/widgets/src/runtime-hooks.ts | 40 ++ .../execute-automation/component.tsx | 19 +- .../src/speedtest-tracker/component.tsx | 4 +- packages/widgets/src/stocks/component.spec.ts | 10 +- packages/widgets/src/stocks/component.tsx | 21 +- packages/widgets/src/stocks/index.ts | 1 + .../widgets/src/system-disks/component.tsx | 24 +- packages/widgets/src/system-disks/index.ts | 1 + .../src/system-resources/component.tsx | 9 +- packages/widgets/src/timetable/component.tsx | 14 +- packages/widgets/src/tracearr/component.tsx | 4 +- packages/widgets/src/traefik/component.tsx | 3 +- packages/widgets/src/umami/component.tsx | 69 ++-- packages/widgets/src/umami/umami-content.tsx | 53 ++- .../src/umami/umami-events-content.tsx | 17 +- packages/widgets/src/umami/umami-top-list.tsx | 23 +- packages/widgets/src/ups/component.tsx | 3 +- .../widgets/src/uptime-kuma/component.tsx | 3 +- packages/widgets/src/video/component.tsx | 29 +- packages/widgets/src/video/index.ts | 1 + packages/widgets/src/vpn/component.tsx | 62 ++- packages/widgets/src/vpn/index.ts | 1 + .../widgets/src/weather/component.spec.ts | 4 +- packages/widgets/src/weather/component.tsx | 90 +++-- packages/widgets/src/weather/icon.tsx | 26 +- packages/widgets/src/widget-runtime.spec.ts | 88 ++++ 198 files changed, 4209 insertions(+), 2295 deletions(-) create mode 100644 apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts create mode 100644 apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx create mode 100644 e2e/shared/login.ts create mode 100644 packages/api/src/router/custom-widget/custom-widget-access.ts create mode 100644 packages/api/src/router/test/custom-widget.spec.ts create mode 100644 packages/api/src/router/test/location.spec.ts create mode 100644 packages/widgets/src/advanced-focus-support.spec.ts create mode 100644 packages/widgets/src/common/application-url.spec.ts create mode 100644 packages/widgets/src/common/application-url.ts create mode 100644 packages/widgets/src/common/locale.spec.ts create mode 100644 packages/widgets/src/common/locale.ts create mode 100644 packages/widgets/src/common/query-state-indicator.spec.ts create mode 100644 packages/widgets/src/common/query-state-indicator.tsx create mode 100644 packages/widgets/src/common/query-state.spec.ts create mode 100644 packages/widgets/src/common/query-state.ts delete mode 100644 packages/widgets/src/coolify/use-advanced-open-sections.ts create mode 100644 packages/widgets/src/errors/component.spec.ts create mode 100644 packages/widgets/src/network-controller/network-status/layout.spec.ts create mode 100644 packages/widgets/src/network-controller/network-status/layout.ts create mode 100644 packages/widgets/src/runtime-hooks.ts create mode 100644 packages/widgets/src/widget-runtime.spec.ts diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 1f7842fd7..2896b7a97 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,23 +6,23 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| ------------------------------------------ | ---------------------------------------------------------- | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | -| Hold `Shift` while hovering a widget | Opens its temporary advanced view | -| `Shift` + `Enter` on a focused widget | Opens its advanced view until it is closed | -| `Esc` | Closes an open advanced view or board menu | -| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell | +| Shortcut | Description | +| ---------------------------------------------- | ---------------------------------------------------------- | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` while hovering 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 | +| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. -Advanced view and precise placement are available outside edit mode. App tiles stay compact and do not have an -advanced view. Precise placement still requires permission to change the board and the right-click interaction -preference to be enabled. +Advanced view and precise placement are available outside edit mode. Widgets without a distinct advanced experience +adapt to their normal dimensions instead. Precise placement still requires permission to change the board and the +right-click interaction preference to be enabled. ::: diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 29d56e4eb..2148af841 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -191,10 +191,10 @@ When entering the edit mode, you'll get some additional buttons to work with: - **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 to expand an advanced preview in place while the rest of the board stays visible -but dimmed. Release `Shift` to close it. You can also focus the widget and press `Shift` + `Enter` to keep its advanced -view open until you close it. App tiles stay compact. If you can change the board, right-click empty grid space to add -an item at that position. +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. If you +can change the board, right-click empty grid space to add an item at that position. On a touch device, touch and hold empty grid space for the same precise-placement menu. To continue, click the option "New Item": diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 6babee071..85a89001a 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -179,9 +179,9 @@ This will disable the status check for apps. Widgets expose their routine controls without requiring edit mode: -- Hold `Shift` while hovering a widget to expand its advanced preview in place while the other widgets remain visible - but dimmed. Release `Shift` to close it. Focus the widget and press `Shift` + `Enter` to keep the view open until you - close it, or use the maximize control on touch devices. App tiles stay compact. +- 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, or use the maximize control on touch devices. 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 diff --git a/apps/docs/docs/management/custom-widgets/index.mdx b/apps/docs/docs/management/custom-widgets/index.mdx index 0395253dc..9e7174609 100644 --- a/apps/docs/docs/management/custom-widgets/index.mdx +++ b/apps/docs/docs/management/custom-widgets/index.mdx @@ -50,6 +50,16 @@ To create a custom widget, navigate to the **Custom Widgets** section in the man | **Action Button** | Clickable button executing the API request | Triggers: restart, clear cache | | **Custom JSX** | Custom JSX template with Mantine v9 components | Full layout control | +## Security and permissions + +Custom widget definitions, endpoint details, authentication settings, and secrets are managed by administrators. +Board editors can select an enabled definition without receiving its endpoint or credentials, while board viewers only +receive the rendered widget data they are allowed to view. **Action Button** requests are administrator-only. + +Custom API requests accept HTTP and HTTPS endpoints without URL-embedded credentials. Redirects are rejected, requests +time out after 10 seconds, and responses are limited to 2 MiB. Detailed upstream errors stay in the server logs; the +dashboard only shows a generic failure. + ### AI-Powered JSX Generation When editing or creating a custom widget with the **Custom JSX** display type, you'll find a **Copy Prompt** button. Clicking it copies a pre-formatted prompt describing your widget's name, API endpoint, and available Mantine components. Paste this into your preferred AI assistant (ChatGPT, Claude, Copilot, etc.) to generate a JSX template tailored to your data. diff --git a/apps/docs/docs/widgets/bazarr/index.mdx b/apps/docs/docs/widgets/bazarr/index.mdx index 5cd0c1532..cb35f6c71 100644 --- a/apps/docs/docs/widgets/bazarr/index.mdx +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -36,7 +36,7 @@ import widgetOverview from "./img/widget-overview.png"; ]} /> - + ### 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 4bc00990e..6ac158aac 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -34,7 +34,7 @@ This widget displays time-series charts for a selected Beszel system, including ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index ff26c0a8d..2feec3a94 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -27,7 +27,7 @@ The Coolify widget displays a compact overview of your Coolify instance. It can ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 94eb62116..62603dc12 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -26,4 +26,5 @@ See the [Custom Widgets documentation](/docs/management/custom-widgets) for full :::note 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. ::: diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index c20ba99f9..9b9d1f26f 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -20,7 +20,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns -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. Advanced view exposes every selected source, while compact layouts reduce controls according to both widget width and height. +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 @@ -53,7 +53,7 @@ import smallSize from "./img/small.png"; - 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 f4d7ca418..1e7b966b9 100644 --- a/apps/docs/docs/widgets/dns-hole-summary/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-summary/index.mdx @@ -17,7 +17,7 @@ import { dnsHoleSummaryWidget } from "."; -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. Advanced view keeps the aggregate metrics visible and adds a named status chip for each source. +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 @@ -52,7 +52,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index c988e0876..5f410a7f3 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -28,7 +28,7 @@ import youtubeStream from "./img/nasa-iss-stream.png"; iframe youtube-iss-stream
- + ### Adding the widget diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index 3b81c0023..2deeecd66 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -39,7 +39,7 @@ 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-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 4bd3aa6d1..840315831 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -48,7 +48,7 @@ import mediaRequestList from "./img/list.png"; - 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 without hiding requests returned by healthy 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 fe8055826..3adfcbffb 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provide an overview of your media requests, including total requests, open requests, approvals, 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 @@ -45,7 +45,7 @@ import mediaRequestStats from "./img/stats.png"; - 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/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index bcc6e281e..4df364116 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -29,7 +29,7 @@ import bedrockItem from "./img/bedrock.png"; minecraft-server-status bedrock-edition
- + ### 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 1386fc069..a71c9233a 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -18,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 @@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png"; ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index df4d14ec2..887908372 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -26,7 +26,7 @@ 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 41638ac33..89771e35b 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -34,7 +34,7 @@ import notificationsScreenshot from "./img/notifications.png"; items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index 4479165e2..a2ef00ebe 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -28,7 +28,7 @@ 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 ccbcd7dab..90bb6754c 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -83,7 +83,7 @@ When the widget has enough space, a "last updated" timestamp appears at the bott ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index 017ea97ca..38134344a 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. - + ### Adding the widget diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx index 1c700b15c..6fe5325de 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -21,7 +21,7 @@ import screenshot from "./img/screenshot.png"; rss-feed noted-lol - + ### Adding the widget diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index 4294c9cab..a95ed13bf 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -14,7 +14,7 @@ import { stockPriceWidget } from "."; Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. -The compact view prioritizes the symbol, current price, and trend. Company name, change, and time range appear only when both the widget width and height can support them; the advanced view always shows the complete summary. +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. ### Screenshots @@ -34,7 +34,7 @@ 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 5015ac725..d533fa025 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -44,7 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> - + ### Adding the widget diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx index 27fef9fb9..fbf41c4e3 100644 --- a/apps/docs/docs/widgets/video/index.mdx +++ b/apps/docs/docs/widgets/video/index.mdx @@ -27,7 +27,7 @@ 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/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx index fb6223cbe..0e23619e5 100644 --- a/apps/docs/docs/widgets/vpn/index.mdx +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -38,7 +38,7 @@ import singleIntegrationImage from "./img/single.png"; ]} /> - + ### Adding the widget diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx index 1328aa548..b0c08e51c 100644 --- a/apps/docs/src/components/widgets/interactions.tsx +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -1,12 +1,13 @@ import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react"; -const interactions = [ - { - icon: IconArrowsMaximize, - title: "Advanced view", - description: - "Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.", - }, +const advancedInteraction = { + icon: IconArrowsMaximize, + title: "Advanced view", + description: + "Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.", +} as const; + +const standardInteractions = [ { icon: IconMouse2, title: "Widget actions", @@ -27,27 +28,35 @@ const interactions = [ }, ] 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. The rest of the board stays visible but dimmed. Touch - devices expose a maximize control; app tiles stay compact. -

-
-); +export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => { + const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions; + + return ( +
+ + Power-user controls + Show shortcuts + + Hide shortcuts + + +
+ {interactions.map(({ icon: Icon, title, description }) => ( +
+ +
{title}
+

{description}

+
+ ))} +
+ {advancedView && ( +

+ Advanced view reuses the widget's live data and cache. The rest of the board stays visible but dimmed. + Touch devices expose a maximize control; compact-only widgets adapt in place instead. +

+ )} +
+ ); +}; diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx index ec8ba59c9..00e75d158 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-actions.tsx @@ -42,7 +42,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { { onSuccess: () => { void utils.customWidget.all.invalidate(); - void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); + void utils.widget.customApi.getData.invalidate(); void revalidatePathActionAsync("/manage/custom-widgets"); }, onError: () => { @@ -103,7 +103,7 @@ export const CustomWidgetRowActions = ({ widget }: { widget: WidgetRef }) => { message: t("notification.deleted", { name: widget.name }), }); void utils.customWidget.all.invalidate(); - void utils.widget.customApi.getData.invalidate({ definitionId: widget.id }); + void utils.widget.customApi.getData.invalidate(); void revalidatePathActionAsync("/manage/custom-widgets"); }, onError: () => { diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index c2820757f..c42230742 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx @@ -611,7 +611,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi setPreviewRefreshSignal((n) => n + 1); await utils.customWidget.all.invalidate(); await utils.customWidget.byId.invalidate({ id: definitionId }); - await utils.widget.customApi.getData.invalidate({ definitionId }); + await utils.widget.customApi.getData.invalidate(); } } catch (err) { const serverErrors = extractServerErrors(err, values.displayType); @@ -657,7 +657,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi hasTestedRef.current = true; setPreviewFetchResult({ success: res.success, - error: res.success ? undefined : res.error, + error: res.success ? undefined : t("notification.previewError"), responseInfo: res.responseInfo, rawResponse: res.rawResponse, }); @@ -679,7 +679,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi }); setPreviewJson(null); } - }, [getPreviewInput, previewMutation]); + }, [getPreviewInput, previewMutation, t]); useEffect(() => { if (previewRefreshSignal > 0 && hasTestedRef.current) { @@ -816,7 +816,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi cachedJson={previewJson} onTest={() => void handlePreviewTest()} isTesting={previewMutation.isPending} - testError={previewMutation.error?.message ?? null} + testError={previewMutation.error ? t("notification.previewError") : null} /> @@ -866,7 +866,7 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi cachedJson={previewJson} onTest={() => void handlePreviewTest()} isTesting={previewMutation.isPending} - testError={previewMutation.error?.message ?? null} + testError={previewMutation.error ? t("notification.previewError") : null} /> diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx index 383d74795..c95e2e91a 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx @@ -24,7 +24,7 @@ interface PreviewInput { export interface PreviewFetchResult { success: boolean; error?: string; - responseInfo: { status: number; statusText: string } | null; + responseInfo: { status: number } | null; rawResponse: string | null; } @@ -93,7 +93,7 @@ export function CustomWidgetPreview({ {fetchResult.responseInfo && ( - {fetchResult.responseInfo.status} {fetchResult.responseInfo.statusText} + {fetchResult.responseInfo.status} )} @@ -104,7 +104,7 @@ export function CustomWidgetPreview({ {fetchResult?.success && ( - {fetchResult.responseInfo?.status} {fetchResult.responseInfo?.statusText} + {fetchResult.responseInfo?.status} )} 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 index 60cc31954..a16e2ba12 100644 --- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -3,6 +3,10 @@ animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1); } +.manualBackdrop { + pointer-events: auto; +} + .surface { position: fixed; z-index: 1; @@ -15,6 +19,25 @@ transform-origin: top left; } +.manualSurface { + position: fixed; + max-width: none; + max-height: none; + overflow: visible; + background: transparent; + box-shadow: none; + overscroll-behavior: contain; + animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +.manualSurface > div { + width: 100%; + height: 100%; + max-height: none !important; + overflow: visible; +} + .backdropClosing { animation: focus-backdrop-out 180ms ease-in forwards; } @@ -32,6 +55,18 @@ height: 100%; } +.surfacePortalTarget, +.manualSurfaceMount { + width: 100%; + height: 100%; + min-height: 0; +} + +.manualSurfaceMount { + overflow: hidden; + border-radius: inherit; +} + .surfaceHostAdvanced { position: fixed; inset: 0; @@ -49,7 +84,7 @@ } .closeButton { - position: fixed; + position: absolute; z-index: 2; pointer-events: auto; } @@ -99,7 +134,8 @@ @media (prefers-reduced-motion: reduce) { .backdrop, - .surface { + .surface, + .manualSurface { animation: none; } } diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx index 3f38bdc44..39143a2a8 100644 --- a/apps/nextjs/src/components/board/advanced-focus/context.tsx +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -16,6 +16,7 @@ interface ActiveFocus { sourceRect: FocusRect; activation: "preview" | "manual"; autofocusClose: boolean; + restorePreviewFocus: boolean; phase: "visible" | "closing"; } @@ -79,6 +80,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, activation: options.activation ?? "manual", autofocusClose: options.autofocusClose ?? false, + restorePreviewFocus: options.activation === "preview" && document.activeElement === source, phase: "visible", }); }, @@ -118,12 +120,46 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { if (current?.activation === "preview" && current.itemId !== itemId) { cancelCloseTimer(); updateActive(null); + if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true }); } startHoverTimer(); }, [cancelCloseTimer, startHoverTimer, updateActive], ); + useEffect(() => { + const handlePointerMove = (event: PointerEvent) => { + const current = activeRef.current; + if (!shiftHeldRef.current || current?.activation !== "preview") return; + + const underlyingSource = document + .elementsFromPoint(event.clientX, event.clientY) + .map((element) => + element instanceof HTMLElement + ? element.matches(".grid-stack-item-content") + ? element + : element.closest(".grid-stack-item-content") + : null, + ) + .find((source) => source !== null && source !== current.source); + const itemId = underlyingSource?.closest(".grid-stack-item[data-id]")?.dataset.id; + if (!underlyingSource || !itemId) return; + + if (underlyingSource.hasAttribute("aria-keyshortcuts")) { + hover(itemId, underlyingSource); + return; + } + + hoveredRef.current = null; + cancelHoverTimer(); + cancelCloseTimer(); + updateActive(null); + }; + + window.addEventListener("pointermove", handlePointerMove, { capture: true }); + return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true }); + }, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]); + const leave = useCallback( (itemId: string) => { if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; @@ -145,7 +181,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape" && activeRef.current) { - const advancedSurface = document.querySelector(`.${classes.surface}`); + const advancedSurface = document.querySelector("[data-advanced-focus-surface]"); if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface)) return; event.preventDefault(); @@ -170,11 +206,11 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { if (activeRef.current?.activation === "preview") close(false); }; - window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keydown", handleKeyDown, { capture: true }); window.addEventListener("keyup", handleKeyUp); window.addEventListener("blur", handleBlur); return () => { - window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keydown", handleKeyDown, { capture: true }); window.removeEventListener("keyup", handleKeyUp); window.removeEventListener("blur", handleBlur); cancelHoverTimer(); @@ -194,7 +230,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { return ( {children} - {active && ( + {active?.activation === "preview" && ( { test("allows Escape from the advanced surface itself", () => { const surface = document.createElement("section"); - surface.setAttribute("role", "region"); + surface.setAttribute("role", "dialog"); const button = document.createElement("button"); surface.append(button); @@ -44,6 +44,24 @@ describe("isEscapeOwnedByNestedOverlay", () => { expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); }); + test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const target = document.createElement("button"); + surface.append(target); + document.body.append(surface); + + const menu = document.createElement("div"); + menu.setAttribute("role", "menu"); + menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList; + document.body.append(menu); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + + menu.remove(); + surface.remove(); + }); + test("does not mistake the compact widget source for a nested overlay", () => { const surface = document.createElement("section"); surface.setAttribute("role", "region"); diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts index 47bd86dd1..8e58947a2 100644 --- a/apps/nextjs/src/components/board/advanced-focus/escape.ts +++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts @@ -6,8 +6,14 @@ const escapeOwnerSelector = [ ].join(", "); export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => { - if (!(target instanceof Element)) return false; + const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null; + if (escapeOwner !== null && escapeOwner !== advancedSurface) return true; - const escapeOwner = target.closest(escapeOwnerSelector); - return escapeOwner !== null && escapeOwner !== advancedSurface; + return Array.from(document.querySelectorAll(escapeOwnerSelector)).some( + (owner) => + owner !== advancedSurface && + owner.getAttribute("aria-hidden") !== "true" && + owner.closest('[aria-hidden="true"]') === null && + owner.getClientRects().length > 0, + ); }; diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts new file mode 100644 index 000000000..3d5827aef --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.spec.ts @@ -0,0 +1,107 @@ +// @vitest-environment jsdom + +import { act, createElement, Fragment, useRef, useState } from "react"; +import { createRoot } from "react-dom/client"; +import { MantineProvider } from "@mantine/core"; +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { AdvancedFocusManualSurface } from "./manual-surface"; + +const sourceRect = { left: 100, top: 120, width: 240, height: 160 }; +const rect = { left: 40, top: 80, width: 640, height: 480 }; +const closePosition = { left: 688, top: 80 }; + +describe("AdvancedFocusManualSurface", () => { + let host: HTMLDivElement | undefined; + let root: ReturnType | undefined; + + afterEach(async () => { + if (root) await act(async () => root?.unmount()); + host?.remove(); + document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove()); + vi.unstubAllGlobals(); + }); + + test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "matchMedia", + vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + ); + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); + + const Harness = () => { + const [opened, setOpened] = useState(false); + const contentRef = useRef(null); + + return createElement( + Fragment, + null, + createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"), + createElement( + AdvancedFocusManualSurface, + { + opened, + phase: "visible", + id: "advanced-focus-test", + label: "Test widget advanced view", + closeLabel: "Close advanced view", + rect, + closePosition, + sourceRect, + radius: "md", + contentRef, + onClose: () => setOpened(false), + }, + createElement("button", { type: "button" }, "First widget action"), + createElement("button", { type: "button" }, "Last widget action"), + ), + ); + }; + + await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness)))); + const trigger = document.querySelector("button"); + expect(trigger).not.toBeNull(); + trigger?.focus(); + await act(async () => trigger?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + + const dialog = document.querySelector("[role='dialog']"); + const closeButton = dialog?.querySelector("[aria-label='Close advanced view']"); + const actions = dialog?.querySelectorAll("button"); + expect(dialog?.id).toBe("advanced-focus-test"); + expect(dialog?.getAttribute("aria-modal")).toBe("true"); + expect(dialog?.closest("[data-portal]")).not.toBeNull(); + expect(dialog?.closest(".grid-stack-item")).toBeNull(); + expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe( + "Test widget advanced view", + ); + expect(document.activeElement).toBe(closeButton); + + const lastAction = actions?.item((actions?.length ?? 1) - 1); + lastAction?.focus(); + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true })); + expect(document.activeElement).toBe(closeButton); + closeButton?.focus(); + document.dispatchEvent( + new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }), + ); + expect(document.activeElement).toBe(lastAction); + + const backdrop = document.querySelector("[data-advanced-focus-overlay]"); + await act(async () => backdrop?.click()); + await act(async () => await new Promise((resolve) => setTimeout(resolve, 20))); + expect(document.activeElement).toBe(trigger); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx new file mode 100644 index 000000000..7772f0d6f --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/manual-surface.tsx @@ -0,0 +1,101 @@ +"use client"; + +import type { CSSProperties, PropsWithChildren, Ref } from "react"; +import { Modal, VisuallyHidden } from "@mantine/core"; +import { useReducedMotion } from "@mantine/hooks"; + +import classes from "./advanced-focus.module.css"; +import type { FocusClosePosition, FocusRect } from "./geometry"; + +interface AdvancedFocusManualSurfaceProps extends PropsWithChildren { + opened: boolean; + phase: "visible" | "closing"; + id: string; + label: string; + closeLabel: string; + rect: FocusRect; + closePosition: FocusClosePosition; + sourceRect: FocusRect; + radius: string | number; + contentRef: Ref; + onClose: () => void; +} + +export const AdvancedFocusManualSurface = ({ + opened, + phase, + id, + label, + closeLabel, + rect, + closePosition, + sourceRect, + radius, + contentRef, + onClose, + children, +}: AdvancedFocusManualSurfaceProps) => { + const reduceMotion = useReducedMotion(); + const transitionDuration = reduceMotion ? 0 : 180; + const geometryStyle = { + left: rect.left, + top: rect.top, + width: rect.width, + height: rect.height, + "--focus-translate-x": `${sourceRect.left - rect.left}px`, + "--focus-translate-y": `${sourceRect.top - rect.top}px`, + "--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1, + "--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1, + } as CSSProperties; + const relativeClosePosition = { + left: closePosition.left - rect.left, + top: closePosition.top - rect.top, + }; + + return ( + + + + + {label} + + + {children} + + + ); +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/wheel.ts b/apps/nextjs/src/components/board/advanced-focus/wheel.ts index 193fa5dde..e6cb1946e 100644 --- a/apps/nextjs/src/components/board/advanced-focus/wheel.ts +++ b/apps/nextjs/src/components/board/advanced-focus/wheel.ts @@ -13,10 +13,8 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (delta === 0) return false; let current = target instanceof Element ? target : null; - let startedInScrollContainer = false; while (current && root.contains(current)) { const isCurrentScrollContainer = isVerticalScrollContainer(current); - startedInScrollContainer ||= isCurrentScrollContainer; if (isCurrentScrollContainer && canScrollVertically(current, delta)) { current.scrollTop += delta; return true; @@ -24,8 +22,9 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null if (current === root) break; current = current.parentElement; } - if (startedInScrollContainer) return false; + // A nested control can reach its edge before the widget's main Mantine viewport. + // Hand the gesture to that viewport instead of making Shift+wheel feel stuck. const viewport = [...root.querySelectorAll("[data-scrollbars='y'], [data-scrollbars='xy']")].find( (element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta), ); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index 32e71fe0a..3e458909f 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,8 +1,9 @@ -import type { CSSProperties, KeyboardEvent, MutableRefObject } from "react"; -import { useEffect, useRef } from "react"; -import { ActionIcon, Badge, Box, Card } from "@mantine/core"; -import { useElementSize, useViewportSize } from "@mantine/hooks"; -import { IconMaximize, IconX } from "@tabler/icons-react"; +import type { CSSProperties, KeyboardEvent } from "react"; +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { ActionIcon, Badge, Box, Card, Portal } from "@mantine/core"; +import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks"; +import { IconMaximize } from "@tabler/icons-react"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -12,8 +13,9 @@ 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 type { WidgetDefinition } from "@homarr/widgets"; +import type { WidgetRuntimeRef } from "@homarr/widgets"; import { + createWidgetRuntimeState, loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, supportsAdvancedFocus as definitionSupportsAdvancedFocus, @@ -25,6 +27,7 @@ import type { SectionItem } from "~/app/[locale]/boards/_types"; import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; import { useAdvancedFocus } from "../advanced-focus/context"; 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"; @@ -52,13 +55,12 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { const widgetName = t(`widget.${item.kind}.name`); const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName }); const [isEditMode] = useEditMode(); - const widgetStateRef = useRef | null>(null); - const closeButtonRef = useRef(null); - const surfaceRef = useRef(null); + const widgetRuntimeRef = useRef(createWidgetRuntimeState()); + const cardRef = useRef(null); + const [manualSurface, setManualSurface] = useState(null); + const [surfacePortalTarget, setSurfacePortalTarget] = useState(null); const { active, open, close, dismiss, hover, leave } = useAdvancedFocus(); - const supportsAdvancedFocus = definitionSupportsAdvancedFocus( - widgetImports[item.kind].definition as WidgetDefinition, - ); + const supportsAdvancedFocus = definitionSupportsAdvancedFocus(widgetImports[item.kind].definition); const advancedViewId = `advanced-focus-${item.id}`; const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null; const isAdvanced = activeFocus !== null; @@ -66,16 +68,21 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { ? 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"; + + // Keep one widget subtree mounted while moving its stable portal target between the grid and viewport layers. + // This preserves iframe/editor state and escapes Gridstack's transformed containing block. + useIsomorphicEffect(() => { + const target = document.createElement("div"); + target.className = advancedFocusClasses.surfacePortalTarget ?? ""; + setSurfacePortalTarget(target); + return () => target.remove(); + }, []); // Responsive layout changes remount grid items. Never leave their focus backdrop orphaned. useEffect(() => () => dismiss(item.id), [dismiss, item.id]); - useEffect(() => { - if (!activeFocus?.autofocusClose || activeFocus.phase !== "visible") return; - const frame = requestAnimationFrame(() => closeButtonRef.current?.focus()); - return () => cancelAnimationFrame(frame); - }, [activeFocus?.autofocusClose, activeFocus?.phase]); - const openAdvanced = (autofocusClose = false) => { if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose }); }; @@ -92,7 +99,7 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { openAdvanced(true); }; useEffect(() => { - const surface = surfaceRef.current; + const surface = isManual ? manualSurface : cardRef.current; if (!isAdvanced || !surface) return; const handleWheel = (event: WheelEvent) => { @@ -106,11 +113,87 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => { surface.addEventListener("wheel", handleWheel, { capture: true, passive: false }); return () => surface.removeEventListener("wheel", handleWheel, { capture: true }); - }, [isAdvanced]); + }, [isAdvanced, isManual, manualSurface]); + + const mountPortalTarget = (node: HTMLDivElement | null, activation: "compact" | "preview" | "manual") => { + if (!node || !surfacePortalTarget) return; + const shouldMount = + (activation === "compact" && !isAdvanced) || + (activation === "preview" && isPreview) || + (activation === "manual" && isManual); + if (shouldMount) node.append(surfacePortalTarget); + }; + + const previewStyle = + isPreview && advancedRect + ? ({ + 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 = ( + +
+ {!isEditMode && supportsAdvancedFocus && !isAdvanced && ( + openAdvanced()} + > + + + )} +
+ + + +
+ ); return ( <> - + { }} className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)} > -
- {activeFocus?.activation === "manual" && closePosition && ( - close()} - > - - - )} - 0 ? activeFocus.sourceRect.width / advancedRect.width : 1, - "--focus-scale-y": - advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1, - } as CSSProperties) - : undefined - } - > -
- {!isEditMode && supportsAdvancedFocus && !isAdvanced && ( - openAdvanced()} - > - - - )} -
- - - -
+
mountPortalTarget(node, "compact")} className={advancedFocusClasses.surfaceHost}> + {!surfacePortalTarget && widgetCard}
+ {surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)} + {isPreview && ( + +
mountPortalTarget(node, "preview")} + className={advancedFocusClasses.surfaceHostAdvanced} + /> + + )} + {isManual && advancedRect && closePosition && ( + close()} + > +
mountPortalTarget(node, "manual")} + className={advancedFocusClasses.manualSurfaceMount} + /> + + )} {!isAdvanced && item.advancedOptions.title?.trim() && ( | null>; + widgetRuntimeRef: WidgetRuntimeRef; displayMode: "compact" | "advanced"; } diff --git a/apps/nextjs/src/components/board/items/item-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index b8a136bbb..153de4f96 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -115,7 +115,7 @@ const BoardItemMenuInner = ({ offset, item, resetErrorBoundary }: BoardItemMenuP pos="absolute" top={offset} right={offset} - style={{ zIndex: 10 }} + style={{ zIndex: "var(--mantine-z-index-popover)" }} aria-label={tItem("menu.label.settings")} > 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 04ed92b17..787e7c1b0 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -33,7 +33,7 @@ export const ItemSelectModal = createModal(({ actions, inn const { createItem } = useItemActions(); const { openModal: openEditModal } = useModalAction(WidgetEditModal); const { data: integrationData } = clientApi.integration.all.useQuery(); - const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); + const { data: customWidgetDefs } = clientApi.customWidget.available.useQuery({ boardId: innerProps.board.id }); const settings = useSettings(); const { updateAndPersistBoard } = usePersistBoard(innerProps.board); @@ -208,7 +208,7 @@ export const ItemSelectModal = createModal(({ actions, inn - {def.description ?? def.url} + {def.description} | null>; + widgetRuntimeRef: WidgetRuntimeRef; sourceRef: RefObject; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => { +export const WidgetContextMenu = ({ item, widgetRuntimeRef, sourceRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); @@ -70,8 +70,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); - const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition as WidgetDefinition); + const currentDefinition = useMemo( + () => widgetImports[item.kind].definition as WidgetDefinition & { kind: string }, + [item.kind], + ); + const canOpenAdvancedFocus = supportsAdvancedFocus(currentDefinition); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); const { open: openAdvancedFocus } = useAdvancedFocus(); @@ -93,15 +96,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: ); const options = useMemo( - () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, + () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options), [item.kind, settings, item.options], ); - const widgetQueryKeys = useMemo( - () => getWidgetQueryKeys(currentDefinition as Parameters[0]), - [currentDefinition], - ); - const widgetQueryMatcher = (currentDefinition as WidgetDefinition).queryMatcher; + const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(currentDefinition), [currentDefinition]); + const widgetQueryMatcher = "queryMatcher" in currentDefinition ? currentDefinition.queryMatcher : undefined; const matchesWidgetQuery = useCallback( (queryKey: QueryKey) => matchesWidgetItemQuery( @@ -112,11 +112,11 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: boardId: board.id, integrationIds: item.integrationIds, options, - runtimeQueries: getWidgetRuntimeQueries(widgetStateRef), + runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef), }, widgetQueryMatcher, ), - [board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetStateRef], + [board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetRuntimeRef], ); const isWidgetFetching = useIsFetching({ @@ -156,11 +156,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); }, [currentDefinition, hasChangeAccess, settings]); - const widgetContextActions = (() => { - const def = currentDefinition 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 = + currentDefinition.contextActions?.({ options, setOptions: setItemOptions, integrationIds: item.integrationIds, @@ -170,10 +167,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: itemId: item.id, canInteractWithSelectedIntegrations, }, - widgetStateRef, - }); - return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - })(); + widgetRuntimeRef, + }) ?? []; const openEditModal = useCallback(() => { openModal( @@ -294,7 +289,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: disabled={action.disabled} color={action.color} > - {String(translateIfNecessary(t, action.label))} + {t(action.label as never)} ); })} @@ -416,14 +411,8 @@ const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQ const hasError = queries.some((q) => q.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 ( - + 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 f6ec2600f..d0c32701c 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 @@ -4,7 +4,7 @@ right: 10px; bottom: calc(100% - 10px); margin-bottom: 1px; - z-index: 200; + z-index: var(--mantine-z-index-app); pointer-events: none; opacity: 0; transform: translateY(6px); @@ -101,6 +101,15 @@ overflow: hidden; } +@media (forced-colors: active) { + .panel { + border-color: CanvasText; + background: Canvas; + box-shadow: none; + backdrop-filter: none; + } +} + .metadataLabel { color: var(--mantine-color-dimmed); flex-shrink: 0; diff --git a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx index 906abdea6..baaceb69e 100644 --- a/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx +++ b/apps/nextjs/src/components/board/sections/category/category-menu-actions.tsx @@ -2,7 +2,7 @@ import { useCallback } from "react"; import { fetchApi } from "@homarr/api/client"; import { getCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; -import { createId } from "@homarr/common"; +import { createId, getSafeApplicationUrl } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; @@ -116,10 +116,13 @@ export const useCategoryMenuActions = (category: CategorySection) => { }); const apps = await fetchApi.app.byIds.query(appIds); - const appsWithUrls = apps.filter((app) => app.href && app.href.length > 0); + const safeUrls = apps.flatMap((app) => { + const href = getSafeApplicationUrl(app.href); + return href ? [href] : []; + }); - for (const app of appsWithUrls) { - const openedWindow = window.open(app.href ?? undefined); + for (const href of safeUrls) { + const openedWindow = window.open(href, "_blank", "noopener,noreferrer"); if (openedWindow) { continue; } diff --git a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx index cffc3446f..fef149d36 100644 --- a/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx +++ b/apps/nextjs/src/components/board/sections/dynamic/dynamic-menu.tsx @@ -51,7 +51,7 @@ export const BoardDynamicSectionMenu = ({ section }: { section: DynamicSectionIt pos="absolute" top={4} right={4} - style={{ zIndex: 10 }} + style={{ zIndex: "var(--mantine-z-index-popover)" }} aria-label={tItem("menu.label.settings")} > diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index c37e34e2b..e94f6cfb6 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -19,6 +19,7 @@ import { import type { SqliteDatabase } from "./shared/e2e-db"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; import { createHomarrContainer } from "./shared/create-homarr-container"; +import { loginAsync } from "./shared/login"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" }; @@ -68,11 +69,12 @@ describe("Board advanced interactions", () => { try { const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); - const compactSurface = widget.locator(".clock-wrapper"); - const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" }); - const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" }); + const compactSurface = page.locator(".clock-wrapper").first(); + const previewSurface = page.getByRole("region", { name: "Date and time advanced view" }); + const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" }); + const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" }); const dimmingOverlay = page.locator("[data-advanced-focus-overlay]"); - const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first(); + const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first(); await compactSurface.evaluate((element) => { element.setAttribute("data-lifecycle-probe", "same-instance"); @@ -80,42 +82,54 @@ describe("Board advanced interactions", () => { await widget.focus(); await page.keyboard.down("Shift"); await widget.hover(); - await expect(advancedSurface).toBeVisible({ timeout: 2_000 }); - await expect(advancedSurface).toHaveCSS("animation-name", "none"); + await expect(previewSurface).toBeVisible({ timeout: 2_000 }); + await expect(previewSurface).toHaveCSS("animation-name", "none"); const compactBounds = await widget.boundingBox(); - const advancedBounds = await advancedSurface.boundingBox(); + const advancedBounds = await previewSurface.boundingBox(); expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0); - await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); await expect(dimmingOverlay).toBeVisible(); await expect(dimmingOverlay).toHaveCSS("pointer-events", "none"); await expect(otherWidget).toBeVisible(); await expect(widget).toBeFocused(); - await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } }); + await previewSurface.click({ button: "right", position: { x: 80, y: 80 } }); const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" }); await expect(portalledMenuItem).toBeVisible(); await portalledMenuItem.hover(); - await expect(advancedSurface).toBeVisible(); + await expect(previewSurface).toBeVisible(); await page.keyboard.press("Escape"); await expect(portalledMenuItem).toBeHidden(); - await expect(advancedSurface).toBeVisible(); + await expect(previewSurface).toBeVisible(); - await otherWidget.hover(); - await expect(advancedSurface).toBeHidden(); - await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 }); + const otherBounds = await otherWidget.boundingBox(); + expect(otherBounds).not.toBeNull(); + if (!otherBounds) throw new Error("Bookmarks widget has no bounds"); + await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2); + await expect(previewSurface).toBeHidden(); + await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 }); await page.keyboard.up("Shift"); - await expect(iframeAdvancedSurface).toBeHidden(); + await expect(bookmarksPreviewSurface).toBeHidden(); await expect(dimmingOverlay).toBeHidden(); await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); await expect(widget).toBeFocused(); await page.keyboard.press("Shift+Enter"); - await expect(advancedSurface).toBeVisible(); + await expect(manualSurface).toBeVisible(); + await expect(manualSurface).toHaveAttribute("aria-modal", "true"); + expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull(); + await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto"); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Tab"); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Shift+Tab"); await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount( 0, ); - await advancedSurface.evaluate((element) => { + await manualSurface.evaluate((element) => { const probe = document.createElement("div"); probe.dataset.shiftScrollProbe = "true"; probe.style.cssText = "height:80px;overflow-y:auto"; @@ -123,11 +137,19 @@ describe("Board advanced interactions", () => { probe.addEventListener("wheel", (event) => event.stopPropagation()); element.append(probe); - const unrelated = probe.cloneNode(true) as HTMLDivElement; + const mainViewport = document.createElement("div"); + mainViewport.dataset.scrollbars = "y"; + mainViewport.style.cssText = "height:80px;overflow-y:auto"; + mainViewport.innerHTML = '
'; + element.append(mainViewport); + + const unrelated = document.createElement("div"); unrelated.dataset.shiftScrollProbe = "unrelated"; + unrelated.style.cssText = "height:80px;overflow-y:hidden"; + unrelated.innerHTML = '
'; element.append(unrelated); }); - const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe='true']"); + const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']"); await page.keyboard.down("Shift"); const pixelEventCancelled = await scrollProbe.evaluate( (element) => @@ -156,56 +178,78 @@ describe("Board advanced interactions", () => { new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }), ); }); + expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120); expect( - await advancedSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), + await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop), ).toBe(0); + + await manualSurface.click({ button: "right", position: { x: 80, y: 80 } }); + await expect(portalledMenuItem).toBeVisible(); await page.keyboard.press("Escape"); - await expect(advancedSurface).toBeHidden(); + await expect(portalledMenuItem).toBeHidden(); + await expect(manualSurface).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(manualSurface).toBeHidden(); await expect(widget).toBeFocused(); + expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden"); await widget.click({ button: "right" }); await page.getByRole("menuitem", { name: "Open advanced view" }).click(); - await expect(advancedSurface).toBeVisible(); - await page.keyboard.press("Escape"); - await expect(advancedSurface).toBeHidden(); + await expect(manualSurface).toBeVisible(); + const viewport = page.viewportSize(); + if (!viewport) throw new Error("Board page has no viewport"); + await page.mouse.click(4, viewport.height - 4); + await expect(manualSurface).toBeHidden(); + await expect(widget).toBeFocused(); - const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first(); - await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); - await appWidget.click({ button: "right" }); - await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); - await page.keyboard.press("Escape"); - await page.keyboard.down("Shift"); - await appWidget.hover(); - await expect(advancedSurface).toBeHidden(); - await page.keyboard.up("Shift"); + for (const compactOnlyKind of ["app", "iframe"]) { + const compactOnlyWidget = page + .locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`) + .first(); + await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await compactOnlyWidget.hover({ position: { x: 4, y: 4 } }); + await expect(previewSurface).toBeHidden(); + await expect(dimmingOverlay).toBeHidden(); + await page.keyboard.up("Shift"); + } } finally { await context.close(); } }, 60_000); - test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => { + test("keeps compact-only iframe state and dismisses retained 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 = ''; + (element as HTMLIFrameElement).srcdoc = ''; + (element as HTMLIFrameElement).dataset.advancedFocusProbe = "true"; }); - const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe"); + const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']"); await frame.locator("#state").fill("changed"); - await iframeSlot.focus(); - await page.keyboard.press("Shift+Enter"); - await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible(); - await expect(frame.locator("#state")).toHaveValue("changed"); + await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter"); + await iframeSlot.dispatchEvent("contextmenu", { button: 2 }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); await page.keyboard.press("Escape"); + await page.keyboard.down("Shift"); + await iframeSlot.hover({ position: { x: 4, y: 4 } }); + await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0); + await page.keyboard.up("Shift"); await expect(frame.locator("#state")).toHaveValue("changed"); - await iframeSlot.focus(); + const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first(); + await bookmarksSlot.focus(); await page.keyboard.press("Shift+Enter"); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible(); await page.setViewportSize({ width: 800, height: 900 }); - await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden(); + await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden(); await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible(); } finally { await context.close(); @@ -229,7 +273,7 @@ describe("Board advanced interactions", () => { expect(bounds.height).toBeGreaterThanOrEqual(44); expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366); expect(bounds.y + bounds.height).toBeLessThanOrEqual(768); - const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox(); + const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox(); const contentBounds = await page.locator(".clock-widget-container").boundingBox(); expect(surfaceBounds).not.toBeNull(); expect(contentBounds).not.toBeNull(); @@ -240,7 +284,7 @@ describe("Board advanced interactions", () => { ); } await closeButton.click(); - await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden(); } finally { await context.close(); } @@ -304,7 +348,7 @@ describe("Board advanced interactions", () => { await widget.click({ button: "right" }); await page.getByRole("menuitem", { name: "Open advanced view" }).click(); - await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible(); + 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"); @@ -325,14 +369,8 @@ const openBoardAsync = async ( 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); - const loginRedirect = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" }); - await page.locator("css=button[type='submit']").click(); - await loginRedirect; - await page.goto(`${baseUrl}/boards/${boardName}`); - await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({ + await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` }); + await expect(page.locator(".clock-wrapper").first()).toBeVisible({ timeout: 15_000, }); @@ -368,6 +406,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => const desktopLayoutId = createId(); const sectionId = createId(); const clockItemId = createId(); + const bookmarksItemId = createId(); const iframeItemId = createId(); const appItemId = createId(); const appId = createId(); @@ -392,6 +431,22 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), }, + { + id: bookmarksItemId, + kind: "bookmarks", + boardId, + options: stringify({ + title: "", + layout: "column", + hideTitle: false, + hideIcon: false, + hideHostname: false, + openNewTab: true, + withBorder: false, + items: [appId], + }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, { id: iframeItemId, kind: "iframe", @@ -418,6 +473,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => width: 4, height: 3, }, + { + itemId: bookmarksItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 3, + width: 4, + height: 3, + }, { itemId: iframeItemId, sectionId, diff --git a/e2e/custom-widgets-custom-jsx.spec.ts b/e2e/custom-widgets-custom-jsx.spec.ts index 51013aea9..1f2bc31fe 100644 --- a/e2e/custom-widgets-custom-jsx.spec.ts +++ b/e2e/custom-widgets-custom-jsx.spec.ts @@ -3,6 +3,7 @@ import { describe, test } from "vitest"; import { createHomarrContainer } from "./shared/create-homarr-container"; import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import { loginAsync } from "./shared/login"; import { startMockApiServerAsync } from "./shared/mock-api-server"; import { seedAdminUserAsync } from "./shared/seed-admin-user"; @@ -17,7 +18,7 @@ const initialTemplate = const updatedTemplate = '{data.title}{data.value}'; describe("Custom JSX custom widgets", () => { - test("creates, previews, and edits a customJsx widget", async () => { + test("reliably creates, previews, and edits a customJsx widget", async () => { const mockApi = await startMockApiServerAsync({ title: "E2E Widget", status: "online", @@ -38,48 +39,56 @@ describe("Custom JSX custom widgets", () => { const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; const browser = await chromium.launch(); - const context = await browser.newContext(); - const page = await context.newPage(); const destination = "/manage/custom-widgets/new"; try { - await page.goto(`${baseUrl}/auth/login?callbackUrl=${encodeURIComponent(destination)}`, { - waitUntil: "networkidle", - }); - await page.getByLabel("Username").fill(adminCredentials.username); - await page.locator("#password").fill(adminCredentials.password); - await Promise.all([ - page.waitForURL(`**${destination}`, { timeout: 30_000 }), - page.locator("css=button[type='submit']").click(), - ]); - await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 }); - await page.getByRole("textbox", { name: "Name" }).fill("E2E Custom JSX"); - await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`); - await page.getByRole("combobox", { name: "Display Type" }).click(); - await page.getByRole("option", { name: "Custom JSX" }).click(); - await page.getByLabel("JSX Template").fill(initialTemplate); + for (const attempt of [1, 2, 3]) { + const context = await browser.newContext(); + const page = await context.newPage(); + const widgetName = `E2E Custom JSX ${attempt}`; - await page.getByRole("button", { name: "Test" }).first().click(); - const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" }); - await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({ - timeout: 15_000, - }); + try { + await loginAsync({ page, baseUrl, credentials: adminCredentials, destination }); + await expect(page.getByRole("textbox", { name: "Name" })).toBeVisible({ timeout: 30_000 }); + await page.getByRole("textbox", { name: "Name" }).fill(widgetName); + await page.getByRole("textbox", { name: "URL", exact: true }).fill(`${mockApi.url}/status`); + await page.getByRole("combobox", { name: "Display Type" }).click(); + await page.getByRole("option", { name: "Custom JSX" }).click(); + await page.getByLabel("JSX Template").fill(initialTemplate); - await page.getByRole("button", { name: "Create" }).first().click(); - await expect(page.getByText('Widget "E2E Custom JSX" created successfully.')).toBeVisible({ timeout: 15_000 }); - await page.waitForURL("**/manage/custom-widgets/edit/**", { timeout: 15_000 }); - await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate); + await page.getByRole("button", { name: "Test" }).first().click(); + const previewPanel = page.locator(".mantine-Card-root").filter({ hasText: "Preview" }); + await expect(previewPanel.getByRole("heading", { name: "E2E Widget" }).first()).toBeVisible({ + timeout: 15_000, + }); - await page.getByLabel("JSX Template").fill(updatedTemplate); - await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate); + const editNavigation = page.waitForURL((url) => url.pathname.startsWith("/manage/custom-widgets/edit/"), { + timeout: 15_000, + waitUntil: "domcontentloaded", + }); + await page.getByRole("button", { name: "Create" }).first().click(); + await editNavigation; + await expect(page.getByText(`Widget "${widgetName}" created successfully.`)).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByLabel("JSX Template")).toHaveValue(initialTemplate); - await page.getByRole("button", { name: "Save" }).first().click(); - await expect(page.getByText('Widget "E2E Custom JSX" updated successfully.')).toBeVisible({ timeout: 15_000 }); - await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate); + await page.getByLabel("JSX Template").fill(updatedTemplate); + await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate); + + await page.getByRole("button", { name: "Save" }).first().click(); + await expect(page.getByText(`Widget "${widgetName}" updated successfully.`)).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByLabel("JSX Template")).toHaveValue(updatedTemplate); + } finally { + await context.close(); + } + } } finally { await browser.close(); await homarrContainer.stop(); await mockApi.close(); } - }, 120_000); + }, 180_000); }); diff --git a/e2e/shared/login.ts b/e2e/shared/login.ts new file mode 100644 index 000000000..37684299b --- /dev/null +++ b/e2e/shared/login.ts @@ -0,0 +1,51 @@ +import type { Page } from "@playwright/test"; + +interface LoginInput { + page: Page; + baseUrl: string; + credentials: { + username: string; + password: string; + }; + destination?: string; +} + +export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => { + const destinationUrl = new URL(destination, baseUrl); + const loginUrl = new URL("/auth/login", baseUrl); + loginUrl.searchParams.set("callbackUrl", destinationUrl.href); + + await page.goto(loginUrl.href, { waitUntil: "load" }); + await page.waitForFunction(() => { + const form = document.querySelector("form"); + return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$")); + }); + await page.getByLabel("Username").fill(credentials.username); + await page.locator("#password").fill(credentials.password); + + await page.locator("button[type='submit']").click(); + await waitForAuthenticatedSessionAsync(page, baseUrl); + + await page.goto(destinationUrl.href, { waitUntil: "domcontentloaded" }); + const currentUrl = new URL(page.url()); + if (currentUrl.origin !== destinationUrl.origin || currentUrl.pathname !== destinationUrl.pathname) { + throw new Error("Authenticated destination was not reached"); + } +}; + +const waitForAuthenticatedSessionAsync = async (page: Page, baseUrl: string) => { + const sessionUrl = new URL("/api/auth/session", baseUrl).href; + const deadline = Date.now() + 30_000; + + while (Date.now() < deadline) { + const response = await page.request.get(sessionUrl); + if (response.ok()) { + const session = (await response.json()) as { user?: unknown } | null; + if (session?.user !== undefined) return; + } + + await page.waitForTimeout(100); + } + + throw new Error("Authentication session was not established"); +}; diff --git a/packages/api/src/router/custom-widget/custom-widget-access.ts b/packages/api/src/router/custom-widget/custom-widget-access.ts new file mode 100644 index 000000000..90abeebc3 --- /dev/null +++ b/packages/api/src/router/custom-widget/custom-widget-access.ts @@ -0,0 +1,45 @@ +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; +import { z } from "zod/v4"; + +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 type { BoardPermission } from "@homarr/definitions"; + +import { throwIfActionForbiddenAsync } from "../board/board-access"; + +export const customApiItemInputSchema = z.object({ + boardId: z.string().min(1).max(100), + itemId: z.string().min(1).max(100), + definitionId: z.string().min(1).max(100), +}); + +const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough(); + +export async function assertCustomApiItemBindingAsync( + ctx: { db: Database; session: Session | null }, + input: z.infer, + permission?: BoardPermission, +) { + const item = await ctx.db.query.items.findFirst({ + columns: { options: true }, + where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")), + }); + + if (!item) throwCustomApiWidgetNotFound(); + if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission); + + try { + const options = customApiOptionsSchema.parse(parse(item.options)); + if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound(); + } catch (error) { + if (error instanceof TRPCError) throw error; + throwCustomApiWidgetNotFound(); + } +} + +function throwCustomApiWidgetNotFound(): never { + throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" }); +} diff --git a/packages/api/src/router/custom-widget/custom-widget-router.ts b/packages/api/src/router/custom-widget/custom-widget-router.ts index dc15b6759..286cb6cd8 100644 --- a/packages/api/src/router/custom-widget/custom-widget-router.ts +++ b/packages/api/src/router/custom-widget/custom-widget-router.ts @@ -4,7 +4,7 @@ import { z } from "zod/v4"; import { createId } from "@homarr/common"; import { decryptSecret, encryptSecret } from "@homarr/common/server"; -import { customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; +import { boards, customWidgetDefinitions, customWidgetSecrets } from "@homarr/db/schema"; import { eq } from "@homarr/db"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { @@ -19,17 +19,23 @@ import { } from "@homarr/validation/custom-widget"; import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; +import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "./custom-widget-access"; import { applyAuth } from "./auth"; import { extractDisplayDataWithFallback } from "./display-data"; import { parseDisplayConfig } from "./parse-display-config"; +import { + consumeBoundedResponseAsync, + readBoundedJsonResponseAsync, + validateCustomApiUrl, + withCustomApiResponseAsync, +} from "../widgets/custom-api-security"; const adminProcedure = permissionRequiredProcedure.requiresPermission("admin"); const logger = createLogger({ module: "custom-widget" }); -const FETCH_TIMEOUT_MS = 10_000; - -const validateUrl = (urlString: string): URL => new URL(urlString); +const PUBLIC_PREVIEW_ERROR = "Custom API preview failed"; const updateFieldSerializers: Record unknown> = { displayConfig: (value) => superjson.stringify(value), @@ -54,7 +60,7 @@ function getImportJsonSchema() { export const customWidgetRouter = createTRPCRouter({ schema: publicProcedure.query(() => getImportJsonSchema()), - all: protectedProcedure.query(async ({ ctx }) => { + all: adminProcedure.query(async ({ ctx }) => { const definitions = await ctx.db.query.customWidgetDefinitions.findMany({ orderBy: (table, { asc }) => asc(table.name), }); @@ -72,7 +78,24 @@ export const customWidgetRouter = createTRPCRouter({ })); }), - byId: protectedProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { + available: protectedProcedure + .input(z.object({ boardId: z.string().min(1).max(100) })) + .query(async ({ ctx, input }) => { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + return await ctx.db.query.customWidgetDefinitions.findMany({ + columns: { + id: true, + name: true, + description: true, + iconUrl: true, + displayType: true, + enabled: true, + }, + orderBy: (table, { asc }) => asc(table.name), + }); + }), + + byId: adminProcedure.input(z.object({ id: z.string() })).query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.id), with: { secrets: true }, @@ -275,75 +298,65 @@ export const customWidgetRouter = createTRPCRouter({ }), ) .mutation(async ({ ctx, input }) => { - const secrets = [...input.secrets]; - - if (input.definitionId) { - const existing = await ctx.db.query.customWidgetDefinitions.findFirst({ - where: eq(customWidgetDefinitions.id, input.definitionId), - with: { secrets: true }, - }); - if (existing) { - for (const dbSecret of existing.secrets) { - if (!secrets.some((s) => s.kind === dbSecret.kind)) { - secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) }); + try { + const secrets = [...input.secrets]; + if (input.definitionId) { + const existing = await ctx.db.query.customWidgetDefinitions.findFirst({ + where: eq(customWidgetDefinitions.id, input.definitionId), + with: { secrets: true }, + }); + if (existing) { + for (const dbSecret of existing.secrets) { + if (!secrets.some((s) => s.kind === dbSecret.kind)) { + secrets.push({ kind: dbSecret.kind, value: decryptSecret(dbSecret.value) }); + } } } } - } - const url = validateUrl(input.url); - const headers = new Headers({ Accept: "application/json" }); + const url = validateCustomApiUrl(input.url); + const headers = new Headers({ Accept: "application/json" }); + if (input.method !== "GET" && input.requestBody) headers.set("Content-Type", "application/json"); + applyAuth(headers, url, input.authType, secrets, input.headerName); - if (input.method !== "GET" && input.requestBody) { - headers.set("Content-Type", "application/json"); - } + return await withCustomApiResponseAsync( + url, + { + method: input.method, + headers, + body: input.method !== "GET" ? input.requestBody : undefined, + }, + async (response) => { + const responseInfo = { status: response.status }; + if (!response.ok) { + await consumeBoundedResponseAsync(response); + return { + success: false as const, + error: PUBLIC_PREVIEW_ERROR, + responseInfo, + rawResponse: null, + }; + } - applyAuth(headers, url, input.authType, secrets, input.headerName); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - - try { - const response = await fetch(url.toString(), { - method: input.method, - headers, - body: input.method !== "GET" ? input.requestBody : undefined, - redirect: "follow", - signal: controller.signal, - }); - - const responseInfo = { status: response.status, statusText: response.statusText }; - - if (!response.ok) { - const body = await response.text().catch(() => ""); - return { - success: false as const, - error: `HTTP ${response.status}: ${response.statusText}`, - responseInfo, - rawResponse: body, - }; - } - - const json: unknown = await response.json(); - const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig); - - return { - success: true as const, - responseInfo, - rawResponse: JSON.stringify(json, null, 2), - displayData, - }; + const json = await readBoundedJsonResponseAsync(response); + const displayData = extractDisplayDataWithFallback(json, input.displayType, input.displayConfig); + return { + success: true as const, + responseInfo, + rawResponse: JSON.stringify(json, null, 2), + displayData, + }; + }, + ); } catch (error) { - if (error instanceof TRPCError) throw error; - logger.error("Custom widget preview failed", { error }); + const errorId = createId(); + logger.error("Custom widget preview failed", { errorId, error }); return { success: false as const, - error: error instanceof Error ? error.message : "Failed to fetch data", + error: PUBLIC_PREVIEW_ERROR, responseInfo: null, rawResponse: null, }; - } finally { - clearTimeout(timeout); } }), @@ -389,7 +402,8 @@ export const customWidgetRouter = createTRPCRouter({ return { id: newId, name: `${definition.name} (copy)` }; }), - execute: protectedProcedure.input(z.object({ definitionId: z.string() })).mutation(async ({ ctx, input }) => { + execute: adminProcedure.input(customApiItemInputSchema).mutation(async ({ ctx, input }) => { + await assertCustomApiItemBindingAsync(ctx, input); const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.definitionId), with: { secrets: true }, @@ -407,50 +421,41 @@ export const customWidgetRouter = createTRPCRouter({ throw new TRPCError({ code: "BAD_REQUEST", message: "Only actionButton widgets can be executed" }); } - const decryptedSecrets = definition.secrets.map((s) => ({ - kind: s.kind, - value: decryptSecret(s.value), - })); - - const url = validateUrl(definition.url); - const headers = new Headers({ Accept: "application/json" }); - - if (definition.method !== "GET" && definition.requestBody) { - headers.set("Content-Type", "application/json"); - } - - applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const response = await fetch(url.toString(), { - method: definition.method, - headers, - body: definition.method !== "GET" ? definition.requestBody : undefined, - redirect: "follow", - signal: controller.signal, - }); + const decryptedSecrets = definition.secrets.map((s) => ({ + kind: s.kind, + value: decryptSecret(s.value), + })); + const url = validateCustomApiUrl(definition.url); + const headers = new Headers({ Accept: "application/json" }); + if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json"); + applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName); - const responseInfo = { status: response.status, statusText: response.statusText }; - - if (!response.ok) { - return { success: false as const, error: `HTTP ${response.status}: ${response.statusText}`, responseInfo }; - } - - logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status }); - return { success: true as const, responseInfo }; + return await withCustomApiResponseAsync( + url, + { + method: definition.method, + headers, + body: definition.method !== "GET" ? definition.requestBody : undefined, + }, + async (response) => { + await consumeBoundedResponseAsync(response); + const responseInfo = { status: response.status }; + if (!response.ok) { + return { success: false as const, errorCode: "UPSTREAM_REJECTED" as const, responseInfo }; + } + logger.info("Executed custom widget action", { definitionId: input.definitionId, status: response.status }); + return { success: true as const, responseInfo }; + }, + ); } catch (error) { - if (error instanceof TRPCError) throw error; - logger.error("Custom widget execute failed", { definitionId: input.definitionId, error }); + const errorId = createId(); + logger.error("Custom widget execute failed", { errorId, definitionId: input.definitionId, error }); return { success: false as const, - error: error instanceof Error ? error.message : "Request failed", + errorCode: "REQUEST_FAILED" as const, responseInfo: null, }; - } finally { - clearTimeout(timeout); } }), }); diff --git a/packages/api/src/router/custom-widget/display-data.ts b/packages/api/src/router/custom-widget/display-data.ts index 627530d20..d04550d83 100644 --- a/packages/api/src/router/custom-widget/display-data.ts +++ b/packages/api/src/router/custom-widget/display-data.ts @@ -111,13 +111,7 @@ const extractors: Record = { data: query(json, (c.jsonPath as string) ?? "$"), maxHeight: c.maxHeight ?? 300, }), - actionButton: (_json, c) => ({ - type: "actionButton", - buttonLabel: c.buttonLabel ?? "Execute", - buttonColor: c.buttonColor ?? "blue", - confirmText: c.confirmText ?? "", - successMessage: c.successMessage ?? "", - }), + actionButton: (_json, c) => extractActionButtonDisplay(c), customJsx: (json, config) => ({ type: "customJsx" as const, template: config.template as string, @@ -140,6 +134,20 @@ export function extractDisplayDataWithFallback(json: unknown, displayType: strin return extractor?.(json, displayConfig); } -export function extractActionButtonDisplay(displayConfig: Config): unknown { - return extractors.actionButton?.(null, displayConfig); +export interface ActionButtonDisplayData { + type: "actionButton"; + buttonLabel: string; + buttonColor: string; + confirmText: string; + successMessage: string; +} + +export function extractActionButtonDisplay(displayConfig: Config): ActionButtonDisplayData { + return { + type: "actionButton", + buttonLabel: typeof displayConfig.buttonLabel === "string" ? displayConfig.buttonLabel : "Execute", + buttonColor: typeof displayConfig.buttonColor === "string" ? displayConfig.buttonColor : "blue", + confirmText: typeof displayConfig.confirmText === "string" ? displayConfig.confirmText : "", + successMessage: typeof displayConfig.successMessage === "string" ? displayConfig.successMessage : "", + }; } diff --git a/packages/api/src/router/location.ts b/packages/api/src/router/location.ts index 99bb9c3af..050d66cb4 100644 --- a/packages/api/src/router/location.ts +++ b/packages/api/src/router/location.ts @@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({ .input(locationSearchCityInput) .output(locationSearchCityOutput) .query(async ({ input }) => { + const url = new URL("https://geocoding-api.open-meteo.com/v1/search"); + url.searchParams.set("name", input.query); const res = await withTimeoutAsync(async (signal) => { - return await fetchWithTrustedCertificatesAsync( - `https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`, - { signal }, - ); + return await fetchWithTrustedCertificatesAsync(url.toString(), { signal }); }); return (await res.json()) as z.infer; }), diff --git a/packages/api/src/router/test/custom-widget.spec.ts b/packages/api/src/router/test/custom-widget.spec.ts new file mode 100644 index 000000000..b026b2f68 --- /dev/null +++ b/packages/api/src/router/test/custom-widget.spec.ts @@ -0,0 +1,376 @@ +// @vitest-environment node + +import { stringify } from "superjson"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { eq } from "@homarr/db"; +import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; +import type { GroupPermissionKey } from "@homarr/definitions"; + +import { customWidgetRouter } from "../custom-widget/custom-widget-router"; +import { customApiRouter } from "../widgets/custom-api"; + +const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({ + user: { id: userId, permissions, colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCustomWidgetCaller = (db: Database, session: Session | null) => + customWidgetRouter.createCaller({ db, deviceType: undefined, session }); + +const createCustomApiCaller = (db: Database, session: Session | null) => + customApiRouter.createCaller({ db, deviceType: undefined, session }); + +async function createFixtureAsync(db: Database) { + const ownerId = createId(); + const viewerId = createId(); + const modifierId = createId(); + const unrelatedId = createId(); + const adminId = createId(); + const boardId = createId(); + const itemId = createId(); + const definitionId = createId(); + + await db.insert(users).values( + [ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({ + id, + name: id, + })), + ); + await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false }); + await db.insert(boardUserPermissions).values([ + { boardId, userId: viewerId, permission: "view" }, + { boardId, userId: modifierId, permission: "modify" }, + ]); + await db.insert(customWidgetDefinitions).values({ + id: definitionId, + name: "Restart service", + description: "Restarts a service", + url: "https://api.example.com/restart", + method: "POST", + displayType: "actionButton", + displayConfig: stringify({ + type: "actionButton", + buttonLabel: "Restart", + confirmText: "Continue?", + }), + creatorId: adminId, + }); + await db.insert(items).values({ + id: itemId, + boardId, + kind: "customApi", + options: stringify({ definitionId, refreshInterval: 30 }), + }); + + return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId }; +} + +beforeEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.useRealTimers(); +}); + +describe("custom widget definition authorization", () => { + it("restricts the full definition list to administrators", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + + await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) { + await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + } + + await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([ + expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }), + ]); + }); + + it("returns only safe definition summaries to board modifiers", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + + const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({ + boardId: fixture.boardId, + }); + expect(result).toEqual([ + { + id: fixture.definitionId, + name: "Restart service", + description: "Restarts a service", + iconUrl: null, + displayType: "actionButton", + enabled: true, + }, + ]); + expect(JSON.stringify(result)).not.toContain("api.example.com"); + + for (const userId of [fixture.viewerId, fixture.unrelatedId]) { + await expect( + createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + } + }); +}); + +describe("custom widget action authorization", () => { + it("requires an administrator even for users who can view or modify the board", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const input = { + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }; + + await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" }); + for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) { + await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + } + }); + + it("executes for an administrator only when board, item, and definition are bound", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 })); + vi.stubGlobal("fetch", fetchMock); + const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])); + + await expect( + caller.execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).resolves.toEqual({ success: true, responseInfo: { status: 204 } }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/restart", + expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }), + ); + }); + + it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const input = { + boardId: mismatch === "board" ? createId() : fixture.boardId, + itemId: mismatch === "item" ? createId() : fixture.itemId, + definitionId: mismatch === "definition" ? createId() : fixture.definitionId, + }; + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects disabled definitions before sending a request", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ enabled: false }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("custom API outbound request hardening", () => { + it("rejects credential-bearing action URLs without exposing the credential", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ url: "https://operator:super-secret@private.example.com/restart" }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + + const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("rejects redirects and redacts fetch errors", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi + .fn() + .mockRejectedValue( + new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"), + ); + vi.stubGlobal("fetch", fetchMock); + + const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/restart", + expect.objectContaining({ redirect: "error" }), + ); + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/); + }); + + it("aborts timed-out action requests and returns a stable failure", async () => { + vi.useFakeTimers(); + const db = createDb(); + const fixture = await createFixtureAsync(db); + const started = Promise.withResolvers(); + const fetchMock = vi.fn((_url: string, init: RequestInit) => { + started.resolve(); + return new Promise((_resolve, reject) => { + init.signal?.addEventListener("abort", () => { + reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret")); + }); + }); + }); + vi.stubGlobal("fetch", fetchMock); + + const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }); + await started.promise; + await vi.advanceTimersByTimeAsync(10_000); + + const result = await request; + expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/); + }); + + it("bounds action responses before reporting success", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1)))); + + await expect( + createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }), + ).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null }); + }); + + it("binds data requests to a viewable item and never exposes backend error text", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ + displayType: "singleValue", + method: "GET", + displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }), + }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")), + ); + + const input = { + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }; + const error = await createCustomApiCaller(db, createSession(fixture.viewerId)) + .getData(input) + .catch((caught: unknown) => caught); + expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" }); + expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/); + + await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect( + createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("bounds and disposes non-success data responses", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + await db + .update(customWidgetDefinitions) + .set({ + displayType: "singleValue", + method: "GET", + displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }), + }) + .where(eq(customWidgetDefinitions.id, fixture.definitionId)); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 }))); + + const error = await createCustomApiCaller(db, createSession(fixture.viewerId)) + .getData({ + boardId: fixture.boardId, + itemId: fixture.itemId, + definitionId: fixture.definitionId, + }) + .catch((caught: unknown) => caught); + + expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" }); + expect(JSON.stringify(error)).not.toContain("x".repeat(100)); + }); + + it("redacts unsafe preview failures and rejects redirects", async () => { + const db = createDb(); + const fixture = await createFixtureAsync(db); + const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value")); + vi.stubGlobal("fetch", fetchMock); + const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])); + + const result = await caller.preview({ + url: "https://api.example.com/data", + method: "GET", + authType: "none", + displayType: "singleValue", + displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" }, + secrets: [], + }); + expect(fetchMock).toHaveBeenCalledWith( + "https://api.example.com/data", + expect.objectContaining({ redirect: "error" }), + ); + expect(result).toEqual({ + success: false, + error: "Custom API preview failed", + responseInfo: null, + rawResponse: null, + }); + expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/); + }); +}); diff --git a/packages/api/src/router/test/location.spec.ts b/packages/api/src/router/test/location.spec.ts new file mode 100644 index 000000000..64c6cdfb4 --- /dev/null +++ b/packages/api/src/router/test/location.spec.ts @@ -0,0 +1,36 @@ +// @vitest-environment node + +import { beforeEach, describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; + +import { locationRouter } from "../location"; + +const mocks = vi.hoisted(() => ({ fetch: vi.fn() })); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: mocks.fetch, +})); + +vi.mock("@homarr/core/infrastructure/http/timeout", () => ({ + withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise) => + await callback(new AbortController().signal), +})); + +beforeEach(() => { + vi.clearAllMocks(); + mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] }))); +}); + +describe("locationRouter", () => { + test("encodes the complete search text as one name parameter", async () => { + const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null }); + + await caller.searchCity({ query: "Paris&language=xx&token=secret" }); + + const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string); + expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret"); + expect(requestedUrl.searchParams.has("language")).toBe(false); + expect(requestedUrl.searchParams.has("token")).toBe(false); + }); +}); diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts index 4e3db53fc..d72d125d2 100644 --- a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts +++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts @@ -12,8 +12,14 @@ import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list"; import { calendarRouter } from "../../widgets/calendar"; +import { beszelRouter } from "../../widgets/beszel"; +import { firewallRouter } from "../../widgets/firewall"; +import { indexerManagerRouter } from "../../widgets/indexer-manager"; +import { mediaOrganizerRouter } from "../../widgets/media-organizer"; import { mediaRequestsRouter } from "../../widgets/media-requests"; import { mediaServerRouter } from "../../widgets/media-server"; +import { notificationsRouter } from "../../widgets/notifications"; +import { vpnRouter } from "../../widgets/vpn"; vi.hoisted(() => { process.env.SKIP_ENV_VALIDATION = "true"; @@ -21,7 +27,11 @@ vi.hoisted(() => { const createHandler = (integration: { name: string }, data: T) => ({ getDataAsync: async () => { - if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret"); + if (integration.name === "Offline") { + throw new Error( + "GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body", + ); + } return { data, timestamp: new Date() }; }, }); @@ -34,6 +44,40 @@ vi.mock("@homarr/request-handler/media-server", () => ({ mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, })); +vi.mock("@homarr/request-handler/media-organizer", () => ({ + mediaOrganizerRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }), + }, +})); + +vi.mock("@homarr/request-handler/beszel", () => ({ + beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/firewall", () => ({ + firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) }, + firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, + firewallMemoryRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }), + }, + firewallVersionRequestHandler: { + handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }), + }, +})); + +vi.mock("@homarr/request-handler/indexer-manager", () => ({ + indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/notifications", () => ({ + notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/vpn", () => ({ + vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) }, +})); + vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => { const actual = await importOriginal(); return { @@ -121,7 +165,7 @@ describe("partial integration failures", () => { expect.arrayContaining([ expect.objectContaining({ integration: expect.objectContaining({ id: calendar.offlineId }), - error: "Integration request failed", + error: "INTEGRATION_REQUEST_FAILED", }), ]), ); @@ -136,7 +180,7 @@ describe("partial integration failures", () => { expect.objectContaining({ integrationId: mediaServer.offlineId, sessions: [], - error: "Integration request failed", + error: "INTEGRATION_REQUEST_FAILED", }), ]), ); @@ -165,13 +209,13 @@ describe("partial integration failures", () => { }), ]); expect(latest.failedIntegrations).toEqual([ - expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }), + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), ]); expect(JSON.stringify(latest)).not.toContain("secret"); const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds }); expect(stats.failedIntegrations).toEqual([ - expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }), + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }), ]); expect(JSON.stringify(stats)).not.toContain("secret"); expect(stats.stats).toHaveLength(1); @@ -183,4 +227,58 @@ describe("partial integration failures", () => { }), ]); }); + + test("aggregate widget payloads never expose sensitive partial-failure details", async () => { + const cases = [ + { + kind: "sonarr" as const, + run: async (setup: Awaited>) => + await mediaOrganizerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getData({ integrationIds: setup.integrationIds, pageSize: 10 }), + }, + { + kind: "beszel" as const, + run: async (setup: Awaited>) => + await beszelRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSystems({ integrationIds: setup.integrationIds }), + }, + { + kind: "opnsense" as const, + run: async (setup: Awaited>) => + await firewallRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getFirewallCpuStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "prowlarr" as const, + run: async (setup: Awaited>) => + await indexerManagerRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getIndexersStatus({ integrationIds: setup.integrationIds }), + }, + { + kind: "ntfy" as const, + run: async (setup: Awaited>) => + await notificationsRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getNotifications({ integrationIds: setup.integrationIds }), + }, + { + kind: "gluetun" as const, + run: async (setup: Awaited>) => + await vpnRouter + .createCaller({ db: setup.db, deviceType: undefined, session: setup.session }) + .getSummaries({ integrationIds: setup.integrationIds }), + }, + ]; + + for (const testCase of cases) { + const setup = await setupAsync(testCase.kind); + const payload = JSON.stringify(await testCase.run(setup)); + expect(payload).toContain("INTEGRATION_REQUEST_FAILED"); + expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i); + } + }); }); 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 index 12fbe5778..729e5f4c8 100644 --- a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts +++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts @@ -1,3 +1,5 @@ +// @vitest-environment node + import { describe, expect, it } from "vitest"; import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security"; diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 8cfc3e166..c52ca1717 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -10,7 +10,7 @@ import { beszelSystemsRequestHandler, } from "@homarr/request-handler/beszel"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; import { BoundedAsyncQueue } from "./bounded-async-queue"; @@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); const MAX_PENDING_LIVE_EVENTS = 4; -const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); - export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({ return { integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: data, updatedAt: timestamp, }; @@ -50,10 +47,9 @@ export const beszelRouter = createTRPCRouter({ fallback: (integration, error) => ({ integrationId: integration.id, integrationName: integration.name, - integrationUrl: integration.url, systems: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, @@ -127,7 +123,7 @@ export const beszelRouter = createTRPCRouter({ history: [], systemNameMap: {}, updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, @@ -194,7 +190,7 @@ export const beszelRouter = createTRPCRouter({ systemStats: [], containerStats: [], updatedAt: new Date(0), - error: errorMessage(error), + error: toPublicIntegrationError(error), }; } }), @@ -271,15 +267,14 @@ export const beszelRouter = createTRPCRouter({ integrationId: integration.id, systemId: input.systemId, emittedEventCount, - error: errorMessage(error), + error, }); queue.fail( - error instanceof TRPCError - ? error - : new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : String(error), - }), + new TRPCError({ + code: "BAD_GATEWAY", + message: "Live integration request failed", + cause: error, + }), ); } })(); diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 4265064b7..8e9ee31ef 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -5,7 +5,7 @@ import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const calendarRouter = createTRPCRouter({ @@ -52,7 +52,7 @@ export const calendarRouter = createTRPCRouter({ name: integration.name, kind: integration.kind, }, - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts index ec2d5c81f..abe005055 100644 --- a/packages/api/src/router/widgets/custom-api-security.ts +++ b/packages/api/src/router/widgets/custom-api-security.ts @@ -1,6 +1,7 @@ import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024; +export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000; export function validateCustomApiUrl(urlString: string): URL { if (urlString.length > 2_048) throw new Error("Custom API URL is too long"); @@ -19,3 +20,48 @@ export async function readBoundedJsonResponseAsync( ): Promise { return await readBoundedJsonResponse(response, maxBytes, "Custom API response"); } + +export async function consumeBoundedResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + await response.body?.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + + if (!response.body) return; + const reader = response.body.getReader(); + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) return; + byteLength += value?.byteLength ?? 0; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`Custom API response exceeds ${maxBytes} bytes`); + } + } +} + +export async function withCustomApiResponseAsync( + url: URL, + init: Omit, + consumeResponseAsync: (response: Response) => Promise, + timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS, +): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + + try { + const response = await fetch(url.toString(), { + ...init, + redirect: "error", + signal: controller.signal, + }); + return await consumeResponseAsync(response); + } finally { + clearTimeout(timeout); + } +} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index a306e71ca..68e2ccad7 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -1,22 +1,27 @@ import { TRPCError } from "@trpc/server"; -import superjson from "superjson"; -import { z } from "zod/v4"; +import { parse } from "superjson"; +import { createId } from "@homarr/common"; import { decryptSecret } from "@homarr/common/server"; import { customWidgetDefinitions } from "@homarr/db/schema"; import { eq } from "@homarr/db"; import { createLogger } from "@homarr/core/infrastructure/logs"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { applyAuth } from "../custom-widget/auth"; +import { assertCustomApiItemBindingAsync, customApiItemInputSchema } from "../custom-widget/custom-widget-access"; import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data"; -import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "./custom-api-security"; +import { + consumeBoundedResponseAsync, + readBoundedJsonResponseAsync, + validateCustomApiUrl, + withCustomApiResponseAsync, +} from "./custom-api-security"; const logger = createLogger({ module: "widget:customApi" }); -const FETCH_TIMEOUT_MS = 10_000; - export const customApiRouter = createTRPCRouter({ - getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => { + getData: protectedProcedure.input(customApiItemInputSchema).query(async ({ ctx, input }) => { + await assertCustomApiItemBindingAsync(ctx, input, "view"); const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ where: eq(customWidgetDefinitions.id, input.definitionId), with: { secrets: true }, @@ -32,59 +37,52 @@ export const customApiRouter = createTRPCRouter({ let displayConfig: Record; try { - displayConfig = superjson.parse(definition.displayConfig) as Record; + displayConfig = parse(definition.displayConfig) as Record; } catch { throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Widget has corrupt display configuration" }); } if (definition.displayType === "actionButton") { - return extractActionButtonDisplay(displayConfig); + const actionDisplay = extractActionButtonDisplay(displayConfig); + return { + ...actionDisplay, + canExecute: ctx.session.user.permissions.includes("admin"), + }; } - const decryptedSecrets = definition.secrets.map((s) => ({ - kind: s.kind, - value: decryptSecret(s.value), - })); - - const url = validateCustomApiUrl(definition.url); - const headers = new Headers({ Accept: "application/json" }); - - if (definition.method !== "GET" && definition.requestBody) { - headers.set("Content-Type", "application/json"); - } - - applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName); - - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); - try { - const response = await fetch(url.toString(), { - method: definition.method, - headers, - body: definition.method !== "GET" ? definition.requestBody : undefined, - redirect: "error", - signal: controller.signal, - }); + const decryptedSecrets = definition.secrets.map((s) => ({ + kind: s.kind, + value: decryptSecret(s.value), + })); + const url = validateCustomApiUrl(definition.url); + const headers = new Headers({ Accept: "application/json" }); + if (definition.method !== "GET" && definition.requestBody) headers.set("Content-Type", "application/json"); + applyAuth(headers, url, definition.authType, decryptedSecrets, definition.headerName); - if (!response.ok) { - throw new TRPCError({ - code: "BAD_REQUEST", - message: `API returned ${response.status}: ${response.statusText}`, - }); - } - - const json = await readBoundedJsonResponseAsync(response); - return extractDisplayDataWithFallback(json, definition.displayType, displayConfig); + return await withCustomApiResponseAsync( + url, + { + method: definition.method, + headers, + body: definition.method !== "GET" ? definition.requestBody : undefined, + }, + async (response) => { + if (!response.ok) { + await consumeBoundedResponseAsync(response); + throw new Error(`Custom API returned HTTP ${response.status}`); + } + const json = await readBoundedJsonResponseAsync(response); + return extractDisplayDataWithFallback(json, definition.displayType, displayConfig); + }, + ); } catch (error) { - if (error instanceof TRPCError) throw error; - logger.error("Failed to fetch custom API data", { definitionId: input.definitionId, error }); + const errorId = createId(); + logger.error("Failed to fetch custom API data", { errorId, definitionId: input.definitionId, error }); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", - message: error instanceof Error ? error.message : "Failed to fetch data from external API", + message: "Custom API request failed", }); - } finally { - clearTimeout(timeout); } }), }); diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index fdeb77831..335f75d09 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -7,7 +7,7 @@ import { } from "@homarr/request-handler/firewall"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall")); @@ -35,7 +35,7 @@ const queryFirewall = < 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", + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index fe411d9be..4d1d893d8 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) => @@ -34,7 +34,7 @@ export const indexerManagerRouter = createTRPCRouter({ integrationId: integration.id, integrationName: integration.name, indexers: [], - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index 2f38423f1..91738b4a8 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -4,7 +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 { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaOrganizerRouter = createTRPCRouter({ @@ -39,7 +39,7 @@ export const mediaOrganizerRouter = createTRPCRouter({ missingCount: 0, queued: [], queuedCount: 0, - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index 4a68adb12..28d0cb15a 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -12,7 +12,7 @@ import { import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; interface IntegrationQueryResult { @@ -47,7 +47,7 @@ export const mediaRequestsRouter = createTRPCRouter({ fallback: (integration) => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind }, data: [], - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, @@ -104,7 +104,7 @@ export const mediaRequestsRouter = createTRPCRouter({ fallback: (integration) => ({ integration: { id: integration.id, name: integration.name, kind: integration.kind }, data: { stats: null, users: [] }, - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 1ce8b863f..77e00204b 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server" import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createMediaServerIntegrationMiddleware = (action: IntegrationAction) => @@ -43,7 +43,7 @@ export const mediaServerRouter = createTRPCRouter({ integrationName: integration.name, integrationKind: integration.kind, sessions: [], - error: "Integration request failed", + error: PUBLIC_INTEGRATION_ERROR, }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index 86124164c..3453b47a6 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const createNotificationsIntegrationMiddleware = (action: IntegrationAction) => @@ -39,7 +39,7 @@ export const notificationsRouter = createTRPCRouter({ updatedAt: new Date(0), }, data: [], - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/router/widgets/releases.ts b/packages/api/src/router/widgets/releases.ts index d234a75c1..8101fefbb 100644 --- a/packages/api/src/router/widgets/releases.ts +++ b/packages/api/src/router/widgets/releases.ts @@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc"; import { throwIfActionForbiddenAsync } from "../board/board-access"; const logger = createLogger({ module: "releasesRouter" }); +const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed"; const formatVersionFilterRegex = (versionFilter: z.infer | undefined) => { if (!versionFilter) return undefined; @@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({ }) .getDataAsync(); + const data = response.data.success + ? response.data + : { + success: false as const, + error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE }, + }; + if (!response.data.success) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error: response.data.error, + }); + } + return { id: repositoryId, provider: repository.provider, timestamp: response.timestamp, - ...response.data, + ...data, }; } catch (error) { + logger.warn("Release provider request failed", { + repositoryId, + provider: repository.provider, + error, + }); return { id: repositoryId, provider: repository.provider, timestamp: new Date(), success: false as const, - error: { code: "unexpected" as const, message: String(error) }, + error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE }, }; } }), diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 79ee5308b..276367440 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { vpnSummaryHandler } from "@homarr/request-handler/vpn"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const vpnRouter = createTRPCRouter({ @@ -33,7 +33,7 @@ export const vpnRouter = createTRPCRouter({ updatedAt: new Date(0), }, summary: null, - error: error instanceof Error ? error.message : String(error), + error: toPublicIntegrationError(error), }), throwOnAllFailures: true, }, diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index ec16b955d..c095c7715 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -5,6 +5,10 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; const logger = createLogger({ module: "settleIntegrations" }); +export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED"; + +export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR; + interface IntegrationLike { id: string; name: string; diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts index 372c2889e..37e7c3640 100644 --- a/packages/api/src/test/settle-integrations.spec.ts +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; -import { settleIntegrationQueries } from "../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations"; const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, @@ -22,6 +22,17 @@ const integrations = [ beforeEach(() => vi.clearAllMocks()); describe("settleIntegrationQueries", () => { + test("maps sensitive integration failures to a stable public code", () => { + const error = new Error( + "GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body", + ); + + const publicError = toPublicIntegrationError(error); + + expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR); + expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i); + }); + test("returns an empty result when no integrations are configured", async () => { await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]); }); diff --git a/packages/common/src/url.ts b/packages/common/src/url.ts index f13cc1efc..079de8418 100644 --- a/packages/common/src/url.ts +++ b/packages/common/src/url.ts @@ -48,3 +48,38 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/; export const isAbsoluteUrl = (urlOrPath: string): boolean => { return absoluteUrlRegex.test(urlOrPath.toLowerCase()); }; + +export const SAFE_NEW_TAB_REL = "noopener noreferrer"; + +interface SafeApplicationUrlOptions { + baseUrl?: unknown; +} + +/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */ +export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => { + if (typeof value !== "string" || value.trim() === "") return undefined; + + try { + let baseUrl: string | undefined; + if (options.baseUrl !== undefined) { + const parsedBase = parseHttpUrl(options.baseUrl); + if (!parsedBase) return undefined; + parsedBase.search = ""; + parsedBase.hash = ""; + baseUrl = parsedBase.toString(); + } + + return parseHttpUrl(value, baseUrl)?.toString(); + } catch { + return undefined; + } +}; + +const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => { + if (typeof value !== "string") return undefined; + const url = new URL(value, baseUrl); + if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") { + return undefined; + } + return url; +}; diff --git a/packages/spotlight/src/components/actions/items/children-action-item.tsx b/packages/spotlight/src/components/actions/items/children-action-item.tsx index 3d5427506..f56e46a23 100644 --- a/packages/spotlight/src/components/actions/items/children-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/children-action-item.tsx @@ -1,5 +1,6 @@ import { Spotlight } from "@mantine/spotlight"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import { Link } from "@homarr/ui"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -15,10 +16,18 @@ interface ChildrenActionItemProps { export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => { const interaction = action.useInteraction(childrenOptions.option, query); + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/spotlight/src/components/actions/items/group-action-item.tsx b/packages/spotlight/src/components/actions/items/group-action-item.tsx index e6a802c26..bc7cfaf41 100644 --- a/packages/spotlight/src/components/actions/items/group-action-item.tsx +++ b/packages/spotlight/src/components/actions/items/group-action-item.tsx @@ -2,6 +2,7 @@ import { Spotlight } from "@mantine/spotlight"; import type { TranslationObject } from "@homarr/translation"; import { Link } from "@homarr/ui"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common"; import type { SearchGroup } from "../../../lib/group"; import type { inferSearchInteractionOptions } from "../../../lib/interaction"; @@ -30,10 +31,18 @@ export const SpotlightGroupActionItem = const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record; + const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined; const renderRoot = - interaction.type === "link" + interaction.type === "link" && safeHref ? (props: Record) => { - return ; + return ( + + ); } : undefined; diff --git a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx index b177b20b4..cb2098505 100644 --- a/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-custom-widget-select-input.tsx @@ -13,10 +13,14 @@ export const WidgetCustomWidgetSelectInput = ({ property, kind, options, + boardId, }: CommonWidgetInputProps<"customWidgetSelect">) => { const t = useWidgetInputTranslation(kind, property); const form = useFormContext(); - const { data, isLoading } = clientApi.customWidget.all.useQuery(); + const { data, isLoading } = clientApi.customWidget.available.useQuery( + { boardId: boardId ?? "" }, + { enabled: Boolean(boardId) }, + ); const [search, setSearch] = useState(""); const currentValue = form.values.options[property] as string; diff --git a/packages/widgets/src/_inputs/widget-location-input.tsx b/packages/widgets/src/_inputs/widget-location-input.tsx index 37d26cf2e..91501aa2c 100644 --- a/packages/widgets/src/_inputs/widget-location-input.tsx +++ b/packages/widgets/src/_inputs/widget-location-input.tsx @@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; import { createModal, useModalAction } from "@homarr/modals"; -import { useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { formatLocalizedCompactNumber } from "../common/locale"; import type { OptionLocation } from "../options"; import type { CommonWidgetInputProps } from "./common"; import { useWidgetInputTranslation } from "./common"; @@ -128,7 +129,7 @@ const LocationSearchModal = createModal(({ actions, in if (error) { return ( - {error.message} + {tCommon("action.tryAgain")} ); } @@ -187,6 +188,7 @@ interface LocationSearchTableRowProps { const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => { const t = useScopedI18n("widget.common.location.table"); + const locale = useCurrentIntlLocale(); const onSelect = useCallback(() => { onLocationSelect({ name: city.name, @@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location closeModal(); }, [city, onLocationSelect, closeModal]); - const formatter = Intl.NumberFormat("en", { notation: "compact" }); - return ( @@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location {city.population ? ( - {formatter.format(city.population)} + {formatLocalizedCompactNumber(city.population, locale)} ) : ( {t("population.fallback")} )} diff --git a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx index 45c9ed595..9e3b925e4 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -272,19 +272,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId }); const setSecret = clientApi.widget.secrets.setSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to save token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({ onSuccess: () => refetch(), - onError: (error) => { + onError: () => { showErrorNotification({ - title: "Failed to delete token", - message: error.message, + title: tRepository("tokens.label"), + message: tRepository("tokens.notConfigured"), }); }, }); diff --git a/packages/widgets/src/advanced-focus-support.spec.ts b/packages/widgets/src/advanced-focus-support.spec.ts new file mode 100644 index 000000000..1b8e7a73f --- /dev/null +++ b/packages/widgets/src/advanced-focus-support.spec.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "vitest"; + +import { supportsAdvancedFocus } from "./definition"; +import { widgetImports } from "."; + +const compactOnlyWidgetKinds = [ + "app", + "bazarr", + "beszelSystemGrid", + "beszelSystemStats", + "beszelSystemTable", + "coolify", + "dnsHoleControls", + "dnsHoleSummary", + "iframe", + "indexerManager", + "mediaRequests-requestList", + "mediaRequests-requestStats", + "minecraftServerStatus", + "networkControllerSummary", + "notebook", + "notifications", + "paperlessNgx", + "patchmon", + "releases", + "rssFeed", + "stockPrice", + "systemDisks", + "video", + "vpn", +] as const; + +describe("advanced focus support", () => { + test("is reserved for widgets with a distinct advanced experience", () => { + const actualCompactOnlyKinds = Object.entries(widgetImports) + .filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition)) + .map(([kind]) => kind) + .toSorted(); + + expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted()); + }); +}); diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 945a2de55..9e786afa8 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -18,6 +18,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client" import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; +import { getUsableWidgetQueryData } from "../common/query-state"; import actionTargetClasses from "../common/action-target.module.css"; import "react-quill-new/dist/quill.snow.css"; @@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({ }: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); const locale = useCurrentIntlLocale(); - const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ + const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, }); + const note = getUsableWidgetQueryData(noteQuery); + const { refetch } = noteQuery; const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); const [isEditing, setIsEditing] = useState(false); diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 40e9064c1..6e84876fd 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -2,7 +2,7 @@ import type { PropsWithChildren } from "react"; import { Fragment, Suspense } from "react"; -import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; +import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core"; import { IconMinus } from "@tabler/icons-react"; import combineClasses from "clsx"; @@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client"; import { MaskedOrNormalImage } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; @@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); - const { data: app } = clientApi.app.byId.useQuery({ - id: options.appId, - }); + const appQuery = clientApi.app.byId.useQuery({ id: options.appId }); + const app = getUsableWidgetQueryData(appQuery); + const href = getSafeApplicationUrl(app?.href); useRegisterSpotlightContextResults( `app-${app?.id ?? options.appId}`, - app?.href + app && href ? [ { id: app.id, @@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget interaction() { return { type: "link", - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - href: app.href!, + href, newTab: options.openInNewTab, }; }, }, ] : [], - [app, options.openInNewTab], + [app, href, options.openInNewTab], ); + if (isInitialWidgetQueryPending(appQuery)) return ; if (!app) return ; const isTiny = height < 100 || width < 100; const isColumnLayout = options.layout.startsWith("column"); return ( - - ( - - {line} -
-
- ))} - position="right-start" - multiline - disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} - styles={{ tooltip: { maxWidth: 300 } }} - > - e.preventDefault() : undefined} + + + ( + + {line} +
+
+ ))} + position="right-start" + multiline + disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode} + styles={{ tooltip: { maxWidth: 300 } }} > - - {options.showTitle && ( - - {app.name} - - )} - {options.descriptionDisplayMode === "normal" && ( - - {app.description?.split("\n").map((line, index) => ( - - {line} -
-
- ))} -
- )} -
- -
-
- {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( - }> - - - ) : null} -
+ e.preventDefault() : undefined} + > + + {options.showTitle && ( + + {app.name} + + )} + {options.descriptionDisplayMode === "normal" && !isTiny && ( + + {app.description?.split("\n").map((line, index) => ( + + {line} +
+
+ ))} +
+ )} +
+ +
+ + {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( + }> + + + ) : null} + + {appQuery.error && ( + + + + )} +
); } @@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren diff --git a/packages/widgets/src/app/ping/ping-indicator.tsx b/packages/widgets/src/app/ping/ping-indicator.tsx index 75dc38974..1548881b5 100644 --- a/packages/widgets/src/app/ping/ping-indicator.tsx +++ b/packages/widgets/src/app/ping/ping-indicator.tsx @@ -11,7 +11,9 @@ interface PingIndicatorProps { export const PingIndicator = ({ appId }: PingIndicatorProps) => { const t = useI18n(); - const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false }); + + if (error) return ; if (!pingResult) return ; diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 1c6266b8e..16ff58069 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -8,9 +8,9 @@ import { formatBytes, formatDuration } from "@homarr/common"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { WidgetEmptyState } from "../common/empty-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; -import { setWidgetRuntimeQueries } from "../definition"; -import { NoIntegrationDataError } from "../errors/no-data-integration"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { AudioStatsContent } from "./audio-stats-content"; export default function AudioStatsWidget({ @@ -19,7 +19,7 @@ export default function AudioStatsWidget({ width, height, displayMode = "compact", - widgetStateRef, + widgetRuntimeRef, }: WidgetComponentProps<"audioStats">) { const statsInput = { integrationId: integrationIds[0] ?? "" }; const { data: response, error: statsError } = clientApi.widget.audioStats.getStats.useQuery(statsInput); @@ -29,7 +29,7 @@ export default function AudioStatsWidget({ streamsInput, { enabled: streamsEnabled }, ); - setWidgetRuntimeQueries(widgetStateRef, [ + useWidgetRuntimeQueries(widgetRuntimeRef, [ getQueryKey(clientApi.widget.audioStats.getStats, statsInput, "query"), ...(streamsEnabled ? [getQueryKey(clientApi.widget.mediaServer.getCurrentStreams, streamsInput, "query")] : []), ]); @@ -37,7 +37,6 @@ export default function AudioStatsWidget({ if (statsError && response === undefined) throw statsError; if (streamsEnabled && streamsError && streamResults === undefined) throw streamsError; const currentStreams = streamResults ?? []; - if (streamsEnabled && currentStreams.some(({ error }) => Boolean(error))) throw new NoIntegrationDataError(); if (!response) return ; const summary = ( @@ -56,9 +55,15 @@ export default function AudioStatsWidget({ ); const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; const advancedGridColumns = width >= 760 ? 2 : 1; + const hasStreamErrors = currentStreams.some(({ error }) => Boolean(error)); return (
{summary}
+ {hasStreamErrors && ( + + + + )} {response.kind === "navidrome" ? ( diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 3538ecc2f..c4914bfff 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -36,19 +36,14 @@ const iconSizeByWidth = [ { minWidth: 0, size: 16 }, ] as const; -export default function BazarrWidget({ - integrationIds, - options, - width, - height, - displayMode, -}: WidgetComponentProps<"bazarr">) { +export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); - const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery( + const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, { enabled: Boolean(integrationIds[0]) }, ); + if (error && !badges) throw error; if (!badges) return ; const statValues = { @@ -59,10 +54,10 @@ export default function BazarrWidget({ } as const; const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options]) + .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced"); + const gridCols = getGridCols(width, height, visibleStatKeys.length); const iconSize = getIconSize(Math.min(width, height)); if (visibleStatKeys.length === 0) { @@ -100,14 +95,14 @@ export default function BazarrWidget({ ); } -export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number { +export function getGridCols(width: number, height: number, itemCount: number): 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)); + const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; + const maxColumnsByWidth = Math.max(1, Math.floor(width / 160)); + const maxRowsByHeight = Math.max(1, Math.floor(height / 72)); + const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight); + return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit)); } export function getIconSize(width: number): number { diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index a4c10a540..3a63fe4bd 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { + supportsAdvancedFocus: false, icon: IconBadgeCc, supportedIntegrations: ["bazarr"], integrationsRequired: true, diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts index 2401ce4e6..e3552a737 100644 --- a/packages/widgets/src/bazarr/layout.spec.ts +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -4,11 +4,11 @@ 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); + expect(getGridCols(220, 100, 4)).toBe(1); }); test("uses four columns in a wide advanced surface", () => { - expect(getGridCols(800, 400, 4, true)).toBe(4); + expect(getGridCols(800, 400, 4)).toBe(2); }); test("sizes icons from the constrained dimension", () => { diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index ae5d20769..f7743b935 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; const alertIconMap: Record = { CPU: Cpu, @@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({ () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), [integrationIds, options.showHistory, options.maxHistoryItems], ); - const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); + const results = getUsableWidgetQueryData(alertsQuery) ?? []; + const { isPending } = alertsQuery; const systemNameMap = useMemo(() => { const map: Record = {}; @@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({ const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360); const showAlertDescriptions = displayMode === "advanced" || height >= 190; - if (alertsError) throw alertsError; - if (isPending) { return (
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index d7fcbb21c..66f32dc35 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -37,6 +37,7 @@ import { } from "../beszel/_shared/format"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; @@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({ isEditMode, width, height, - displayMode = "compact", }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); const { openModal } = useModalAction(BeszelSystemStatsModal); - const { - data: results = [], - error: systemsError, - isPending, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const results = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending } = systemsQuery; const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); - if (systemsError) throw systemsError; - if (isPending) { return (
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({ ); } - const advanced = displayMode === "advanced"; - const cols = advanced - ? Math.min(filteredSystems.length, width >= 900 ? 2 : 1) - : getColCount(width, height, filteredSystems.length); + const cols = getColCount(width, height, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT; + const minimumCellHeight = MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = advanced - ? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size)) - : getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); return ( diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index a448bb76a..a4031e865 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -15,9 +15,10 @@ import { useScopedI18n } from "@homarr/translation/client"; import classes from "./component.module.css"; import type { WidgetComponentProps } from "../definition"; -import { setWidgetRuntimeQueries } from "../definition"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; @@ -26,12 +27,10 @@ export default function BeszelSystemStatsWidget({ integrationIds, isEditMode, width, - height, - displayMode = "compact", boardId, itemId, setOptions, - widgetStateRef, + widgetRuntimeRef, }: WidgetComponentProps<"beszelSystemStats">) { const t = useScopedI18n("widget.beszelSystemStats"); const board = useOptionalBoard(); @@ -46,22 +45,26 @@ export default function BeszelSystemStatsWidget({ message: t("error.selectionSaveMessage"), }), }); - const { - data: systemsResult = [], - isPending: systemsPending, - error: systemsError, - } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; + const { isPending: systemsPending } = systemsQuery; 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 includeDocker = - (displayMode === "advanced" || height >= 380) && - (options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork); - setWidgetRuntimeQueries( - widgetStateRef, + const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork; + const periodOptions = [ + { value: "1m", label: t("period.live") }, + { value: "1h", label: t("period.oneHour") }, + { value: "12h", label: t("period.twelveHours") }, + { value: "24h", label: t("period.oneDay") }, + { value: "1w", label: t("period.oneWeek") }, + { value: "30d", label: t("period.thirtyDays") }, + ]; + useWidgetRuntimeQueries( + widgetRuntimeRef, selectedSystem && options.timePeriod !== "1m" ? [ getQueryKey( @@ -127,7 +130,6 @@ export default function BeszelSystemStatsWidget({ [boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions], ); - if (systemsError) throw systemsError; if (systemsPending) return (
@@ -212,36 +214,39 @@ export default function BeszelSystemStatsWidget({ )} - {!isEditMode && displayMode === "advanced" && ( - - )} + {!isEditMode && + (width >= 560 ? ( + + ) : ( +