From 1fa345edd106f474513bd86e52f4ee52aacd02d3 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 +- apps/docs/docs/widgets/bazarr/index.mdx | 2 +- .../widgets/beszel-system-stats/index.mdx | 2 +- apps/docs/docs/widgets/coolify/index.mdx | 2 +- .../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-form.tsx | 1 - .../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 +- .../src/components/board/items/item-menu.tsx | 1 - e2e/board-advanced-interactions.spec.ts | 170 +++++--- e2e/shared/login.ts | 51 +++ .../custom-widget/custom-widget-access.ts | 45 +++ 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/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 +- .../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 +- 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 + packages/widgets/src/beszel/_shared/chart.tsx | 57 +-- .../widgets/src/beszel/_shared/stats-view.tsx | 47 ++- packages/widgets/src/bookmarks/component.tsx | 283 ++++++------- packages/widgets/src/bookmarks/layout.spec.ts | 2 +- .../src/calendar/calendar-event-list.tsx | 72 ++-- .../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 + packages/widgets/src/coolify/resource-row.tsx | 16 +- .../widgets/src/coolify/servers-section.tsx | 8 +- .../src/coolify/single-instance-layout.tsx | 72 ++-- .../src/custom-api/custom-jsx-display.spec.ts | 1 - .../src/custom-api/custom-jsx-display.tsx | 1 - packages/widgets/src/custom-api/index.ts | 21 +- packages/widgets/src/definition.spec.ts | 53 +-- .../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/errors/component.spec.ts | 61 +++ packages/widgets/src/errors/component.tsx | 11 +- packages/widgets/src/firewall/component.tsx | 46 ++- .../src/firewall/failure-state.spec.ts | 29 +- .../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 | 102 +++-- 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 | 101 +++-- .../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 +- .../src/media-transcoding/component.tsx | 107 +++-- .../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 ++++--- .../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 + 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/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 ++++ 165 files changed, 3698 insertions(+), 1649 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 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/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/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-form.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-form.tsx index 70cf130e7..9f7df3dab 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 @@ -47,7 +47,6 @@ interface CustomWidgetFormProps { initialValues?: Partial; definitionId?: string; } - const sectionLinks = [ ["general", "section.general", IconSettings], ["sources", "section.sources", IconApi], 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-menu.tsx b/apps/nextjs/src/components/board/items/item-menu.tsx index 64537d151..c373e3c66 100644 --- a/apps/nextjs/src/components/board/items/item-menu.tsx +++ b/apps/nextjs/src/components/board/items/item-menu.tsx @@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => { return ; }; - const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => { const { data: session } = useSession(); const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false); 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/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/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/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-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 1391cafd9..138369c57 100644 --- a/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx +++ b/packages/widgets/src/_inputs/widget-multiReleasesRepositories-input.tsx @@ -271,19 +271,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/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 6e035a27c..0133e458f 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -14,9 +14,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"; @@ -25,12 +26,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(); @@ -45,22 +44,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" ? [ { @@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({ [boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions], ); - if (systemsError) throw systemsError; if (systemsPending) return (
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({ )} - {!isEditMode && displayMode === "advanced" && ( - - )} + {!isEditMode && + (width >= 560 ? ( + + ) : ( +