From 61a2f8cb6c254ead5f5a6205e8fa9189c87b0afc Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Mon, 3 Aug 2026 17:18:22 +0200 Subject: [PATCH] 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 ? ( + + ) : ( +