@@ -33,18 +32,23 @@ import widgetCompact from './img/compact.png';
### Supported Integrations
-
### Tabs
-| Tab | Description |
-|---|---|
+| Tab | Description |
+| ----------- | ----------------------------------------------------------------------------------------------------- |
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
-| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
+| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
@@ -56,6 +60,8 @@ The widget adapts to its container size, so it looks good at any grid size:
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
+
diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx
index fece199eb..7142ccfc5 100644
--- a/apps/docs/docs/widgets/media-releases/index.mdx
+++ b/apps/docs/docs/widgets/media-releases/index.mdx
@@ -1,30 +1,28 @@
---
-title: 'Media releases'
-description: 'Display newly added medias or upcoming releases from different integrations'
+title: "Media releases"
+description: "Display newly added medias or upcoming releases from different integrations"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { mediaReleasesWidget } from '.';
-import { embyIntegration } from '@site/docs/integrations/emby';
-import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
-import { plexIntegration } from '@site/docs/integrations/plex';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { mediaReleasesWidget } from ".";
+import { embyIntegration } from "@site/docs/integrations/emby";
+import { jellyfinIntegration } from "@site/docs/integrations/jellyfin";
+import { plexIntegration } from "@site/docs/integrations/plex";
-
Provides a list of newly added media or upcoming releases from your media server.
### Screenshots
-import backdrop from './img/backdrop.png';
-import poster from './img/poster.png';
+import backdrop from "./img/backdrop.png";
+import poster from "./img/poster.png";
@@ -33,17 +31,26 @@ import poster from './img/poster.png';
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx
index 1c491a051..68f3a27ab 100644
--- a/apps/docs/docs/widgets/media-request-list/index.mdx
+++ b/apps/docs/docs/widgets/media-request-list/index.mdx
@@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration"
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -45,6 +47,8 @@ import mediaRequestList from "./img/list.png";
- Approve / decline a request by clicking on the thumbs up or down icon.
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx
index a7b40f624..b381f177b 100644
--- a/apps/docs/docs/widgets/media-request-stats/index.mdx
+++ b/apps/docs/docs/widgets/media-request-stats/index.mdx
@@ -1,39 +1,42 @@
---
-title: 'Media Requests Stats'
-description: 'Statistics about your media requests'
+title: "Media Requests Stats"
+description: "Statistics about your media requests"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { mediaRequestStatsWidget } from '.';
-import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr';
-import { overseerrIntegration } from '@site/docs/integrations/overseerr';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { mediaRequestStatsWidget } from ".";
+import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
+import { overseerrIntegration } from "@site/docs/integrations/overseerr";
-
-
+
Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
### Supported Integrations
-
+
### Screenshots
-import mediaRequestStats from './img/stats.png';
+import mediaRequestStats from "./img/stats.png";
@@ -41,6 +44,8 @@ import mediaRequestStats from './img/stats.png';
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx
index 8fb0c857c..f76ce050b 100644
--- a/apps/docs/docs/widgets/media-server/index.mdx
+++ b/apps/docs/docs/widgets/media-server/index.mdx
@@ -1,56 +1,62 @@
---
-title: 'Media server streams'
-description: 'Show the current streams on your media servers'
+title: "Media server streams"
+description: "Show the current streams on your media servers"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { mediaServerWidget } from '.';
-import { embyIntegration } from '@site/docs/integrations/emby';
-import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
-import { navidromeIntegration } from '@site/docs/integrations/navidrome';
-import { plexIntegration } from '@site/docs/integrations/plex';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { mediaServerWidget } from ".";
+import { embyIntegration } from "@site/docs/integrations/emby";
+import { jellyfinIntegration } from "@site/docs/integrations/jellyfin";
+import { navidromeIntegration } from "@site/docs/integrations/navidrome";
+import { plexIntegration } from "@site/docs/integrations/plex";
+
-
-
-The media server widget enables you to watch and view current active sessions over all your media servers.
+The media server widget enables you to watch and view current active sessions over all your media servers.
It also displays useful information, that you can use when providing support to users or debugging a problem.
-
-
### Screenshots
-import mediaTable from './img/table.png';
+import mediaTable from "./img/table.png";
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/media-transcoding/index.mdx b/apps/docs/docs/widgets/media-transcoding/index.mdx
index 0fc280021..256b8edd6 100644
--- a/apps/docs/docs/widgets/media-transcoding/index.mdx
+++ b/apps/docs/docs/widgets/media-transcoding/index.mdx
@@ -1,35 +1,31 @@
---
-title: 'Media transcoding'
-description: 'Statistics, current queue and worker status of your media transcoding'
+title: "Media transcoding"
+description: "Statistics, current queue and worker status of your media transcoding"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { mediaTranscodingWidget } from '.';
-import { tdarrIntegration } from '@site/docs/integrations/tdarr';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { mediaTranscodingWidget } from ".";
+import { tdarrIntegration } from "@site/docs/integrations/tdarr";
+
-
-
-The media transcoding widget provides you with an overview of your media transcoding status.
+The media transcoding widget provides you with an overview of your media transcoding status.
It shows you the current queue, worker status, and statistics about your transcoding jobs.
-
-
### Screenshots
-import queueCard from './img/queue.png';
-import workersCard from './img/workers.png';
-import statisticsCard from './img/statistics.png';
+import queueCard from "./img/queue.png";
+import workersCard from "./img/workers.png";
+import statisticsCard from "./img/statistics.png";
@@ -39,13 +35,20 @@ import statisticsCard from './img/statistics.png';
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx
index 51101246b..8a99cf8cf 100644
--- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx
+++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx
@@ -1,44 +1,45 @@
---
-title: 'Minecraft Server Status'
-description: 'Displays the status of a Minecraft server.'
+title: "Minecraft Server Status"
+description: "Displays the status of a Minecraft server."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import TabItem from '@theme/TabItem';
-import Tabs from '@theme/Tabs';
-import { minecraftServerStatusWidget } from '.';
+import TabItem from "@theme/TabItem";
+import Tabs from "@theme/Tabs";
+import { minecraftServerStatusWidget } from ".";
-
-
+
Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us).
### Screenshots
-import javaItem from './img/java.png';
-import bedrockItem from './img/bedrock.png';
+import javaItem from "./img/java.png";
+import bedrockItem from "./img/bedrock.png";
-
+
+
+
### Adding the widget
### Configuration
+
### Data source
We use the Minecraft Server Status API to fetch the server status.
-If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
\ No newline at end of file
+If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
diff --git a/apps/docs/docs/widgets/network-controller-status/index.mdx b/apps/docs/docs/widgets/network-controller-status/index.mdx
index a01496234..ad0feaf9d 100644
--- a/apps/docs/docs/widgets/network-controller-status/index.mdx
+++ b/apps/docs/docs/widgets/network-controller-status/index.mdx
@@ -1,44 +1,49 @@
---
-title: 'Network Status'
-description: 'Display connected devices on a network'
+title: "Network Status"
+description: "Display connected devices on a network"
hide_title: true
---
-import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import TabItem from '@theme/TabItem';
-import Tabs from '@theme/Tabs';
-import { networkControllerStatusWidget } from '.';
+import TabItem from "@theme/TabItem";
+import Tabs from "@theme/Tabs";
+import { networkControllerStatusWidget } from ".";
-
-
+
Shows connected users and connected guest devices on wired or wireless networks.
### Screenshots
-import wifiCard from './img/wifi.png';
-import wiredCard from './img/wired.png';
+import wifiCard from "./img/wifi.png";
+import wiredCard from "./img/wired.png";
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx
index fe4cd3357..1386fc069 100644
--- a/apps/docs/docs/widgets/network-controller-summary/index.mdx
+++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx
@@ -1,38 +1,42 @@
---
-title: 'Network Controller Summary'
-description: 'Displays the summary of a Network Controller'
+title: "Network Controller Summary"
+description: "Displays the summary of a Network Controller"
hide_title: true
---
-import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import TabItem from '@theme/TabItem';
-import Tabs from '@theme/Tabs';
-import { networkControllerSummaryWidget } from '.';
+import TabItem from "@theme/TabItem";
+import Tabs from "@theme/Tabs";
+import { networkControllerSummaryWidget } from ".";
-
-
+
Shows information whether your WAN is connected, Wi-Fi is active and several other information.
### Screenshots
-import summaryCard from './img/summary.png';
+import summaryCard from "./img/summary.png";
### Supported Integrations
-
+
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx
index 393137ed2..a245600aa 100644
--- a/apps/docs/docs/widgets/notebook/index.mdx
+++ b/apps/docs/docs/widgets/notebook/index.mdx
@@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown."
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -24,6 +26,8 @@ import editOn from "./img/edit.png";
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx
index 0a715ab3c..41638ac33 100644
--- a/apps/docs/docs/widgets/notifications/index.mdx
+++ b/apps/docs/docs/widgets/notifications/index.mdx
@@ -1,44 +1,41 @@
---
-title: 'Notifications'
-description: 'Display notification history from an integration'
+title: "Notifications"
+description: "Display notification history from an integration"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { notificationsWidget } from '.';
-import { ntfyIntegration } from '@site/docs/integrations/ntfy';
-import { nextcloudIntegration } from '@site/docs/integrations/nextcloud';
-import { gotifyIntegration } from '@site/docs/integrations/gotify';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { IconAd } from "@tabler/icons-react";
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { notificationsWidget } from ".";
+import { ntfyIntegration } from "@site/docs/integrations/ntfy";
+import { nextcloudIntegration } from "@site/docs/integrations/nextcloud";
+import { gotifyIntegration } from "@site/docs/integrations/gotify";
+
+
The notifications widget allows you to display notifications from various integrations in a unified view.
### Screenshots
-import notificationsScreenshot from './img/notifications.png';
+import notificationsScreenshot from "./img/notifications.png";
### Supported Integrations
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx
index 3dd684efd..4479165e2 100644
--- a/apps/docs/docs/widgets/paperless-ngx/index.mdx
+++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx
@@ -1,34 +1,39 @@
---
-title: 'Paperless-ngx'
-description: 'Displays document management statistics including inbox ratio, document counts, and metadata.'
+title: "Paperless-ngx"
+description: "Displays document management statistics including inbox ratio, document counts, and metadata."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { paperlessNgxWidget } from '.';
-import { paperlessNgxIntegration } from '@site/docs/integrations/paperless-ngx';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { paperlessNgxWidget } from ".";
+import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
-
+
This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types.
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx
index d16ab8d68..ccbcd7dab 100644
--- a/apps/docs/docs/widgets/patchmon/index.mdx
+++ b/apps/docs/docs/widgets/patchmon/index.mdx
@@ -1,21 +1,19 @@
---
-title: 'PatchMon'
-description: 'Displays host patch statistics from PatchMon including update and security counts.'
+title: "PatchMon"
+description: "Displays host patch statistics from PatchMon including update and security counts."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { patchmonWidget } from '.';
-import { patchmonIntegration } from '@site/docs/integrations/patchmon';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { patchmonWidget } from ".";
+import { patchmonIntegration } from "@site/docs/integrations/patchmon";
-
+
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
@@ -39,13 +37,13 @@ In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a r
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
-| Stat | Green | Yellow | Red |
-|------|-------|--------|-----|
-| Needs Updates | 0 | 1–4 | 5+ |
-| Security Updates | 0 | 1–9 | 10+ |
-| Hosts with Security Updates | 0 | — | 1+ |
-| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
-| Outdated Packages | 0 | 1–49 | 50+ |
+| Stat | Green | Yellow | Red |
+| --------------------------- | ------------- | ------ | --------- |
+| Needs Updates | 0 | 1–4 | 5+ |
+| Security Updates | 0 | 1–9 | 10+ |
+| Hosts with Security Updates | 0 | — | 1+ |
+| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
+| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
@@ -62,13 +60,13 @@ The OS distribution limit defaults to **All**. A widget size of **2×3** or larg
The widget adapts its layout based on the available space:
-| Widget size | Layout |
-|-------------|--------|
-| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
+| Widget size | Layout |
+| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
+| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
-| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
-| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
-| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
+| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
+| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
+| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
@@ -76,14 +74,21 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx
index faf386a13..017ea97ca 100644
--- a/apps/docs/docs/widgets/releases/index.mdx
+++ b/apps/docs/docs/widgets/releases/index.mdx
@@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -33,6 +35,8 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx
index 68bbb1071..d663554d6 100644
--- a/apps/docs/docs/widgets/rss-feed/index.mdx
+++ b/apps/docs/docs/widgets/rss-feed/index.mdx
@@ -1,37 +1,40 @@
---
-title: 'RSS Feed'
-description: 'Monitor and display one or more generic RSS, ATOM or JSON feeds.'
+title: "RSS Feed"
+description: "Monitor and display one or more generic RSS, ATOM or JSON feeds."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { rssFeedWidget } from '.';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { rssFeedWidget } from ".";
-
+
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
### Screenshots
-import screenshot from './img/screenshot.png';
+import screenshot from "./img/screenshot.png";
+
+
### Adding the widget
### Configuration
+
### Examples
+
Here's a small collection of examples, that you can use for testing or actual use (in no particular order):
+
- https://noted.lol/rss/
- https://blog.google/products/chrome/rss/
- https://www.w3.org/blog/feed/
@@ -45,4 +48,4 @@ Here's a small collection of examples, that you can use for testing or actual us
- http://feeds.foxnews.com/foxnews/latest
There is a good list of feeds here:
-https://github.com/greysonp/rss-feeds/blob/master/feeds.json
\ No newline at end of file
+https://github.com/greysonp/rss-feeds/blob/master/feeds.json
diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx
index d101d6376..28c83d3aa 100644
--- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx
+++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx
@@ -1,34 +1,32 @@
---
-title: 'Entity State'
-description: 'Display the state of an entity and toggle it optionally'
+title: "Entity State"
+description: "Display the state of an entity and toggle it optionally"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { smartHomeEntityStateWidget } from '.';
-import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { smartHomeEntityStateWidget } from ".";
+import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant";
-
-
+
The entity state enables you to display the current state of an entity.
-This works with most entities and enables you display almost any data.
+This works with most entities and enables you display almost any data.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
### Screenshots
-import sun from './img/sun.png';
-import lightSwitch from './img/light-switch.png';
-import temperature from './img/temperature.png';
+import sun from "./img/sun.png";
+import lightSwitch from "./img/light-switch.png";
+import temperature from "./img/temperature.png";
@@ -36,32 +34,40 @@ import temperature from './img/temperature.png';
### Supported Integrations
-
+
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
+
+
### Adding the widget
### Configuration
+
### How to find the entity ID
To find the entity ID in Home Assistant, follow these steps:
+
1. Open Home Assistant and go to the **Developer Tools** section (usually found in the sidebar).
2. Click on the **States** tab.
3. In the list of entities, find the entity you want to use.
4. The entity ID will be displayed in the format `domain.object_id` (e.g., `light.living_room_light`).
5. Copy the entity ID and paste it into the widget configuration in Homarr. It will then show the State that is visible in Home Assistant.
-import retrieveEntityId from './img/retrieve-entity-id.png';
+import retrieveEntityId from "./img/retrieve-entity-id.png";
-
\ No newline at end of file
+
diff --git a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx
index 522c113ca..f0118799a 100644
--- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx
+++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx
@@ -1,50 +1,60 @@
---
-title: 'Execute Automation'
-description: 'Trigger an automation with one click'
+title: "Execute Automation"
+description: "Trigger an automation with one click"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react";
-import Tabs from '@theme/Tabs';
-import TabItem from '@theme/TabItem';
-import { smartHomeExecuteAutomationWidget } from '.';
-import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant';
+import Tabs from "@theme/Tabs";
+import TabItem from "@theme/TabItem";
+import { smartHomeExecuteAutomationWidget } from ".";
+import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant";
-
-
+
The execute automation widget allows you to trigger an automation with one click.
### Screenshots
-import empty from './img/empty.png';
-import withDisplayname from './img/with-displayname.png';
+import empty from "./img/empty.png";
+import withDisplayname from "./img/with-displayname.png";
### Supported Integrations
-
+
### Interactions
- Trigger the automation by clicking on the widget.
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx
index 94be18dd7..c6c0bd992 100644
--- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx
+++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx
@@ -1,39 +1,45 @@
---
-title: 'Speedtest Tracker'
-description: 'Displays speed test results from your Speedtest Tracker instance.'
+title: "Speedtest Tracker"
+description: "Displays speed test results from your Speedtest Tracker instance."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { speedtestTrackerWidget } from '.';
-import { speedtestTrackerIntegration } from '@site/docs/integrations/speedtest-tracker';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { speedtestTrackerWidget } from ".";
+import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-tracker";
+
+
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance,
including the latest result, historical averages, and a live chart of the last 12 hours.
### Screenshots
-import screenshot from './img/screenshot.png';
+import screenshot from "./img/screenshot.png";
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx
index 5d9a24c64..2a6119240 100644
--- a/apps/docs/docs/widgets/stock-price/index.mdx
+++ b/apps/docs/docs/widgets/stock-price/index.mdx
@@ -1,28 +1,26 @@
---
-title: 'Stock Price'
-description: 'Displays the current stock price of a company.'
+title: "Stock Price"
+description: "Displays the current stock price of a company."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { stockPriceWidget } from '.';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { stockPriceWidget } from ".";
-
+
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
### Screenshots
-import apple from './img/apple.png';
-import google from './img/google.png';
-import ethereum from './img/ethereum.png';
-import nvidia from './img/nvidia.png';
+import apple from "./img/apple.png";
+import google from "./img/google.png";
+import ethereum from "./img/ethereum.png";
+import nvidia from "./img/nvidia.png";
@@ -34,17 +32,21 @@ import nvidia from './img/nvidia.png';
-
+
+
### Adding the widget
### Configuration
+
### Examples
+
Here's a small collection of examples, that you can use for testing or actual use (in no particular order):
+
- AAPL
- GOOGL
- AMZN
@@ -59,4 +61,4 @@ Here's a small collection of examples, that you can use for testing or actual us
### Data source
We use the Yahoo Finance API to fetch the stock prices.
-If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com`
\ No newline at end of file
+If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com`
diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx
index b3be87e1d..5015ac725 100644
--- a/apps/docs/docs/widgets/system-disks/index.mdx
+++ b/apps/docs/docs/widgets/system-disks/index.mdx
@@ -4,6 +4,8 @@ description: "Disk usage of your system"
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -42,6 +44,8 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/system-disks/index.ts b/apps/docs/docs/widgets/system-disks/index.ts
index 8dd68f057..ade53efc0 100644
--- a/apps/docs/docs/widgets/system-disks/index.ts
+++ b/apps/docs/docs/widgets/system-disks/index.ts
@@ -19,7 +19,11 @@ export const systemDisksWidget: WidgetDefinition = {
description: "How disk usage values are shown",
values: {
type: "select",
- options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"],
+ options: [
+ "Percentage (e.g., 76%)",
+ "Absolute values (e.g., 800GB / 1TB)",
+ "Free space percentage (e.g., 24% free)",
+ ],
},
defaultValue: "Percentage (e.g., 76%)",
},
diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx
index 16ad19750..cdc258f4c 100644
--- a/apps/docs/docs/widgets/system-resources/index.mdx
+++ b/apps/docs/docs/widgets/system-resources/index.mdx
@@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time"
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
]}
/>
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx
index e9bdfd326..8fb3b7ccd 100644
--- a/apps/docs/docs/widgets/timetable/index.mdx
+++ b/apps/docs/docs/widgets/timetable/index.mdx
@@ -4,6 +4,8 @@ description: "Displays departure times for a station."
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -19,18 +21,12 @@ import trainDepartures from "./img/train-departures.png";
import streetDepartures from "./img/street-departures.png";
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/tracearr/index.mdx b/apps/docs/docs/widgets/tracearr/index.mdx
index a579d8af3..ff02699ca 100644
--- a/apps/docs/docs/widgets/tracearr/index.mdx
+++ b/apps/docs/docs/widgets/tracearr/index.mdx
@@ -1,52 +1,57 @@
---
-title: 'Tracearr'
-description: 'Monitor media server streams, user activity, and policy violations.'
+title: "Tracearr"
+description: "Monitor media server streams, user activity, and policy violations."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { tracearrWidget } from '.';
-import { tracearrIntegration } from '@site/docs/integrations/tracearr';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { tracearrWidget } from ".";
+import { tracearrIntegration } from "@site/docs/integrations/tracearr";
-
+
The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations.
### Sections
-| Section | Description |
-|---|---|
-| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. |
-| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). |
-| **Recent Activity** | A paginated list of watching history with user avatars and watched status. |
-| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. |
+| Section | Description |
+| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
+| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. |
+| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). |
+| **Recent Activity** | A paginated list of watching history with user avatars and watched status. |
+| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. |
### Screenshots
-import widgetOverview from './img/widget-overview.png';
-import streamsAndStats from './img/streams-and-stats.png';
+import widgetOverview from "./img/widget-overview.png";
+import streamsAndStats from "./img/streams-and-stats.png";
+
+
### Adding the widget
### Supported Integrations
-
+
### Configuration
+
diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx
index dc9f6a6c4..dfc2f9247 100644
--- a/apps/docs/docs/widgets/traefik/index.mdx
+++ b/apps/docs/docs/widgets/traefik/index.mdx
@@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun
hide_title: true
---
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -26,6 +28,8 @@ This widget displays a compact overview of your Traefik instance, including tota
]}
/>
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/umami/index.mdx b/apps/docs/docs/widgets/umami/index.mdx
index 0d0006054..6f2c2bb7f 100644
--- a/apps/docs/docs/widgets/umami/index.mdx
+++ b/apps/docs/docs/widgets/umami/index.mdx
@@ -1,28 +1,33 @@
---
-title: 'Umami Analytics'
-description: 'Display visitor stats from your Umami analytics instance'
+title: "Umami Analytics"
+description: "Display visitor stats from your Umami analytics instance"
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { umamiWidget } from '.';
-import { umamiIntegration } from '@site/docs/integrations/umami';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { umamiWidget } from ".";
+import { umamiIntegration } from "@site/docs/integrations/umami";
+
+
The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists.
### Supported Integrations
-
+
+
+
### Adding the widget
@@ -34,12 +39,12 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b
### View modes
-| View | Description |
-|---|---|
-| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. |
-| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. |
-| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. |
-| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". |
+| View | Description |
+| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
+| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. |
+| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. |
+| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. |
+| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". |
### Notes
diff --git a/apps/docs/docs/widgets/ups/index.mdx b/apps/docs/docs/widgets/ups/index.mdx
index bf512990d..8a3d7037e 100644
--- a/apps/docs/docs/widgets/ups/index.mdx
+++ b/apps/docs/docs/widgets/ups/index.mdx
@@ -1,20 +1,19 @@
---
-title: 'UPS'
-description: 'Monitor the status of your UPS devices through a NUT server.'
+title: "UPS"
+description: "Monitor the status of your UPS devices through a NUT server."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { upsWidget } from '.';
-import { peaNutIntegration } from '@site/docs/integrations/peanut';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { upsWidget } from ".";
+import { peaNutIntegration } from "@site/docs/integrations/peanut";
+
+
This widget displays the status of the UPS devices reported by a Network UPS Tools (NUT) server. Each device shows its
status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and
@@ -22,27 +21,34 @@ output voltage.
### Layouts
-import screenshotFull from './img/screenshot.png';
-import screenshotCompact from './img/screenshot-compact.png';
-import screenshotMini from './img/screenshot-mini.png';
+import screenshotFull from "./img/screenshot.png";
+import screenshotCompact from "./img/screenshot-compact.png";
+import screenshotMini from "./img/screenshot-mini.png";
The widget adapts to the size of the tile, so it stays readable from a full dashboard panel down to a single cell. Multiple UPS devices scroll within the widget at any height.
-| Layout | Size | Preview |
-| --- | --- | --- |
-| Full | 3+ columns wide |
|
-| Compact | ~2 columns |
|
-| Mini | 1 column |
|
+| Layout | Size | Preview |
+| ------- | --------------- | --------------------------------------------------------------------------- |
+| Full | 3+ columns wide |
|
+| Compact | ~2 columns |
|
+| Mini | 1 column |
|
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx
index 3a6592b2e..6c5eefa16 100644
--- a/apps/docs/docs/widgets/uptime-kuma/index.mdx
+++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx
@@ -1,34 +1,39 @@
---
-title: 'Uptime Kuma'
-description: 'Displays monitor uptime statistics, average uptime percentage, and service status counts.'
+title: "Uptime Kuma"
+description: "Displays monitor uptime statistics, average uptime percentage, and service status counts."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { uptimeKumaWidget } from '.';
-import { uptimeKumaIntegration } from '@site/docs/integrations/uptime-kuma';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { uptimeKumaWidget } from ".";
+import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
-
+
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
### Supported Integrations
-
+
+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx
index 3ccf3760a..27fef9fb9 100644
--- a/apps/docs/docs/widgets/video/index.mdx
+++ b/apps/docs/docs/widgets/video/index.mdx
@@ -1,19 +1,17 @@
---
-title: 'Video Stream'
-description: 'Embed a video stream or video from a camera or a website.'
+title: "Video Stream"
+description: "Embed a video stream or video from a camera or a website."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { videoWidget } from '.';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { videoWidget } from ".";
-
+
Do you run surveilance cameras? Or maybe you have a favourite video?
The video widget enables you to integrate both seamlessly into your dashboard.
@@ -22,7 +20,6 @@ This widget uses [video.js](https://videojs.com/) to play HLS or DASH video nati
DRM content is not yet supported. More info here:
https://videojs.com/guides/faqs/#q-what-is-videojs
-
You can use the following URL to test:
http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4
@@ -30,9 +27,12 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp

+
+
### Adding the widget
### Configuration
+
diff --git a/apps/docs/docs/widgets/video/index.tsx b/apps/docs/docs/widgets/video/index.tsx
index e0df87ce0..7d57a27ad 100644
--- a/apps/docs/docs/widgets/video/index.tsx
+++ b/apps/docs/docs/widgets/video/index.tsx
@@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = {
icon: IconDeviceCctv,
name: "Video Stream",
description: "Embed a video stream or video from a camera or a website.",
- path: "../../widgets/stock-price",
+ path: "../../widgets/video",
configuration: {
items: [
{
diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx
index 0aa6a1a16..fb6223cbe 100644
--- a/apps/docs/docs/widgets/vpn/index.mdx
+++ b/apps/docs/docs/widgets/vpn/index.mdx
@@ -1,19 +1,18 @@
---
-title: 'VPN'
-description: 'Monitor the status of your VPN integrations.'
+title: "VPN"
+description: "Monitor the status of your VPN integrations."
hide_title: true
---
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
-import { vpnWidget } from '.';
-import { gluetunIntegration } from '@site/docs/integrations/gluetun';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
-
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
+import { vpnWidget } from ".";
+import { gluetunIntegration } from "@site/docs/integrations/gluetun";
+
+
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS
services are up, the currently exposed public IP address, and the VPN provider and protocol being used.
@@ -23,7 +22,7 @@ current status at a glance.
### Screenshots
-import singleIntegrationImage from './img/single.png';
+import singleIntegrationImage from "./img/single.png";
@@ -31,9 +30,15 @@ import singleIntegrationImage from './img/single.png';
### Supported Integrations
-
+
+
+
### Adding the widget
diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx
index 0ff4081d7..3ca81acaa 100644
--- a/apps/docs/docs/widgets/weather/index.mdx
+++ b/apps/docs/docs/widgets/weather/index.mdx
@@ -1,40 +1,41 @@
---
-title: 'Weather'
-description: 'Displays the current weather information of a set location.'
+title: "Weather"
+description: "Displays the current weather information of a set location."
hide_title: true
---
-import { AddingWidget } from '@site/src/components/widgets/adding';
-import { WidgetConfig } from '@site/src/components/widgets/configuration';
-import { WidgetHeader } from '@site/src/components/widgets/header';
-import { weatherWidget } from '.';
+import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
+import { AddingWidget } from "@site/src/components/widgets/adding";
+import { WidgetConfig } from "@site/src/components/widgets/configuration";
+import { WidgetHeader } from "@site/src/components/widgets/header";
+import { weatherWidget } from ".";
-
+
The Weather Widget uses your location to display the current, highest and lowest temperature.
### Screenshots
-import forecastWeather from './img/forecast.png';
-import currentWeather from './img/current.png';
+import forecastWeather from "./img/forecast.png";
+import currentWeather from "./img/current.png";
+
+
### Adding the widget
### Configuration
+
### Data source
We use the Open Meteo API to fetch the weather and locations.
-If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
\ No newline at end of file
+If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx
new file mode 100644
index 000000000..80ba47b46
--- /dev/null
+++ b/apps/docs/src/components/widgets/interactions.tsx
@@ -0,0 +1,47 @@
+import { IconArrowsMaximize, IconMouse2, IconPlus } from "@tabler/icons-react";
+
+const interactions = [
+ {
+ icon: IconArrowsMaximize,
+ title: "Advanced view",
+ description:
+ "Hold Shift while hovering (then pin it), press Shift + Enter, or choose Open advanced view from the widget menu.",
+ },
+ {
+ icon: IconMouse2,
+ title: "Widget actions",
+ description:
+ "Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.",
+ },
+ {
+ icon: IconPlus,
+ title: "Place it precisely",
+ description:
+ "With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.",
+ },
+] as const;
+
+export const WidgetInteractionGuide = () => (
+
+
+ Power-user controls
+ Show shortcuts
+
+ Hide shortcuts
+
+
+
+ {interactions.map(({ icon: Icon, title, description }) => (
+
+
+
{title}
+
{description}
+
+ ))}
+
+
+ Advanced view reuses the widget's live data and cache. Touch devices expose a maximize control; board actions
+ respect your permissions and right-click preference.
+
+
+);
diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
index 3e167dd8c..9f2c2b3d8 100644
--- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
+++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx
@@ -2,7 +2,6 @@
import type { PropsWithChildren } from "react";
import { useState } from "react";
-import type { QueryKey } from "@tanstack/react-query";
import { QueryClient } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental";
@@ -34,6 +33,7 @@ import {
import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared";
import { env } from "@homarr/common/env";
import { showWarningNotification } from "@homarr/notifications";
+import type { WidgetDefinition } from "@homarr/widgets";
import { widgetImports } from "@homarr/widgets";
import { createWidgetQueryPersister } from "./query-cache-persister";
@@ -93,11 +93,11 @@ export function TRPCReactProvider(props: PropsWithChildren) {
});
client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs });
for (const { definition } of Object.values(widgetImports)) {
- const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string };
+ const def = definition as WidgetDefinition & { kind: string };
if (def.refetchInterval === undefined) continue;
- const key = def.queryKey ?? [["widget", def.kind]];
const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000;
- client.setQueryDefaults(key, { refetchInterval: interval });
+ const primaryQueryKey = def.queryKey ?? [["widget", def.kind]];
+ client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval });
}
return client;
});
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
index 0ba502a9a..292a2ed05 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx
@@ -4,6 +4,7 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -17,17 +18,19 @@ export const ClientBoard = () => {
.sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset);
return (
-
-
-
- {fullWidthSortedSections.map((section) =>
- section.kind === "empty" ? (
-
- ) : (
-
- ),
- )}
-
-
+
+
+
+
+ {fullWidthSortedSections.map((section) =>
+ section.kind === "empty" ? (
+
+ ) : (
+
+ ),
+ )}
+
+
+
);
};
diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
index 30c91de40..5e12a259b 100644
--- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
+++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx
@@ -70,6 +70,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool
};
const AddMenu = () => {
+ const board = useRequiredBoard();
const { data: session } = useSession();
const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal);
const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal);
@@ -101,8 +102,8 @@ const AddMenu = () => {
);
const handleSelectItem = useCallback(() => {
- openItemSelectModal();
- }, [openItemSelectModal]);
+ openItemSelectModal({ board, persistImmediately: false });
+ }, [board, openItemSelectModal]);
const handleSelectApp = useCallback(() => {
openAppSelectModal({
diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
index 25a53ffd2..383d74795 100644
--- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
+++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx
@@ -123,7 +123,7 @@ export function CustomWidgetPreview({
type: "application/json",
});
const blobUrl = URL.createObjectURL(blob);
- window.open(blobUrl);
+ window.open(blobUrl, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}}
>
@@ -167,7 +167,7 @@ function PreviewDisplay({ data }: { data: unknown }) {
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
- return
;
+ return
;
}
return (
diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
new file mode 100644
index 000000000..d1b045802
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
@@ -0,0 +1,132 @@
+.focusScope {
+ height: 100%;
+}
+
+.backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 198;
+ background: rgba(0, 0, 0, 0.64);
+ backdrop-filter: blur(6px) saturate(0.75);
+ touch-action: none;
+ animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.surface {
+ position: fixed;
+ z-index: 199;
+ pointer-events: auto;
+ overflow: hidden;
+ background: var(--mantine-color-body);
+ box-shadow: var(--mantine-shadow-lg);
+ overscroll-behavior: contain;
+ animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1);
+ transform-origin: top left;
+}
+
+.backdropClosing {
+ animation: focus-backdrop-out 180ms ease-in forwards;
+}
+
+.surfaceClosing {
+ animation: focus-surface-out 180ms ease-in forwards;
+}
+
+.sourcePlaceholder {
+ pointer-events: none;
+}
+
+.surfaceHost {
+ width: 100%;
+ height: 100%;
+}
+
+.surfaceHostAdvanced {
+ position: fixed;
+ inset: 0;
+ z-index: 199;
+ width: auto;
+ height: auto;
+ pointer-events: none;
+}
+
+.surfaceControls {
+ position: absolute;
+ inset: 0;
+ z-index: 2;
+ pointer-events: none;
+}
+
+.closeButton {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ z-index: 2;
+ pointer-events: auto;
+}
+
+.pinButton {
+ position: absolute;
+ top: 8px;
+ right: 52px;
+ z-index: 2;
+ pointer-events: auto;
+}
+
+.touchButton {
+ position: absolute;
+ right: 8px;
+ bottom: 8px;
+ z-index: 2;
+ display: none;
+ pointer-events: auto;
+}
+
+@media (pointer: coarse) {
+ .touchButton {
+ display: inline-flex;
+ }
+}
+
+@keyframes focus-backdrop-in {
+ from {
+ opacity: 0;
+ }
+}
+
+@keyframes focus-surface-in {
+ from {
+ transform: translate(var(--focus-translate-x), var(--focus-translate-y))
+ scale(var(--focus-scale-x), var(--focus-scale-y));
+ opacity: 0.72;
+ }
+}
+
+@keyframes focus-backdrop-out {
+ to {
+ opacity: 0;
+ }
+}
+
+@keyframes focus-surface-out {
+ to {
+ transform: translate(var(--focus-translate-x), var(--focus-translate-y))
+ scale(var(--focus-scale-x), var(--focus-scale-y));
+ opacity: 0;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .backdrop,
+ .surface {
+ animation-duration: 1ms;
+ }
+}
+
+@media (forced-colors: active) {
+ .backdrop {
+ backdrop-filter: none;
+ background: Canvas;
+ opacity: 0.8;
+ }
+}
diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx
new file mode 100644
index 000000000..79e4ff50d
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx
@@ -0,0 +1,213 @@
+"use client";
+
+import type { PropsWithChildren } from "react";
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
+import { Box, Portal, RemoveScroll } from "@mantine/core";
+
+import { useEditMode } from "@homarr/boards/edit-mode";
+
+import classes from "./advanced-focus.module.css";
+import { isEscapeOwnedByNestedOverlay } from "./escape";
+import type { FocusRect } from "./geometry";
+
+interface ActiveFocus {
+ itemId: string;
+ source: HTMLElement;
+ sourceRect: FocusRect;
+ pinned: boolean;
+ autofocusClose: boolean;
+ phase: "visible" | "closing";
+}
+
+interface AdvancedFocusContextValue {
+ active: ActiveFocus | null;
+ open: (itemId: string, source: HTMLElement, pinned?: boolean) => void;
+ close: (restoreFocus?: boolean) => void;
+ dismiss: (itemId: string) => void;
+ pin: (autofocusClose?: boolean) => void;
+ hover: (itemId: string, source: HTMLElement) => void;
+ leave: (itemId: string) => void;
+}
+
+const AdvancedFocusContext = createContext
(null);
+const HOVER_DELAY_MS = 150;
+const CLOSE_DURATION_MS = 180;
+
+const isEditableTarget = (target: EventTarget | null) =>
+ target instanceof HTMLElement &&
+ (target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable);
+
+export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
+ const [isEditMode] = useEditMode();
+ const [active, setActive] = useState(null);
+ const activeRef = useRef(null);
+ const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
+ const shiftHeldRef = useRef(false);
+ const hoverTimerRef = useRef | null>(null);
+ const closeTimerRef = useRef | null>(null);
+
+ const cancelHoverTimer = useCallback(() => {
+ if (hoverTimerRef.current === null) return;
+ clearTimeout(hoverTimerRef.current);
+ hoverTimerRef.current = null;
+ }, []);
+
+ const updateActive = useCallback((next: ActiveFocus | null) => {
+ activeRef.current = next;
+ setActive(next);
+ }, []);
+
+ const cancelCloseTimer = useCallback(() => {
+ if (closeTimerRef.current === null) return;
+ clearTimeout(closeTimerRef.current);
+ closeTimerRef.current = null;
+ }, []);
+
+ const open = useCallback(
+ (itemId: string, source: HTMLElement, pinned = false) => {
+ if (isEditMode) return;
+ cancelHoverTimer();
+ cancelCloseTimer();
+ const rect = source.getBoundingClientRect();
+ updateActive({
+ itemId,
+ source,
+ sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
+ pinned,
+ autofocusClose: pinned,
+ phase: "visible",
+ });
+ },
+ [cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive],
+ );
+
+ const close = useCallback(
+ (restoreFocus = true) => {
+ cancelHoverTimer();
+ const current = activeRef.current;
+ if (!current || current.phase === "closing") return;
+ updateActive({ ...current, phase: "closing" });
+ const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS;
+ closeTimerRef.current = setTimeout(() => {
+ closeTimerRef.current = null;
+ updateActive(null);
+ if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus());
+ }, closeDuration);
+ },
+ [cancelHoverTimer, updateActive],
+ );
+
+ const startHoverTimer = useCallback(() => {
+ cancelHoverTimer();
+ const hovered = hoveredRef.current;
+ if (!hovered || !shiftHeldRef.current || activeRef.current) return;
+ hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS);
+ }, [cancelHoverTimer, open]);
+
+ const hover = useCallback(
+ (itemId: string, source: HTMLElement) => {
+ hoveredRef.current = { itemId, source };
+ startHoverTimer();
+ },
+ [startHoverTimer],
+ );
+
+ const leave = useCallback(
+ (itemId: string) => {
+ if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
+ cancelHoverTimer();
+ },
+ [cancelHoverTimer],
+ );
+
+ const dismiss = useCallback(
+ (itemId: string) => {
+ if (activeRef.current?.itemId !== itemId) return;
+ cancelHoverTimer();
+ cancelCloseTimer();
+ updateActive(null);
+ },
+ [cancelCloseTimer, cancelHoverTimer, updateActive],
+ );
+
+ const pin = useCallback(
+ (autofocusClose = false) => {
+ const current = activeRef.current;
+ if (!current || current.pinned || current.phase === "closing") return;
+ updateActive({ ...current, pinned: true, autofocusClose });
+ },
+ [updateActive],
+ );
+
+ useEffect(() => {
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape" && activeRef.current) {
+ const advancedSurface = document.querySelector(`.${classes.surface}`);
+ if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
+ return;
+ event.preventDefault();
+ close();
+ return;
+ }
+ if (isEditMode) return;
+ if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return;
+ if (activeRef.current?.pinned) return;
+ shiftHeldRef.current = true;
+ startHoverTimer();
+ };
+ const handleKeyUp = (event: KeyboardEvent) => {
+ if (event.key !== "Shift") return;
+ shiftHeldRef.current = false;
+ cancelHoverTimer();
+ if (activeRef.current && !activeRef.current.pinned) close(false);
+ };
+ const handleBlur = () => {
+ shiftHeldRef.current = false;
+ cancelHoverTimer();
+ if (activeRef.current && !activeRef.current.pinned) close(false);
+ };
+
+ window.addEventListener("keydown", handleKeyDown);
+ window.addEventListener("keyup", handleKeyUp);
+ window.addEventListener("blur", handleBlur);
+ return () => {
+ window.removeEventListener("keydown", handleKeyDown);
+ window.removeEventListener("keyup", handleKeyUp);
+ window.removeEventListener("blur", handleBlur);
+ cancelHoverTimer();
+ cancelCloseTimer();
+ };
+ }, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]);
+
+ useEffect(() => {
+ if (isEditMode && activeRef.current) close(false);
+ }, [close, isEditMode]);
+
+ const value = useMemo(
+ () => ({ active, open, close, dismiss, pin, hover, leave }),
+ [active, close, dismiss, hover, leave, open, pin],
+ );
+
+ return (
+
+
+ {children}
+
+ {active && (
+
+ close()}
+ aria-hidden
+ />
+
+ )}
+
+ );
+};
+
+export const useAdvancedFocus = () => {
+ const context = useContext(AdvancedFocusContext);
+ if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
+ return context;
+};
diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts
new file mode 100644
index 000000000..dc123eeff
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts
@@ -0,0 +1,54 @@
+import { describe, expect, test } from "vitest";
+
+import { isEscapeOwnedByNestedOverlay } from "./escape";
+
+describe("isEscapeOwnedByNestedOverlay", () => {
+ test("allows Escape from the advanced surface itself", () => {
+ const surface = document.createElement("section");
+ surface.setAttribute("role", "dialog");
+ const button = document.createElement("button");
+ surface.append(button);
+
+ expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false);
+ });
+
+ test("leaves Escape to a nested Mantine modal", () => {
+ const surface = document.createElement("section");
+ surface.setAttribute("role", "dialog");
+ const modal = document.createElement("section");
+ modal.setAttribute("role", "dialog");
+ const input = document.createElement("input");
+ modal.append(input);
+
+ expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true);
+ });
+
+ test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => {
+ const surface = document.createElement("section");
+ surface.setAttribute("role", "dialog");
+ const overlay = document.createElement("div");
+ overlay.setAttribute("role", role);
+ const target = document.createElement("button");
+ overlay.append(target);
+
+ expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
+ });
+
+ test("honors Mantine's stop-propagation marker for menus and comboboxes", () => {
+ const surface = document.createElement("section");
+ surface.setAttribute("role", "dialog");
+ const target = document.createElement("button");
+ target.setAttribute("data-mantine-stop-propagation", "true");
+ surface.append(target);
+
+ expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
+ });
+
+ test("does not mistake the compact widget source for a nested overlay", () => {
+ const surface = document.createElement("section");
+ surface.setAttribute("role", "dialog");
+ const source = document.createElement("button");
+
+ expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false);
+ });
+});
diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts
new file mode 100644
index 000000000..47bd86dd1
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts
@@ -0,0 +1,13 @@
+const escapeOwnerSelector = [
+ '[data-mantine-stop-propagation="true"]',
+ '[role="dialog"]',
+ '[role="menu"]',
+ '[role="listbox"]',
+].join(", ");
+
+export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
+ if (!(target instanceof Element)) return false;
+
+ const escapeOwner = target.closest(escapeOwnerSelector);
+ return escapeOwner !== null && escapeOwner !== advancedSurface;
+};
diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts
new file mode 100644
index 000000000..4b5ee0b89
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts
@@ -0,0 +1,30 @@
+import { describe, expect, it } from "vitest";
+
+import { getAdvancedFocusRect } from "./geometry";
+
+describe("getAdvancedFocusRect", () => {
+ it("expands a widget and keeps it inside the viewport", () => {
+ expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({
+ left: 240,
+ top: 190,
+ width: 720,
+ height: 480,
+ });
+ });
+
+ it("uses the available viewport on small screens", () => {
+ expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({
+ left: 8,
+ top: 68,
+ width: 374,
+ height: 624,
+ });
+ });
+
+ it("keeps controls below the fixed board header on short screens", () => {
+ const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 });
+
+ expect(rect.top).toBeGreaterThanOrEqual(84);
+ expect(rect.top + rect.height).toBeLessThanOrEqual(744);
+ });
+});
diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.ts
new file mode 100644
index 000000000..78bdda016
--- /dev/null
+++ b/apps/nextjs/src/components/board/advanced-focus/geometry.ts
@@ -0,0 +1,26 @@
+import { appShellHeaderHeight } from "../../layout/constants";
+
+export interface FocusRect {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+}
+
+export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => {
+ const margin = viewport.width < 640 ? 8 : 24;
+ const availableWidth = Math.max(0, viewport.width - margin * 2);
+ const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2);
+ const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4)));
+ const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4)));
+
+ return {
+ left: Math.max(margin, (viewport.width - width) / 2),
+ top: Math.max(
+ appShellHeaderHeight + margin,
+ appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2,
+ ),
+ width,
+ height,
+ };
+};
diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts
index d4b2cb24d..0778bf864 100644
--- a/apps/nextjs/src/components/board/items/actions/create-item.ts
+++ b/apps/nextjs/src/components/board/items/actions/create-item.ts
@@ -3,35 +3,48 @@ import { createId } from "@homarr/common";
import type { Modify } from "@homarr/common/types";
import type { WidgetKind } from "@homarr/definitions";
import { widgetDefaultSizes } from "@homarr/definitions";
+import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types";
-import { getFirstEmptyPosition } from "./empty-position";
+import { getFirstEmptyPosition, getNearestEmptyPosition } from "./empty-position";
import { getSectionElements } from "./section-elements";
+export interface BoardPlacementHint {
+ sectionId: string;
+ layoutId: string;
+ xOffset: number;
+ yOffset: number;
+}
+
export interface CreateItemInput {
id?: string;
kind: WidgetKind;
options?: Record;
integrationIds?: string[];
+ advancedOptions?: BoardItemAdvancedOptions;
+ placement?: BoardPlacementHint;
}
export const createItemCallback =
- ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) =>
+ ({ id, kind, options = {}, integrationIds = [], advancedOptions, placement }: CreateItemInput) =>
(previous: Board): Board => {
const firstSection = previous.sections
.filter((section): section is EmptySection => section.kind === "empty")
.toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset)
.at(0);
- if (!firstSection) return previous;
+ if (!firstSection && !placement) return previous;
+
+ const defaultSectionId = firstSection?.id ?? placement?.sectionId;
+ if (!defaultSectionId) return previous;
const widget = {
id: id ?? createId(),
kind,
options,
- layouts: createItemLayouts(previous, firstSection, kind),
+ layouts: createItemLayouts(previous, defaultSectionId, kind, placement),
integrationIds,
- advancedOptions: {
+ advancedOptions: advancedOptions ?? {
title: null,
customCssClasses: [],
borderColor: "",
@@ -53,16 +66,31 @@ const defaultItemSize = { width: 1, height: 1 };
const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize;
-const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => {
+const createItemLayouts = (
+ board: Board,
+ defaultSectionId: string,
+ kind: WidgetKind,
+ placement?: BoardPlacementHint,
+): ItemLayout[] => {
const layouts = getBoardLayouts(board);
const itemSize = getWidgetItemSize(kind);
return layouts.map((layoutId) => {
const boardLayout = board.layouts.find((layout) => layout.id === layoutId);
- const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId });
+ const targetSectionId = placement?.layoutId === layoutId ? placement.sectionId : defaultSectionId;
+ const elements = getSectionElements(board, { sectionId: targetSectionId, layoutId });
+ const targetSection = board.sections.find((section) => section.id === targetSectionId);
+ const dynamicLayout =
+ targetSection?.kind === "dynamic"
+ ? targetSection.layouts.find((layout) => layout.layoutId === layoutId)
+ : undefined;
+ const columnCount = dynamicLayout?.width ?? boardLayout?.columnCount ?? 1;
+ const rowCount = dynamicLayout?.height ?? 9999;
const emptyPosition = boardLayout
- ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize)
+ ? placement?.layoutId === layoutId
+ ? getNearestEmptyPosition(elements, columnCount, rowCount, placement, itemSize)
+ : getFirstEmptyPosition(elements, columnCount, rowCount, itemSize)
: { xOffset: 0, yOffset: 0 };
if (!emptyPosition) {
@@ -73,7 +101,7 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid
width: itemSize.width,
height: itemSize.height,
...emptyPosition,
- sectionId: currentSection.id,
+ sectionId: targetSectionId,
layoutId,
};
});
diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts
index fd91c98d2..ec1803545 100644
--- a/apps/nextjs/src/components/board/items/actions/empty-position.ts
+++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts
@@ -7,19 +7,56 @@ export const getFirstEmptyPosition = (
size: { width: number; height: number } = { width: 1, height: 1 },
) => {
for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) {
- for (let xOffset = 0; xOffset < columnCount; xOffset++) {
- const isOccupied = elements.some(
- (element) =>
- element.yOffset < yOffset + size.height &&
- element.yOffset + element.height > yOffset &&
- element.xOffset < xOffset + size.width &&
- element.xOffset + element.width > xOffset,
- );
-
- if (!isOccupied) {
+ for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) {
+ if (isPositionEmpty(elements, { xOffset, yOffset }, size)) {
return { xOffset, yOffset };
}
}
}
return undefined;
};
+
+export const getNearestEmptyPosition = (
+ elements: Pick[],
+ columnCount: number,
+ rowCount: number,
+ preferred: { xOffset: number; yOffset: number },
+ size: { width: number; height: number },
+) => {
+ const maxX = columnCount - size.width;
+ const maxY = rowCount - size.height;
+ if (maxX < 0 || maxY < 0) return undefined;
+
+ const clamped = {
+ xOffset: Math.min(Math.max(0, preferred.xOffset), maxX),
+ yOffset: Math.min(Math.max(0, preferred.yOffset), maxY),
+ };
+ if (isPositionEmpty(elements, clamped, size)) return clamped;
+
+ const lastOccupiedRow = elements.reduce((maximum, element) => Math.max(maximum, element.yOffset + element.height), 0);
+ const searchMaxY = Math.min(maxY, Math.max(clamped.yOffset + 20, lastOccupiedRow + size.height));
+ let best: { xOffset: number; yOffset: number; distance: number } | undefined;
+
+ for (let yOffset = 0; yOffset <= searchMaxY; yOffset++) {
+ for (let xOffset = 0; xOffset <= maxX; xOffset++) {
+ if (!isPositionEmpty(elements, { xOffset, yOffset }, size)) continue;
+ const distance = Math.abs(xOffset - clamped.xOffset) + Math.abs(yOffset - clamped.yOffset);
+ if (!best || distance < best.distance) best = { xOffset, yOffset, distance };
+ }
+ }
+
+ return best && { xOffset: best.xOffset, yOffset: best.yOffset };
+};
+
+const isPositionEmpty = (
+ elements: Pick[],
+ position: { xOffset: number; yOffset: number },
+ size: { width: number; height: number },
+) =>
+ !elements.some(
+ (element) =>
+ element.yOffset < position.yOffset + size.height &&
+ element.yOffset + element.height > position.yOffset &&
+ element.xOffset < position.xOffset + size.width &&
+ element.xOffset + element.width > position.xOffset,
+ );
diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
index e2a0ff93c..f09383171 100644
--- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test, vi } from "vitest";
+import { afterEach, describe, expect, test, vi } from "vitest";
import * as boardContext from "@homarr/boards/context";
@@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock";
import { LayoutMockBuilder } from "./mocks/layout-mock";
describe("item actions create-item", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
test("should add it to first section", () => {
// Arrange
const itemKind = "clock";
@@ -108,4 +112,58 @@ describe("item actions create-item", () => {
{ height: 1, width: 1 },
);
});
+
+ test("uses the clicked section and nearest cell for the active layout", () => {
+ const activeLayoutId = "desktop";
+ const secondaryLayoutId = "mobile";
+ const rootSectionId = "root";
+ const targetSectionId = "target";
+ const activeLayout = new LayoutMockBuilder({ id: activeLayoutId, columnCount: 12 }).build();
+ const secondaryLayout = new LayoutMockBuilder({ id: secondaryLayoutId, columnCount: 4 }).build();
+ const targetSection = new DynamicSectionMockBuilder({ id: targetSectionId })
+ .addLayout({
+ layoutId: activeLayoutId,
+ parentSectionId: rootSectionId,
+ width: 4,
+ height: 3,
+ })
+ .build();
+ const board = new BoardMockBuilder()
+ .addLayout(activeLayout)
+ .addLayout(secondaryLayout)
+ .addEmptySection({ id: rootSectionId, yOffset: 0 })
+ .addSection(targetSection)
+ .build();
+
+ vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([activeLayoutId, secondaryLayoutId]);
+
+ const result = createItemCallback({
+ kind: "clock",
+ placement: {
+ sectionId: targetSectionId,
+ layoutId: activeLayoutId,
+ xOffset: 2,
+ yOffset: 1,
+ },
+ })(board);
+
+ expect(result.items.at(0)?.layouts).toEqual([
+ {
+ layoutId: activeLayoutId,
+ sectionId: targetSectionId,
+ xOffset: 2,
+ yOffset: 1,
+ width: 1,
+ height: 1,
+ },
+ {
+ layoutId: secondaryLayoutId,
+ sectionId: rootSectionId,
+ xOffset: 0,
+ yOffset: 0,
+ width: 1,
+ height: 1,
+ },
+ ]);
+ });
});
diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
index fcc194618..cc505157b 100644
--- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
+++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types";
-import { getFirstEmptyPosition } from "../empty-position";
+import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
describe("get first empty position", () => {
test.each([
@@ -106,6 +106,37 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined);
});
+
+ test("does not place a wide item beyond the right edge", () => {
+ const elements = createElementsFromLayout([["a", "a", "a", " "]]);
+
+ expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({
+ xOffset: 0,
+ yOffset: 1,
+ });
+ });
+});
+
+describe("get nearest empty position", () => {
+ test("keeps the preferred cell when it fits", () => {
+ expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
+ xOffset: 2,
+ yOffset: 1,
+ });
+ });
+
+ test("chooses the nearest available cell when the preferred cell is occupied", () => {
+ const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }];
+
+ expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({
+ xOffset: 2,
+ yOffset: 0,
+ });
+ });
+
+ test("returns undefined when the item cannot fit within a bounded section", () => {
+ expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined();
+ });
});
const createElementsFromLayout = (layout: string[][]) => {
diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx
index 29b5615e2..5344a2289 100644
--- a/apps/nextjs/src/components/board/items/item-actions.tsx
+++ b/apps/nextjs/src/components/board/items/item-actions.tsx
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
+import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -34,7 +35,12 @@ export const useItemActions = () => {
const createItem = useCallback(
(input: CreateItemInput) => {
- updateBoard(createItemCallback(input));
+ let nextBoard: Board | undefined;
+ updateBoard((previous) => {
+ nextBoard = createItemCallback(input)(previous);
+ return nextBoard;
+ });
+ return nextBoard;
},
[updateBoard],
);
diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx
index ad0f3a305..5ddd9b613 100644
--- a/apps/nextjs/src/components/board/items/item-content.tsx
+++ b/apps/nextjs/src/components/board/items/item-content.tsx
@@ -1,7 +1,8 @@
-import type { MutableRefObject } from "react";
-import { useRef } from "react";
-import { Badge, Card } from "@mantine/core";
-import { useElementSize } from "@mantine/hooks";
+import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react";
+import { useEffect, useRef } from "react";
+import { ActionIcon, Badge, Box, Card, FocusTrap } from "@mantine/core";
+import { useElementSize, useMergedRef, useViewportSize } from "@mantine/hooks";
+import { IconMaximize, IconPin, IconX } from "@tabler/icons-react";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -10,10 +11,14 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings";
+import { useI18n } from "@homarr/translation/client";
import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetError } from "@homarr/widgets/errors";
import type { SectionItem } from "~/app/[locale]/boards/_types";
+import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
+import { useAdvancedFocus } from "../advanced-focus/context";
+import { getAdvancedFocusRect } from "../advanced-focus/geometry";
import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css";
@@ -33,35 +38,160 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => {
export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize();
+ const sourceRef = useRef(null);
+ const mergedRef = useMergedRef(ref, sourceRef);
+ const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
+ const t = useI18n();
+ const [isEditMode] = useEditMode();
const widgetStateRef = useRef | null>(null);
+ const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus();
+ const activeFocus = active?.itemId === item.id ? active : null;
+ const isAdvanced = activeFocus !== null;
+ const advancedRect = activeFocus
+ ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
+ : null;
+
+ // Responsive layout changes remount grid items. Never leave their focus backdrop orphaned.
+ useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
+
+ const openPinned = () => {
+ if (sourceRef.current) open(item.id, sourceRef.current, true);
+ };
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.currentTarget !== event.target || event.key !== "Enter" || !event.shiftKey || isEditMode) return;
+ event.preventDefault();
+ openPinned();
+ };
+ const handleSurfacePointerDown = (event: PointerEvent) => {
+ if (!activeFocus || activeFocus.pinned) return;
+
+ const target =
+ event.target instanceof Element
+ ? event.target.closest(
+ "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex='-1']), [contenteditable='true']",
+ )
+ : null;
+ const alreadyAutofocused = target?.hasAttribute("data-autofocus") ?? false;
+ if (target && !alreadyAutofocused) target.setAttribute("data-autofocus", "true");
+ pin(false);
+ if (target && !alreadyAutofocused) {
+ window.setTimeout(() => target.removeAttribute("data-autofocus"), 250);
+ }
+ };
return (
<>
-
-
+ sourceRef.current && hover(item.id, sourceRef.current)}
+ onPointerLeave={() => leave(item.id)}
+ className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
-
-
+
+
+
+
+ {isAdvanced && (
+
close()}
+ >
+
+
+ )}
+ {isAdvanced && (
+
pin(event.detail === 0)}
+ >
+
+
+ )}
+ {!isEditMode && !isAdvanced && (
+
+
+
+ )}
+
+
+
+
+
+
- {item.advancedOptions.title?.trim() && (
+ {!isAdvanced && item.advancedOptions.title?.trim() && (
| null>;
+ displayMode: "compact" | "advanced";
}
const InnerContent = ({ item, ...dimensions }: InnerContentProps) => {
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css
new file mode 100644
index 000000000..79675c882
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css
@@ -0,0 +1,25 @@
+.selectionCard {
+ overflow: hidden;
+}
+
+.selectionActions {
+ opacity: 1;
+ transition: opacity 150ms ease;
+}
+
+@media (hover: hover) and (pointer: fine) {
+ .selectionActions {
+ opacity: 0;
+ }
+
+ .selectionCard:hover .selectionActions,
+ .selectionCard:focus-within .selectionActions {
+ opacity: 1;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .selectionActions {
+ transition: none;
+ }
+}
diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx
index 2ac349c6d..d7994d77b 100644
--- a/apps/nextjs/src/components/board/items/item-select-modal.tsx
+++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx
@@ -3,10 +3,12 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text,
import { IconApi } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { usePersistBoard } from "@homarr/boards/updater";
import { createId, objectEntries } from "@homarr/common";
import { getIconUrl, getIntegrationName } from "@homarr/definitions";
import type { IntegrationKind, WidgetKind } from "@homarr/definitions";
import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals";
+import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui";
@@ -15,15 +17,47 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid
import { WidgetEditModal } from "@homarr/widgets/modals";
import { useItemActions } from "./item-actions";
+import type { BoardPlacementHint, CreateItemInput } from "./actions/create-item";
+import { createItemCallback } from "./actions/create-item";
+import classes from "./item-select-modal.module.css";
-export const ItemSelectModal = createModal(({ actions }) => {
+interface ItemSelectModalProps {
+ placement?: BoardPlacementHint;
+ board: { id: string; name: string };
+ persistImmediately: boolean;
+}
+
+export const ItemSelectModal = createModal(({ actions, innerProps }) => {
const [search, setSearch] = useState("");
const t = useI18n();
- const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
+ const { createItem } = useItemActions();
const { openModal: openEditModal } = useModalAction(WidgetEditModal);
const { data: integrationData } = clientApi.integration.all.useQuery();
const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery();
const settings = useSettings();
+ const { updateAndPersistBoard } = usePersistBoard(innerProps.board);
+
+ const createAndPersist = (input: CreateItemInput) => {
+ try {
+ if (!innerProps.persistImmediately) {
+ createItem(input);
+ return;
+ }
+ updateAndPersistBoard(createItemCallback(input), {
+ onError: () => {
+ showErrorNotification({
+ title: t("item.create.notification.error.title"),
+ message: t("item.create.notification.error.message"),
+ });
+ },
+ });
+ } catch {
+ showErrorNotification({
+ title: t("item.create.notification.error.title"),
+ message: t("item.create.notification.error.message"),
+ });
+ }
+ };
const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]);
@@ -69,8 +103,13 @@ export const ItemSelectModal = createModal(({ actions }) => {
const handleAddCustomWidget = (definitionId: string) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings);
- createItem({ id: itemId, kind: "customApi", integrationIds: [] });
- updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } });
+ createAndPersist({
+ id: itemId,
+ kind: "customApi",
+ integrationIds: [],
+ options: { ...defaultOptions, definitionId },
+ placement: innerProps.placement,
+ });
actions.closeModal();
};
@@ -88,7 +127,6 @@ export const ItemSelectModal = createModal(({ actions }) => {
const itemId = createId();
const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings);
- createItem({ id: itemId, kind, integrationIds });
actions.closeModal();
openEditModal(
@@ -100,13 +138,19 @@ export const ItemSelectModal = createModal(({ actions }) => {
integrationIds,
},
onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => {
- updateItemOptions({ itemId, newOptions: options });
- updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions });
- updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds });
+ createAndPersist({
+ id: itemId,
+ kind,
+ options,
+ integrationIds: newIntegrationIds,
+ advancedOptions,
+ placement: innerProps.placement,
+ });
},
integrationData: matchingIntegrations,
integrationSupport: hasIntegrationSupport,
settings,
+ boardId: innerProps.board.id,
},
{
title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`,
@@ -144,19 +188,18 @@ export const ItemSelectModal = createModal(({ actions }) => {
style={{ gridColumn: "1 / -1" }}
/>
{filteredCustomWidgets.map((def) => (
- e.currentTarget.style.setProperty("--_hover-opacity", "1")}
- onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
- >
+
{def.iconUrl ? (
-
+
) : (
)}
@@ -174,9 +217,8 @@ export const ItemSelectModal = createModal(({ actions }) => {
left={0}
right={0}
p="xs"
+ className={classes.selectionActions}
style={{
- opacity: "var(--_hover-opacity)",
- transition: "opacity 150ms ease",
background: "linear-gradient(transparent, var(--mantine-color-body) 30%)",
}}
>
@@ -223,14 +265,11 @@ const WidgetItem = ({
h={selectGridCardHeight}
withBorder
pos="relative"
+ className={classes.selectionCard}
style={{
- overflow: "hidden",
- "--_hover-opacity": "0",
borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined,
borderWidth: hasMatchingIntegration ? 2 : undefined,
}}
- onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")}
- onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")}
>
@@ -244,6 +283,11 @@ const WidgetItem = ({
{item.description}
+ {hasMatchingIntegration && (
+
+ {t("item.create.integrationAvailable")}
+
+ )}
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index 8520d6b89..d57161787 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -1,35 +1,40 @@
"use client";
-import type { MutableRefObject, ReactNode } from "react";
-import { useCallback, useMemo } from "react";
+import type { MutableRefObject, ReactNode, RefObject } from "react";
+import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
IconCopy,
IconLayoutKanban,
+ IconMaximize,
IconRefresh,
IconSettings,
IconTrash,
} from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query";
-import { useIsFetching, useQueryClient } from "@tanstack/react-query";
+import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
-import { useSession } from "@homarr/auth/client";
+import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
+import { usePersistBoard } from "@homarr/boards/updater";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
+import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction } from "@homarr/widgets";
-import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
+import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets";
import { WidgetEditModal } from "@homarr/widgets/modals";
import type { SectionItem } from "~/app/[locale]/boards/_types";
+import { useAdvancedFocus } from "../advanced-focus/context";
+import { useBoardPermissions } from "../permissions/client";
import { useSectionContext } from "../sections/section-context";
import { useItemActions } from "./item-actions";
import { ItemMoveModal } from "./item-move-modal";
@@ -37,13 +42,16 @@ import { ItemMoveModal } from "./item-move-modal";
interface WidgetContextMenuProps {
item: SectionItem;
widgetStateRef: MutableRefObject | null>;
+ sourceRef: RefObject;
children: ReactNode;
}
-export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => {
+export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
+ const { hasChangeAccess } = useBoardPermissions(board);
+ const { updateAndPersistBoard } = usePersistBoard(board);
const tItem = useScopedI18n("item");
const tMenu = useScopedI18n("item.menu.label");
const t = useI18n();
@@ -55,19 +63,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } =
useItemActions();
const { data: integrationData, isPending } = clientApi.integration.all.useQuery();
- const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]);
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
+ const { open: openAdvancedFocus } = useAdvancedFocus();
+ const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
+ const [menuOpened, setMenuOpened] = useState(false);
- const widgetQueryKey = useMemo(
- () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]),
- [currentDefinition, item.kind],
+ const widgetQueryKeys = useMemo(
+ () => getWidgetQueryKeys(currentDefinition as Parameters[0]),
+ [currentDefinition],
);
- const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0;
+ const matchesWidgetQuery = useCallback(
+ (queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)),
+ [widgetQueryKeys],
+ );
+ const isWidgetFetching =
+ useIsFetching({
+ type: "active",
+ predicate: (query) => matchesWidgetQuery(query.queryKey),
+ }) > 0;
const handleRefetch = useCallback(() => {
- void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" });
- }, [queryClient, widgetQueryKey]);
+ void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" })));
+ }, [queryClient, widgetQueryKeys]);
+
+ const canInteractWithSelectedIntegrations = useMemo(() => {
+ const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id));
+ return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id));
+ }, [integrationsWithInteractAccess, item.integrationIds]);
const options = useMemo(
() => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record,
@@ -76,11 +99,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => {
+ if (!hasChangeAccess) return [];
const rawOptions = currentDefinition.createOptions(settings) as unknown as Record;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu);
- }, [currentDefinition, settings]);
+ }, [currentDefinition, hasChangeAccess, settings]);
- const widgetContextActions = useMemo(() => {
+ const widgetContextActions = (() => {
const def = currentDefinition as Record;
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -90,17 +114,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds,
- context: { isEditMode, boardId: board.id, itemId: item.id },
+ context: {
+ isEditMode,
+ boardId: board.id,
+ itemId: item.id,
+ canInteractWithSelectedIntegrations,
+ },
widgetStateRef,
});
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[];
- }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]);
+ })();
const persistBoard = useCallback(
- (updatedItems: typeof board.items) => {
- saveBoard({ ...board, items: updatedItems });
+ (updater: (previous: typeof board) => typeof board) => {
+ updateAndPersistBoard(updater, {
+ onError: () => {
+ showErrorNotification({
+ title: t("item.menu.notification.saveError.title"),
+ message: t("item.menu.notification.saveError.message"),
+ });
+ },
+ });
},
- [board, saveBoard],
+ [t, updateAndPersistBoard],
);
const openEditModal = useCallback(() => {
@@ -113,12 +149,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
integrationIds: item.integrationIds,
},
onSuccessfulEdit: (editResult) => {
- updateItemOptions({ itemId: item.id, newOptions: editResult.options });
- updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
- updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
- if (!isEditMode) {
- persistBoard(
- board.items.map((boardItem) =>
+ if (isEditMode) {
+ updateItemOptions({ itemId: item.id, newOptions: editResult.options });
+ updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
+ updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
+ } else {
+ persistBoard((previous) => ({
+ ...previous,
+ items: previous.items.map((boardItem) =>
boardItem.id !== item.id
? boardItem
: {
@@ -128,7 +166,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
integrationIds: editResult.integrationIds,
},
),
- );
+ }));
}
},
integrationData: (integrationData ?? []).filter(
@@ -154,18 +192,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
settings,
isEditMode,
persistBoard,
- board,
]);
const handleToggle = useCallback(
(key: string) => (checked: boolean) => {
- const newOptions = { ...options, [key]: checked };
- updateItemOptions({ itemId: item.id, newOptions });
- persistBoard(
- board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
- );
+ if (isEditMode) {
+ updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } });
+ } else {
+ persistBoard((previous) => ({
+ ...previous,
+ items: previous.items.map((boardItem) =>
+ boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } },
+ ),
+ }));
+ }
},
- [options, item.id, updateItemOptions, persistBoard, board],
+ [options, item.id, updateItemOptions, isEditMode, persistBoard],
);
if (!session) return <>{children}>;
@@ -174,9 +216,28 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden);
return (
-
+
{children}
+ }
+ onClick={() => {
+ if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current, true);
+ }}
+ disabled={isEditMode}
+ >
+ {t("item.advancedFocus.open")}
+
+
{toggleOptions.length > 0 && (
<>
{tMenu("options")}
@@ -248,20 +309,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
{tMenu("refresh")}
- }
- onClick={openEditModal}
- disabled={isPending}
- >
- {tMenu("settings")}
-
+ {hasChangeAccess && (
+ }
+ onClick={openEditModal}
+ disabled={isPending}
+ >
+ {tMenu("settings")}
+
+ )}
>
{isEditMode && (
@@ -290,16 +353,20 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont
interface WidgetQueryStatusProps {
queryClient: QueryClient;
- queryKey: QueryKey;
+ queryKeys: readonly QueryKey[];
isFetching: boolean;
t: TranslationFunction;
}
-const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => {
- const queries = queryClient.getQueryCache().findAll({ queryKey });
+const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => {
+ const queries = queryClient.getQueryCache().findAll({
+ type: "active",
+ predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)),
+ });
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0;
- const ageLabel = useTimeAgo(new Date(latest));
+ const ageLabel = useTimeAgo(new Date(latest || Date.now()));
+ const ageSuffix = latest > 0 ? ` · ${ageLabel}` : "";
if (isFetching) {
return (
@@ -334,7 +401,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
- {t("item.menu.status.error")} · {ageLabel}
+ {t("item.menu.status.error")}
+ {ageSuffix}
@@ -345,7 +413,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery
- {t("item.menu.status.success")} · {ageLabel}
+ {t("item.menu.status.success")}
+ {ageSuffix}
);
diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
index 89b3ba93e..f6ec2600f 100644
--- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
+++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css
@@ -13,10 +13,8 @@
transform 150ms ease;
}
-:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
- .wrapper,
-:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
- .wrapper {
+:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
+:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx
new file mode 100644
index 000000000..606bc5ece
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx
@@ -0,0 +1,177 @@
+"use client";
+
+import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react";
+import { useEffect, useRef, useState } from "react";
+import type { BoxProps } from "@mantine/core";
+import { Box, Menu } from "@mantine/core";
+import { IconPlus } from "@tabler/icons-react";
+
+import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
+import { useEditMode } from "@homarr/boards/edit-mode";
+import { useModalAction } from "@homarr/modals";
+import { useSettings } from "@homarr/settings";
+import { useI18n } from "@homarr/translation/client";
+
+import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
+import { ItemSelectModal } from "../../items/item-select-modal";
+import { useBoardPermissions } from "../../permissions/client";
+import type { BoardPlacementHint } from "../../items/actions/create-item";
+import type { UseGridstackRefs } from "./use-gridstack";
+
+interface MenuState {
+ x: number;
+ y: number;
+ placement: BoardPlacementHint;
+}
+
+interface Props extends BoxProps {
+ section: Exclude | DynamicSectionItem;
+ refs: UseGridstackRefs;
+}
+
+export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren) => {
+ const board = useRequiredBoard();
+ const currentLayoutId = useCurrentLayout();
+ const settings = useSettings();
+ const t = useI18n();
+ const { hasChangeAccess } = useBoardPermissions(board);
+ const { openModal } = useModalAction(ItemSelectModal);
+ const [isEditMode] = useEditMode();
+ const [menu, setMenu] = useState(null);
+ const longPressTimer = useRef | null>(null);
+ const returnFocusRef = useRef(null);
+
+ const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
+ const clearLongPress = () => {
+ if (longPressTimer.current === null) return;
+ clearTimeout(longPressTimer.current);
+ longPressTimer.current = null;
+ };
+
+ useEffect(
+ () => () => {
+ if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
+ },
+ [],
+ );
+ const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => {
+ const wrapper = refs.wrapperElement.current;
+ const gridstack = refs.gridstack.current;
+ if (!(target instanceof Element) || !wrapper || !gridstack) return null;
+ if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null;
+
+ const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true);
+ const rowLimit = section.kind === "dynamic" ? section.height : undefined;
+ if (
+ cell.x < 0 ||
+ cell.y < 0 ||
+ cell.x >= gridstack.getColumn() ||
+ (rowLimit !== undefined && cell.y >= rowLimit) ||
+ !gridstack.isAreaEmpty(cell.x, cell.y, 1, 1)
+ ) {
+ return null;
+ }
+
+ return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y };
+ };
+
+ const handleContextMenu = (event: MouseEvent) => {
+ if (!enabled) return;
+ const placement = resolvePlacement(event.target, event.pageX, event.pageY);
+ if (!placement) return;
+ event.preventDefault();
+ event.stopPropagation();
+ setMenu({ x: event.clientX, y: event.clientY, placement });
+ };
+
+ const handlePointerDown = (event: PointerEvent) => {
+ if (!enabled || event.pointerType !== "touch") return;
+ const placement = resolvePlacement(event.target, event.pageX, event.pageY);
+ if (!placement) return;
+ clearLongPress();
+ longPressTimer.current = setTimeout(() => {
+ setMenu({ x: event.clientX, y: event.clientY, placement });
+ navigator.vibrate?.(20);
+ }, 500);
+ };
+
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (
+ !enabled ||
+ event.currentTarget !== event.target ||
+ (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10"))
+ ) {
+ return;
+ }
+ const wrapper = event.currentTarget;
+ const gridstack = refs.gridstack.current;
+ if (!gridstack) return;
+
+ const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1);
+ for (let yOffset = 0; yOffset < rowCount; yOffset++) {
+ for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) {
+ if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue;
+ const rect = wrapper.getBoundingClientRect();
+ event.preventDefault();
+ returnFocusRef.current = wrapper;
+ setMenu({
+ x: rect.left + 24,
+ y: rect.top + 24,
+ placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset },
+ });
+ return;
+ }
+ }
+ };
+
+ return (
+ <>
+
+ {children}
+
+ {
+ if (!opened) {
+ setMenu(null);
+ const returnFocus = returnFocusRef.current;
+ returnFocusRef.current = null;
+ requestAnimationFrame(() => returnFocus?.focus());
+ }
+ }}
+ withinPortal
+ position="bottom-start"
+ >
+
+
+
+
+ }
+ onClick={() => {
+ returnFocusRef.current = null;
+ if (menu) {
+ openModal({ placement: menu.placement, board, persistImmediately: !isEditMode });
+ }
+ setMenu(null);
+ }}
+ >
+ {t("item.create.addAtPosition")}
+
+
+
+ >
+ );
+};
diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
index e851e5c2a..6934008f5 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
+++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx
@@ -1,13 +1,13 @@
"use client";
import type { BoxProps } from "@mantine/core";
-import { Box } from "@mantine/core";
import combineClasses from "clsx";
import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types";
import { SectionContent } from "../content";
import { SectionProvider } from "../section-context";
import { useSectionItems } from "../use-section-items";
+import { BoardEmptySpaceContextMenu } from "./board-empty-space-context-menu";
import { useGridstack } from "./use-gridstack";
interface Props extends BoxProps {
@@ -22,15 +22,16 @@ export const GridStack = ({ section, ...props }: Props) => {
return (
-
-
+
);
};
diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
index b5e468de6..0c593b0a1 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
+++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts
@@ -14,6 +14,7 @@ import { initializeGridstack } from "./init-gridstack";
export interface UseGridstackRefs {
wrapper: Ref;
+ wrapperElement: RefObject;
items: RefObject>>;
gridstack: RefObject;
}
@@ -287,6 +288,7 @@ export const useGridstack = (section: Omit, itemIds: string[])
refs: {
items: itemRefs,
wrapper: wrapperRef,
+ wrapperElement: wrapperElementRef,
gridstack: gridRef,
},
};
diff --git a/apps/nextjs/src/components/layout/constants.ts b/apps/nextjs/src/components/layout/constants.ts
new file mode 100644
index 000000000..2799f8304
--- /dev/null
+++ b/apps/nextjs/src/components/layout/constants.ts
@@ -0,0 +1 @@
+export const appShellHeaderHeight = 60;
diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx
index b51cf3220..56001cafb 100644
--- a/apps/nextjs/src/components/layout/shell.tsx
+++ b/apps/nextjs/src/components/layout/shell.tsx
@@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai";
import { useOptionalBackgroundProps } from "./background";
+import { appShellHeaderHeight } from "./constants";
import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
@@ -23,7 +24,7 @@ export const ClientShell = ({
return (
{
+ let browser: Browser;
+ let baseUrl: string;
+ let stopContainerAsync: (() => Promise) | undefined;
+
+ beforeAll(async () => {
+ const { db, localMountPath } = await createSqliteDbFileAsync();
+ const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials);
+ await seedInteractionBoardAsync(db, ownerId);
+ await seedCredentialsUserAsync(db, {
+ ...disabledModifierCredentials,
+ enableRightClickOnWidgets: false,
+ boardPermission: "modify",
+ });
+ await seedCredentialsUserAsync(db, {
+ ...viewerCredentials,
+ enableRightClickOnWidgets: true,
+ boardPermission: "view",
+ });
+
+ const homarrContainer = await createHomarrContainer({
+ environment: { AUTH_PROVIDERS: "credentials" },
+ mounts: { "/appdata": localMountPath },
+ }).start();
+ stopContainerAsync = async () => {
+ await homarrContainer.stop();
+ };
+ baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
+ browser = await chromium.launch();
+ }, 120_000);
+
+ afterAll(async () => {
+ await browser?.close();
+ await stopContainerAsync?.();
+ });
+
+ test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
+
+ try {
+ const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
+ const compactSurface = widget.locator(".clock-wrapper");
+ const advancedDialog = page.getByRole("dialog", { name: "Date and time advanced view" });
+
+ await compactSurface.evaluate((element) => {
+ element.setAttribute("data-lifecycle-probe", "same-instance");
+ });
+ await widget.focus();
+ await page.keyboard.down("Shift");
+ await widget.hover();
+ await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
+ await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
+ await expect(widget).toBeFocused();
+ await page.keyboard.up("Shift");
+ await expect(advancedDialog).toBeHidden();
+ await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
+ await expect(widget).toBeFocused();
+
+ await page.keyboard.down("Shift");
+ await widget.hover();
+ await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
+ await page.getByRole("button", { name: "Keep advanced view open" }).click();
+ const pinnedButton = page.getByRole("button", { name: "Advanced view is pinned" });
+ await expect(pinnedButton).toBeFocused();
+ await page.keyboard.up("Shift");
+ await expect(advancedDialog).toBeVisible();
+ await page.keyboard.press("Shift+Tab");
+ await expect(advancedDialog).toBeVisible();
+ await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
+ await page.keyboard.press("Escape");
+ await expect(advancedDialog).toBeHidden();
+ await expect(widget).toBeFocused();
+
+ await page.keyboard.press("Shift+Enter");
+ await expect(advancedDialog).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(advancedDialog).toBeHidden();
+ await expect(widget).toBeFocused();
+
+ await widget.click({ button: "right" });
+ await page.getByRole("menuitem", { name: "Open advanced view" }).click();
+ await expect(advancedDialog).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(advancedDialog).toBeHidden();
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+
+ test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
+
+ try {
+ const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
+ const iframe = iframeSlot.locator("iframe");
+ await iframe.evaluate((element) => {
+ element.srcdoc = 'State ';
+ });
+ const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe");
+ await frame.locator("#state").fill("changed");
+
+ await iframeSlot.focus();
+ await page.keyboard.press("Shift+Enter");
+ await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeVisible();
+ await expect(frame.locator("#state")).toHaveValue("changed");
+ await page.keyboard.press("Escape");
+ await expect(frame.locator("#state")).toHaveValue("changed");
+
+ await iframeSlot.focus();
+ await page.keyboard.press("Shift+Enter");
+ await page.setViewportSize({ width: 800, height: 900 });
+ await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeHidden();
+ await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+
+ test("keeps advanced controls below the fixed header on short screens", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
+
+ try {
+ const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
+ await widget.focus();
+ await page.keyboard.press("Shift+Enter");
+ const closeButton = page.getByRole("button", { name: "Close advanced view" });
+ const bounds = await closeButton.boundingBox();
+ expect(bounds).not.toBeNull();
+ if (!bounds) return;
+ expect(bounds.x).toBeGreaterThanOrEqual(0);
+ expect(bounds.y).toBeGreaterThanOrEqual(60);
+ expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
+ expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
+ await closeButton.click();
+ await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+
+ test("opens the add-at-position chooser only when right click and change access are enabled", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
+
+ try {
+ const grid = page.locator(".grid-stack[data-kind='empty']");
+ const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" });
+ await expect(grid).toHaveAttribute("aria-label", "Add item here");
+
+ await grid.focus();
+ await page.keyboard.press("Shift+F10");
+ await expect(addHereMenuItem).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(addHereMenuItem).toBeHidden();
+ await expect(grid).toBeFocused();
+
+ await touchAndHoldEmptyGridSpaceAsync(grid);
+ await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 });
+ await page.keyboard.press("Escape");
+
+ await rightClickEmptyGridSpaceAsync(grid);
+ await addHereMenuItem.click();
+ await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible();
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+
+ test("honors the user setting gate even when the user can modify the board", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials);
+
+ try {
+ const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
+ const grid = page.locator(".grid-stack[data-kind='empty']");
+
+ await widget.click({ button: "right" });
+ await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
+ await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
+ await rightClickEmptyGridSpaceAsync(grid);
+ await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+
+ test("keeps read-only advanced viewing but blocks add-at-position without change access", async () => {
+ const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
+
+ try {
+ const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
+ const grid = page.locator(".grid-stack[data-kind='empty']");
+
+ await widget.click({ button: "right" });
+ await page.getByRole("menuitem", { name: "Open advanced view" }).click();
+ await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
+ await page.keyboard.press("Escape");
+
+ await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
+ await rightClickEmptyGridSpaceAsync(grid);
+ await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0);
+ } finally {
+ await context.close();
+ }
+ }, 60_000);
+});
+
+const openBoardAsync = async (
+ browser: Browser,
+ baseUrl: string,
+ credentials: { username: string; password: string },
+ viewport = { width: 1280, height: 900 },
+): Promise<{ context: BrowserContext; page: Page }> => {
+ const context = await browser.newContext({ reducedMotion: "reduce", viewport });
+ const page = await context.newPage();
+
+ await page.goto(`${baseUrl}/auth/login`);
+ await page.getByLabel("Username").fill(credentials.username);
+ await page.locator("#password").fill(credentials.password);
+ await page.locator("css=button[type='submit']").click();
+ await page.waitForURL(baseUrl, { timeout: 15_000 });
+ await page.goto(`${baseUrl}/boards/${boardName}`);
+ await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({
+ timeout: 15_000,
+ });
+
+ return { context, page };
+};
+
+const rightClickEmptyGridSpaceAsync = async (grid: Locator) => {
+ const bounds = await grid.boundingBox();
+ expect(bounds).not.toBeNull();
+ if (!bounds) return;
+
+ await grid.click({
+ button: "right",
+ position: { x: bounds.width - 16, y: Math.min(24, bounds.height / 2) },
+ });
+};
+
+const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
+ const bounds = await grid.boundingBox();
+ expect(bounds).not.toBeNull();
+ if (!bounds) return;
+
+ const clientX = bounds.x + bounds.width - 16;
+ const clientY = bounds.y + Math.min(24, bounds.height / 2);
+ await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
+ await new Promise((resolve) => setTimeout(resolve, 550));
+ await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY });
+};
+
+const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
+ const boardId = createId();
+ const baseLayoutId = createId();
+ const desktopLayoutId = createId();
+ const sectionId = createId();
+ const clockItemId = createId();
+ const iframeItemId = createId();
+
+ await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
+ await db.insert(layouts).values([
+ { id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
+ { id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
+ ]);
+ await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
+ await db.insert(items).values([
+ {
+ id: clockItemId,
+ kind: "clock",
+ boardId,
+ options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }),
+ advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
+ },
+ {
+ id: iframeItemId,
+ kind: "iframe",
+ boardId,
+ options: stringify({ embedUrl: "https://example.com", allowScrolling: true }),
+ advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
+ },
+ ]);
+ await db.insert(itemLayouts).values(
+ [baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
+ {
+ itemId: clockItemId,
+ sectionId,
+ layoutId,
+ xOffset: 0,
+ yOffset: 0,
+ width: 4,
+ height: 3,
+ },
+ {
+ itemId: iframeItemId,
+ sectionId,
+ layoutId,
+ xOffset: 4,
+ yOffset: 0,
+ width: 4,
+ height: 3,
+ },
+ ]),
+ );
+ await db
+ .update(users)
+ .set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true })
+ .where(eq(users.id, ownerId));
+};
+
+const seedCredentialsUserAsync = async (
+ db: SqliteDatabase,
+ input: {
+ username: string;
+ password: string;
+ enableRightClickOnWidgets: boolean;
+ boardPermission: "view" | "modify";
+ },
+) => {
+ const userId = createId();
+ const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) });
+ if (!board) throw new Error("Interaction board was not seeded");
+
+ await db.insert(users).values({
+ id: userId,
+ name: input.username,
+ provider: "credentials",
+ password: await hashPasswordAsync(input.password),
+ enableRightClickOnWidgets: input.enableRightClickOnWidgets,
+ homeBoardId: board.id,
+ completedBoardTour: true,
+ completedManageTour: true,
+ });
+ await db.insert(boardUserPermissions).values({
+ boardId: board.id,
+ userId,
+ permission: input.boardPermission,
+ });
+};
diff --git a/packages/api/src/router/test/widgets/patchmon.spec.ts b/packages/api/src/router/test/widgets/patchmon.spec.ts
index df37b5647..27f5cfe40 100644
--- a/packages/api/src/router/test/widgets/patchmon.spec.ts
+++ b/packages/api/src/router/test/widgets/patchmon.spec.ts
@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
});
const caller = createCaller(db, null);
- await expect(caller.getStats({ integrationId })).rejects.toThrow(
- new TRPCError({ code: "UNAUTHORIZED" }),
- );
+ await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
});
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts
new file mode 100644
index 000000000..7933b0d16
--- /dev/null
+++ b/packages/api/src/router/test/widgets/timetable.spec.ts
@@ -0,0 +1,114 @@
+import { stringify } from "superjson";
+import { describe, expect, it, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import type { Database } from "@homarr/db";
+import { boards, items, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { timetableRouter } from "../../widgets/timetable";
+
+const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
+
+vi.mock("@homarr/request-handler/timetable", () => ({
+ timetableGetTimetableRequestHandler: {
+ handler: (input: unknown) => ({
+ getDataAsync: async () => {
+ mocks.getTimetable(input);
+ return { data: { stationId: "station", timestamp: new Date(0), entries: [] } };
+ },
+ }),
+ },
+ timetableSearchStationsRequestHandler: {
+ handler: (input: unknown) => ({
+ getDataAsync: async () => {
+ mocks.searchStations(input);
+ return { data: [] };
+ },
+ }),
+ },
+}));
+
+const createSession = (userId: string): Session => ({
+ user: { id: userId, permissions: [], colorScheme: "light" },
+ expires: new Date().toISOString(),
+});
+
+const createCaller = (db: Database, session: Session | null) =>
+ timetableRouter.createCaller({ db, deviceType: undefined, session });
+
+const createTimetableItemAsync = async (
+ db: Database,
+ { baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean },
+) => {
+ const ownerId = createId();
+ const boardId = createId();
+ const itemId = createId();
+ await db.insert(users).values({ id: ownerId });
+ await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic });
+ await db.insert(items).values({
+ id: itemId,
+ boardId,
+ kind: "timetable",
+ options: stringify({ baseUrl }),
+ });
+ return { boardId, itemId, ownerId };
+};
+
+describe("timetableRouter source authorization", () => {
+ it("allows itemless setup only for the fixed default provider", async () => {
+ const caller = createCaller(createDb(), null);
+
+ await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]);
+ await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+
+ it("loads a persisted private endpoint only through a viewable timetable item", async () => {
+ const db = createDb();
+ const baseUrl = "http://timetable.internal:8080";
+ const { itemId } = await createTimetableItemAsync(db, { baseUrl });
+ const caller = createCaller(db, null);
+
+ await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 });
+ expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
+ await expect(
+ caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }),
+ ).rejects.toMatchObject({ code: "FORBIDDEN" });
+ });
+
+ it("rejects a persisted endpoint when its board is not viewable", async () => {
+ const db = createDb();
+ const baseUrl = "http://timetable.internal:8080";
+ const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
+
+ await expect(
+ createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ });
+
+ it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
+ const db = createDb();
+ const baseUrl = "http://timetable.internal:8080";
+ const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
+ const caller = createCaller(db, createSession(ownerId));
+
+ await caller.searchStations({ baseUrl, boardId, query: "Bern" });
+ expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
+ });
+
+ it("does not treat public board view access as configuration access", async () => {
+ const db = createDb();
+ const baseUrl = "http://timetable.internal:8080";
+ const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl });
+
+ await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
+ code: "NOT_FOUND",
+ });
+ await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({
+ code: "FORBIDDEN",
+ });
+ });
+});
diff --git a/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts
new file mode 100644
index 000000000..905ac00a4
--- /dev/null
+++ b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+
+import { everyBeszelIntegrationFailed } from "../beszel-failures";
+
+describe("everyBeszelIntegrationFailed", () => {
+ it("keeps partial successes", () => {
+ expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false);
+ });
+
+ it("detects when every integration failed", () => {
+ expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true);
+ });
+
+ it("allows an empty integration selection", () => {
+ expect(everyBeszelIntegrationFailed([])).toBe(false);
+ });
+});
diff --git a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts
new file mode 100644
index 000000000..12fbe5778
--- /dev/null
+++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
+
+describe("custom API request hardening", () => {
+ it("accepts HTTP endpoints without embedded credentials", () => {
+ expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com");
+ });
+
+ it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => {
+ expect(() => validateCustomApiUrl(url)).toThrow();
+ });
+
+ it("rejects a response once its byte budget is exceeded", async () => {
+ const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
+ await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
+ });
+});
diff --git a/packages/api/src/router/widgets/beszel-failures.ts b/packages/api/src/router/widgets/beszel-failures.ts
new file mode 100644
index 000000000..e7d75f87d
--- /dev/null
+++ b/packages/api/src/router/widgets/beszel-failures.ts
@@ -0,0 +1,5 @@
+export const everyBeszelIntegrationFailed = (results: readonly unknown[]) =>
+ results.length > 0 &&
+ results.every(
+ (result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined,
+ );
diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts
index 738adecf7..1380bc405 100644
--- a/packages/api/src/router/widgets/beszel.ts
+++ b/packages/api/src/router/widgets/beszel.ts
@@ -13,6 +13,7 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+import { everyBeszelIntegrationFailed } from "./beszel-failures";
import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
@@ -20,6 +21,20 @@ const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
+function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) {
+ if (everyBeszelIntegrationFailed(results)) {
+ const firstError = results[0];
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: `Every Beszel integration failed while loading ${operation}: ${
+ typeof firstError === "object" && firstError !== null && "error" in firstError
+ ? String(firstError.error)
+ : "unknown error"
+ }`,
+ });
+ }
+}
+
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
+ throwIfEveryBeszelIntegrationFailed(results, "systems");
return results;
}),
@@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length,
});
+ throwIfEveryBeszelIntegrationFailed(results, "alerts");
return results;
}),
diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts
index 729e82634..68d968412 100644
--- a/packages/api/src/router/widgets/calendar.ts
+++ b/packages/api/src/router/widgets/calendar.ts
@@ -1,16 +1,13 @@
import { z } from "zod/v4";
-import { createLogger } from "@homarr/core/infrastructure/logs";
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
-const logger = createLogger({ module: "calendarRouter" });
-
export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure
.meta({
@@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => {
- const settled = await Promise.allSettled(
- ctx.integrations.map(async (integration) => {
- const { integrationIds: _integrationIds, ...handlerInput } = input;
- const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
- const { data } = await innerHandler.getDataAsync();
+ return await settleIntegrationQueries(ctx.integrations, async (integration) => {
+ const { integrationIds: _integrationIds, ...handlerInput } = input;
+ const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
+ const { data } = await innerHandler.getDataAsync();
- return {
- events: data,
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- },
- };
- }),
- );
-
- return settled.flatMap((result, index) => {
- if (result.status === "fulfilled") {
- return [result.value];
- }
- const integration = ctx.integrations[index];
- logger.warn(
- new ErrorWithMetadata(
- "Calendar integration request failed; skipping events for this integration",
- {
- integrationId: integration?.id,
- integrationKind: integration?.kind,
- },
- { cause: result.reason },
- ),
- );
- return [];
+ return {
+ events: data,
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ },
+ };
});
}),
});
diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts
new file mode 100644
index 000000000..ec2d5c81f
--- /dev/null
+++ b/packages/api/src/router/widgets/custom-api-security.ts
@@ -0,0 +1,21 @@
+import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
+
+export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
+
+export function validateCustomApiUrl(urlString: string): URL {
+ if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
+
+ const url = new URL(urlString);
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("Custom API URL must use HTTP or HTTPS");
+ }
+ if (url.username || url.password) throw new Error("Custom API URL must not contain credentials");
+ return url;
+}
+
+export async function readBoundedJsonResponseAsync(
+ response: Response,
+ maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
+): Promise {
+ return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
+}
diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts
index 09fafa880..a306e71ca 100644
--- a/packages/api/src/router/widgets/custom-api.ts
+++ b/packages/api/src/router/widgets/custom-api.ts
@@ -9,13 +9,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
import { applyAuth } from "../custom-widget/auth";
import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data";
+import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "./custom-api-security";
const logger = createLogger({ module: "widget:customApi" });
const FETCH_TIMEOUT_MS = 10_000;
-const validateUrl = (urlString: string): URL => new URL(urlString);
-
export const customApiRouter = createTRPCRouter({
getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => {
const definition = await ctx.db.query.customWidgetDefinitions.findFirst({
@@ -47,7 +46,7 @@ export const customApiRouter = createTRPCRouter({
value: decryptSecret(s.value),
}));
- const url = validateUrl(definition.url);
+ const url = validateCustomApiUrl(definition.url);
const headers = new Headers({ Accept: "application/json" });
if (definition.method !== "GET" && definition.requestBody) {
@@ -64,7 +63,7 @@ export const customApiRouter = createTRPCRouter({
method: definition.method,
headers,
body: definition.method !== "GET" ? definition.requestBody : undefined,
- redirect: "follow",
+ redirect: "error",
signal: controller.signal,
});
@@ -75,7 +74,7 @@ export const customApiRouter = createTRPCRouter({
});
}
- const json: unknown = await response.json();
+ const json = await readBoundedJsonResponseAsync(response);
return extractDisplayDataWithFallback(json, definition.displayType, displayConfig);
} catch (error) {
if (error instanceof TRPCError) throw error;
diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts
index 08951da7c..fdeb77831 100644
--- a/packages/api/src/router/widgets/firewall.ts
+++ b/packages/api/src/router/widgets/firewall.ts
@@ -18,20 +18,33 @@ const queryFirewall = <
},
>(
handler: THandler,
+ createFallback: () => Awaited["getDataAsync"]>>["data"],
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
- settleIntegrationQueries(ctx.integrations, async (integration) => {
- const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
- return {
- integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
- summary: data as Awaited["getDataAsync"]>>["data"],
- };
- }),
+ settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
+ const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
+ return {
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
+ summary: data as Awaited["getDataAsync"]>>["data"],
+ error: undefined as string | undefined,
+ };
+ },
+ {
+ fallback: (integration, error) => ({
+ integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
+ summary: createFallback(),
+ error: error instanceof Error ? error.message : "Integration request failed",
+ }),
+ throwOnAllFailures: true,
+ },
+ ),
);
export const firewallRouter = createTRPCRouter({
- getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
- getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
- getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
- getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
+ getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
+ getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
+ getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
+ getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
});
diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts
index 54a949dab..fe411d9be 100644
--- a/packages/api/src/router/widgets/indexer-manager.ts
+++ b/packages/api/src/router/widgets/indexer-manager.ts
@@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(ctx.integrations, async (integration) => {
- const innerHandler = indexerManagerRequestHandler.handler(integration, {});
- const { data: indexers } = await innerHandler.getDataAsync();
+ return await settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
+ const innerHandler = indexerManagerRequestHandler.handler(integration, {});
+ const { data: indexers } = await innerHandler.getDataAsync();
- return {
- integrationId: integration.id,
- indexers,
- };
- });
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ indexers,
+ error: undefined as string | undefined,
+ };
+ },
+ {
+ fallback: (integration, error) => ({
+ integrationId: integration.id,
+ integrationName: integration.name,
+ indexers: [],
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ throwOnAllFailures: true,
+ },
+ );
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts
index dd0d687cb..2f38423f1 100644
--- a/packages/api/src/router/widgets/media-organizer.ts
+++ b/packages/api/src/router/widgets/media-organizer.ts
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
+import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => {
- const results = await Promise.all(
- ctx.integrations.map(async (integration) => {
+ return await settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
+ integrationName: integration.name,
...data,
+ error: undefined as string | undefined,
};
- }),
+ },
+ {
+ fallback: (integration, error) => ({
+ integrationId: integration.id,
+ integrationName: integration.name,
+ missing: [],
+ missingCount: 0,
+ queued: [],
+ queuedCount: 0,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ throwOnAllFailures: true,
+ },
);
- return results;
}),
});
diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts
index fbd192499..eb13b25d1 100644
--- a/packages/api/src/router/widgets/media-requests.ts
+++ b/packages/api/src/router/widgets/media-requests.ts
@@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({
data.map((request) => ({
...request,
integrationId: integration.id,
+ integration,
})),
)
.toSorted((dataA, dataB) => {
@@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
+ return { success: true };
}),
});
diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts
index 591d58d2b..80ce9018d 100644
--- a/packages/api/src/router/widgets/media-server.ts
+++ b/packages/api/src/router/widgets/media-server.ts
@@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
- return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
+ return {
+ integrationId: integration.id,
+ integrationName: integration.name,
+ integrationKind: integration.kind,
+ sessions: data,
+ };
});
}),
});
diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts
index bae8ba9fd..86124164c 100644
--- a/packages/api/src/router/widgets/notifications.ts
+++ b/packages/api/src/router/widgets/notifications.ts
@@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(ctx.integrations, async (integration) => {
- const innerHandler = notificationsRequestHandler.handler(integration, {});
- const { data, timestamp } = await innerHandler.getDataAsync();
+ return await settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
+ const innerHandler = notificationsRequestHandler.handler(integration, {});
+ const { data, timestamp } = await innerHandler.getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- data,
- };
- });
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ data,
+ error: undefined as string | undefined,
+ };
+ },
+ {
+ fallback: (integration, error) => ({
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: new Date(0),
+ },
+ data: [],
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ throwOnAllFailures: true,
+ },
+ );
}),
});
diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts
index 5e43efe3e..56fa10fd1 100644
--- a/packages/api/src/router/widgets/rssFeed.ts
+++ b/packages/api/src/router/widgets/rssFeed.ts
@@ -34,6 +34,11 @@ export const rssFeedRouter = createTRPCRouter({
}),
);
+ const failures = settled.filter((result) => result.status === "rejected");
+ if (settled.length > 0 && failures.length === settled.length) {
+ throw failures[0]?.reason;
+ }
+
return settled
.flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries;
diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts
index c37468752..a0f38c7fa 100644
--- a/packages/api/src/router/widgets/smart-home.ts
+++ b/packages/api/src/router/widgets/smart-home.ts
@@ -1,8 +1,12 @@
import { z } from "zod/v4";
+import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
-import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state";
+import {
+ smartHomeEntityStateRequestHandler,
+ toSafeEntityDetails,
+} from "@homarr/request-handler/smart-home-entity-state";
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({
.query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync();
- return data;
+ return data.state;
+ }),
+ entityDetails: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')",
+ },
+ })
+ .input(z.object({ entityId: z.string() }))
+ .concat(createSmartHomeIntegrationMiddleware("query"))
+ .query(async ({ ctx: { integration }, input }) => {
+ const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
+ const { data } = await innerHandler.getDataAsync();
+ return toSafeEntityDetails(data);
}),
switchEntity: protectedProcedure
.meta({
@@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
-
- return success;
+ if (!success) {
+ throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
+ }
+ return true;
}),
executeAutomation: protectedProcedure
.meta({
@@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
- await client.triggerAutomationAsync(input.automationId);
+ const success = await client.triggerAutomationAsync(input.automationId);
+ if (!success) {
+ throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" });
+ }
+ return true;
}),
});
diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts
index ef60ce320..84f680382 100644
--- a/packages/api/src/router/widgets/timetable.ts
+++ b/packages/api/src/router/widgets/timetable.ts
@@ -1,36 +1,90 @@
+import { TRPCError } from "@trpc/server";
+import { parse } from "superjson";
import z from "zod";
+import type { Session } from "@homarr/auth";
+import type { Database } from "@homarr/db";
+import { and, eq } from "@homarr/db";
+import { boards, items } from "@homarr/db/schema";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable";
+import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+import { throwIfActionForbiddenAsync } from "../board/board-access";
-const baseUrlSchema = z.string().url();
+const baseUrlSchema = z.string().url().max(2_048);
+const timetableSourceSchema = z.object({
+ baseUrl: baseUrlSchema,
+ itemId: z.string().max(100).optional(),
+ boardId: z.string().max(100).optional(),
+});
+
+const resolveTimetableBaseUrlAsync = async (
+ ctx: { db: Database; session: Session | null },
+ input: z.infer,
+ allowBoardConfiguration = false,
+) => {
+ const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
+ if (allowBoardConfiguration) {
+ if (input.boardId) {
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
+ return requestedBaseUrl;
+ }
+ if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
+ }
+ return DEFAULT_TIMETABLE_BASE_URL;
+ }
+ if (!input.itemId) {
+ if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
+ }
+ return DEFAULT_TIMETABLE_BASE_URL;
+ }
+
+ const item = await ctx.db.query.items.findFirst({
+ where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")),
+ });
+ if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
+
+ await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
+ const options = parse>(item.options);
+ const savedBaseUrl = normalizeTimetableBaseUrl(
+ typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
+ );
+ if (requestedBaseUrl !== savedBaseUrl) {
+ throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
+ }
+ return savedBaseUrl;
+};
export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure
.input(
- z.object({
- baseUrl: baseUrlSchema,
- stationId: z.string(),
+ timetableSourceSchema.extend({
+ stationId: z.string().min(1).max(200),
limit: z.number().int().min(1).max(100),
}),
)
- .query(async ({ input }) => {
- const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync();
+ .query(async ({ ctx, input }) => {
+ const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
+ const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
+ const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
}),
searchStations: publicProcedure
.input(
- z.object({
- baseUrl: baseUrlSchema,
- query: z.string(),
+ timetableSourceSchema.extend({
+ query: z.string().max(200),
}),
)
- .query(async ({ input }) => {
- const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
+ .query(async ({ ctx, input }) => {
+ const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
+ const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
+ const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
}),
});
diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts
index 407e4986f..79ee5308b 100644
--- a/packages/api/src/router/widgets/vpn.ts
+++ b/packages/api/src/router/widgets/vpn.ts
@@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => {
- return await settleIntegrationQueries(ctx.integrations, async (integration) => {
- const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
- return {
- integration: {
- id: integration.id,
- name: integration.name,
- kind: integration.kind,
- updatedAt: timestamp,
- },
- summary: data,
- };
- });
+ return await settleIntegrationQueries(
+ ctx.integrations,
+ async (integration) => {
+ const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
+ return {
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: timestamp,
+ },
+ summary: data as typeof data | null,
+ error: undefined as string | undefined,
+ };
+ },
+ {
+ fallback: (integration, error) => ({
+ integration: {
+ id: integration.id,
+ name: integration.name,
+ kind: integration.kind,
+ updatedAt: new Date(0),
+ },
+ summary: null,
+ error: error instanceof Error ? error.message : String(error),
+ }),
+ throwOnAllFailures: true,
+ },
+ );
}),
});
diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts
index a9dbff5ba..773117877 100644
--- a/packages/api/src/settle-integrations.ts
+++ b/packages/api/src/settle-integrations.ts
@@ -11,6 +11,7 @@ interface IntegrationLike {
interface Options {
fallback?: (integration: TIntegration, error: unknown) => TResult;
+ throwOnAllFailures?: boolean;
}
export async function settleIntegrationQueries(
@@ -37,15 +38,18 @@ export async function settleIntegrationQueries 0) {
+ if (
+ errors.length > 0 &&
+ (results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
+ ) {
throw errors[0];
}
diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts
index 09f80bc31..70024da7f 100644
--- a/packages/api/src/test/settle-integrations.spec.ts
+++ b/packages/api/src/test/settle-integrations.spec.ts
@@ -47,4 +47,21 @@ describe("settleIntegrationQueries", () => {
}),
).resolves.toEqual(["First", "Second"]);
});
+
+ test("can retain partial fallbacks while rejecting total failure", async () => {
+ const error = new Error("Integration unavailable");
+ const options = {
+ fallback: (integration: (typeof integrations)[number]) => integration.name,
+ throwOnAllFailures: true,
+ };
+
+ await expect(
+ settleIntegrationQueries(
+ integrations,
+ (integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)),
+ options,
+ ),
+ ).resolves.toEqual(["First", "Second"]);
+ await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
+ });
});
diff --git a/packages/boards/src/updater.spec.ts b/packages/boards/src/updater.spec.ts
new file mode 100644
index 000000000..1f78f7e97
--- /dev/null
+++ b/packages/boards/src/updater.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { getBoardSaveScopeId, shouldRefetchAfterSaveError } from "./updater";
+
+describe("board persistence coordination", () => {
+ it("uses one mutation scope for every save of the same board", () => {
+ expect(getBoardSaveScopeId("board-1")).toBe(getBoardSaveScopeId("board-1"));
+ expect(getBoardSaveScopeId("board-1")).not.toBe(getBoardSaveScopeId("board-2"));
+ });
+
+ it("only refetches after a failed save when no newer local board exists", () => {
+ const failedBoard = { id: "board-1", items: ["first"] };
+ const newerBoard = { id: "board-1", items: ["first", "second"] };
+
+ expect(shouldRefetchAfterSaveError(failedBoard, failedBoard)).toBe(true);
+ expect(shouldRefetchAfterSaveError(newerBoard, failedBoard)).toBe(false);
+ });
+});
diff --git a/packages/boards/src/updater.ts b/packages/boards/src/updater.ts
index 49a301a8c..6763c48ce 100644
--- a/packages/boards/src/updater.ts
+++ b/packages/boards/src/updater.ts
@@ -5,13 +5,19 @@ import { useCallback } from "react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
+type Board = RouterOutputs["board"]["getHomeBoard"];
+type UpdateCallback = (previous: Board) => Board;
+
let boardName: string | null = null;
export const updateBoardName = (name: string | null) => {
boardName = name;
};
-type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"];
+export const getBoardSaveScopeId = (boardId: string) => `board-save:${boardId}`;
+
+export const shouldRefetchAfterSaveError = (currentBoard: T | undefined, failedBoard: T) =>
+ currentBoard === failedBoard;
export const useUpdateBoard = () => {
const utils = clientApi.useUtils();
@@ -21,9 +27,13 @@ export const useUpdateBoard = () => {
if (!boardName) {
throw new Error("Board name is not set");
}
- utils.board.getBoardByName.setData({ name: boardName }, (previous) =>
- previous ? updaterWithoutUndefined(previous) : previous,
- );
+ let updatedBoard: Board | undefined;
+ utils.board.getBoardByName.setData({ name: boardName }, (previous) => {
+ if (!previous) return previous;
+ updatedBoard = updaterWithoutUndefined(previous);
+ return updatedBoard;
+ });
+ return updatedBoard;
},
[utils],
);
@@ -32,3 +42,37 @@ export const useUpdateBoard = () => {
updateBoard,
};
};
+
+interface PersistBoardOptions {
+ onError?: (error: unknown) => void;
+}
+
+export const usePersistBoard = ({ id, name }: Pick) => {
+ const utils = clientApi.useUtils();
+ const { updateBoard } = useUpdateBoard();
+ const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({
+ scope: { id: getBoardSaveScopeId(id) },
+ });
+
+ const updateAndPersistBoard = useCallback(
+ (updater: UpdateCallback, options?: PersistBoardOptions) => {
+ const updatedBoard = updateBoard(updater);
+ if (!updatedBoard) return undefined;
+ const persistedBoard = utils.board.getBoardByName.getData({ name });
+ if (!persistedBoard) return undefined;
+
+ void saveBoardAsync(persistedBoard).catch((error: unknown) => {
+ const currentBoard = utils.board.getBoardByName.getData({ name });
+ if (shouldRefetchAfterSaveError(currentBoard, persistedBoard)) {
+ void utils.board.getBoardByName.invalidate({ name });
+ }
+ options?.onError?.(error);
+ });
+
+ return persistedBoard;
+ },
+ [name, saveBoardAsync, updateBoard, utils],
+ );
+
+ return { updateAndPersistBoard };
+};
diff --git a/packages/common/src/bounded-response.ts b/packages/common/src/bounded-response.ts
new file mode 100644
index 000000000..d1432fb39
--- /dev/null
+++ b/packages/common/src/bounded-response.ts
@@ -0,0 +1,45 @@
+interface ReadableResponse {
+ headers: { get: (name: string) => string | null };
+ body: {
+ getReader: () => {
+ read: () => Promise<{ done: boolean; value?: Uint8Array }>;
+ cancel: () => Promise;
+ };
+ } | null;
+}
+
+export async function readBoundedJsonResponseAsync(
+ response: ReadableResponse,
+ maxBytes: number,
+ label = "Response",
+): Promise {
+ const declaredLength = Number(response.headers.get("content-length"));
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
+ throw new Error(`${label} exceeds ${maxBytes} bytes`);
+ }
+
+ if (!response.body) return null;
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let byteLength = 0;
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ if (!value) continue;
+ byteLength += value.byteLength;
+ if (byteLength > maxBytes) {
+ await reader.cancel();
+ throw new Error(`${label} exceeds ${maxBytes} bytes`);
+ }
+ chunks.push(value);
+ }
+
+ const body = new Uint8Array(byteLength);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ return JSON.parse(new TextDecoder().decode(body)) as unknown;
+}
diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts
index d6e62d5b9..502ae5f6b 100644
--- a/packages/common/src/server.ts
+++ b/packages/common/src/server.ts
@@ -2,3 +2,4 @@ export * from "./security";
export * from "./encryption";
export * from "./request";
export * from "./errors";
+export * from "./bounded-response";
diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts
index 441d7fe3a..2dee921d0 100644
--- a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts
+++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts
@@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration {
const statsByLibrary = await Promise.all(
libraries.map(async (library) => ({
+ library,
mediaType: library.mediaType,
stats: await this.getLibraryStatsAsync(library.id),
})),
@@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration {
totalPodcasts: counts.totalPodcasts,
totalListeningTimeSeconds: listeningStats.totalTime,
activeSessions,
+ libraries: statsByLibrary.map(({ library, stats }) => ({
+ id: library.id,
+ name: library.name,
+ mediaType: library.mediaType,
+ totalItems: stats.totalItems,
+ totalDurationSeconds: stats.totalDuration ?? null,
+ totalSizeBytes: stats.totalSize ?? null,
+ })),
};
}
diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts
index 495a24c08..4c853c139 100644
--- a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts
+++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts
@@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData {
totalPodcasts: number;
totalListeningTimeSeconds: number;
activeSessions: number;
+ libraries?: {
+ id: string;
+ name: string;
+ mediaType: "book" | "podcast";
+ totalItems: number;
+ totalDurationSeconds: number | null;
+ totalSizeBytes: number | null;
+ }[];
}
export type AudiobookshelfLibrary = z.infer;
diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts
index 9496482d6..ce4892d10 100644
--- a/packages/integrations/src/immich/immich-integration.ts
+++ b/packages/integrations/src/immich/immich-integration.ts
@@ -36,7 +36,6 @@ export interface ImmichAlbum {
export interface ImmichAsset {
id: string;
- originalPath: string;
thumbhash: string | null;
fileModifiedAt: string;
fileCreatedAt: string;
@@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration {
thumbhash: asset.thumbhash,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
- originalPath: asset.originalPath,
updatedAt: asset.updatedAt,
publicLink,
};
diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts
index f172cec36..71b0652fc 100644
--- a/packages/integrations/src/index.ts
+++ b/packages/integrations/src/index.ts
@@ -61,6 +61,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal
export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types";
export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types";
export type { StreamSession } from "./interfaces/media-server/media-server-types";
+export type { EntityState } from "./interfaces/smart-home/smart-home-types";
export type {
TdarrQueue,
TdarrPieSegment,
diff --git a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts
index efc113dc7..2208c4953 100644
--- a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts
+++ b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts
@@ -1,4 +1,4 @@
-interface EntityState {
+export interface EntityState {
attributes: Record;
entity_id: string;
last_changed: Date;
diff --git a/packages/integrations/src/overseerr/overseerr-integration.spec.ts b/packages/integrations/src/overseerr/overseerr-integration.spec.ts
new file mode 100644
index 000000000..1fc4484d0
--- /dev/null
+++ b/packages/integrations/src/overseerr/overseerr-integration.spec.ts
@@ -0,0 +1,30 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import { OverseerrIntegration } from "./overseerr-integration";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+const integration = new OverseerrIntegration({
+ id: "overseerr-1",
+ name: "Overseerr",
+ url: "https://overseerr.example.com",
+ externalUrl: null,
+ decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
+});
+
+describe("Overseerr media request actions", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it.each([
+ ["approve", () => integration.approveRequestAsync(42)],
+ ["decline", () => integration.declineRequestAsync(42)],
+ ])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
+ mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
+
+ await expect(run()).rejects.toThrow("Response from integration did not indicate success");
+ });
+});
diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts
index 9de482008..226cebbf3 100644
--- a/packages/integrations/src/overseerr/overseerr-integration.ts
+++ b/packages/integrations/src/overseerr/overseerr-integration.ts
@@ -1,8 +1,8 @@
import { z } from "zod/v4";
+import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createLogger } from "@homarr/core/infrastructure/logs";
-import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
@@ -253,49 +253,27 @@ export class OverseerrIntegration
public async approveRequestAsync(requestId: number): Promise {
logger.info("Approving media request", { requestId, integration: this.integration.name });
- await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
- }).then((response) => {
- if (!response.ok) {
- logger.error(
- new ErrorWithMetadata("Failed to approve media request", {
- requestId,
- integration: this.integration.name,
- reason: `${response.status} ${response.statusText}`,
- url: response.url,
- }),
- );
- }
-
- logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
});
+ if (!response.ok) throw new ResponseError(response);
+ logger.info("Successfully approved media request", { requestId, integration: this.integration.name });
}
public async declineRequestAsync(requestId: number): Promise {
logger.info("Declining media request", { requestId, integration: this.integration.name });
- await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
+ const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), {
method: "POST",
headers: {
"X-Api-Key": this.getSecretValue("apiKey"),
},
- }).then((response) => {
- if (!response.ok) {
- logger.error(
- new ErrorWithMetadata("Failed to decline media request", {
- requestId,
- integration: this.integration.name,
- reason: `${response.status} ${response.statusText}`,
- url: response.url,
- }),
- );
- }
-
- logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
});
+ if (!response.ok) throw new ResponseError(response);
+ logger.info("Successfully declined media request", { requestId, integration: this.integration.name });
}
private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise {
diff --git a/packages/integrations/src/patchmon/patchmon-types.ts b/packages/integrations/src/patchmon/patchmon-types.ts
index 1f912d6c9..96c3db293 100644
--- a/packages/integrations/src/patchmon/patchmon-types.ts
+++ b/packages/integrations/src/patchmon/patchmon-types.ts
@@ -52,9 +52,9 @@ const mapOsDistributionEntry = (
osVersion: entry.os_version,
});
-export const parsePatchMonStatsResponseAsync = async (
- response: { json: () => Promise },
-): Promise> => {
+export const parsePatchMonStatsResponseAsync = async (response: {
+ json: () => Promise;
+}): Promise> => {
let json: unknown;
try {
json = await response.json();
@@ -82,7 +82,5 @@ export const mapPatchMonStats = (data: z.infer b.count - a.count),
+ osDistribution: [...data.os_distribution].map(mapOsDistributionEntry).sort((a, b) => b.count - a.count),
});
diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts
index 64e046256..2a7284b64 100644
--- a/packages/integrations/src/plex/test/plex-integration.spec.ts
+++ b/packages/integrations/src/plex/test/plex-integration.spec.ts
@@ -137,4 +137,4 @@ describe("PlexIntegration.getMediaReleasesAsync", () => {
},
]);
});
-});
\ No newline at end of file
+});
diff --git a/packages/integrations/src/traefik/traefik-bounds.spec.ts b/packages/integrations/src/traefik/traefik-bounds.spec.ts
new file mode 100644
index 000000000..1f14aff89
--- /dev/null
+++ b/packages/integrations/src/traefik/traefik-bounds.spec.ts
@@ -0,0 +1,12 @@
+import { Response } from "undici";
+import { describe, expect, it } from "vitest";
+
+import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
+
+describe("readBoundedTraefikJsonAsync", () => {
+ it("rejects a response that exceeds its byte budget", async () => {
+ const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
+
+ await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
+ });
+});
diff --git a/packages/integrations/src/traefik/traefik-bounds.ts b/packages/integrations/src/traefik/traefik-bounds.ts
new file mode 100644
index 000000000..99a6c8b1b
--- /dev/null
+++ b/packages/integrations/src/traefik/traefik-bounds.ts
@@ -0,0 +1,12 @@
+import type { Response } from "undici";
+
+import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
+
+export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024;
+
+export async function readBoundedTraefikJsonAsync(
+ response: Response,
+ maxBytes = MAX_TRAEFIK_RESPONSE_BYTES,
+): Promise {
+ return await readBoundedJsonResponse(response, maxBytes, "Traefik response");
+}
diff --git a/packages/integrations/src/traefik/traefik-integration.spec.ts b/packages/integrations/src/traefik/traefik-integration.spec.ts
new file mode 100644
index 000000000..ced92e33a
--- /dev/null
+++ b/packages/integrations/src/traefik/traefik-integration.spec.ts
@@ -0,0 +1,43 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import { TraefikIntegration } from "./traefik-integration";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+const integration = new TraefikIntegration({
+ id: "traefik-1",
+ name: "Traefik",
+ url: "https://traefik.example.com",
+ externalUrl: null,
+ decryptedSecrets: [],
+});
+
+describe("TraefikIntegration response bounds", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockFetch.mockImplementation((url) => {
+ const path = new URL(url.toString()).pathname;
+ if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" }));
+ if (path === "/api/http/routers") {
+ return Promise.resolve(
+ jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))),
+ );
+ }
+ return Promise.resolve(jsonResponse([]));
+ });
+ });
+
+ it("caps detailed resource allocation while retaining bounded summary counts", async () => {
+ const result = await integration.getDashboardDataAsync();
+
+ expect(result.resources).toHaveLength(200);
+ expect(result.resources.at(-1)?.name).toBe("router-199");
+ expect(result.http.routers.total).toBe(250);
+ });
+});
+
+const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts
index 0dc8cf57f..2e0e72b4c 100644
--- a/packages/integrations/src/traefik/traefik-integration.ts
+++ b/packages/integrations/src/traefik/traefik-integration.ts
@@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h
import type { IntegrationTestingInput } from "../base/integration";
import { Integration } from "../base/integration";
import type { TestingResult } from "../base/test-connection/test-connection-service";
+import { readBoundedTraefikJsonAsync } from "./traefik-bounds";
import type {
TraefikDashboardData,
TraefikProtocolSummary,
TraefikResource,
+ TraefikResourceDetail,
TraefikResourceStatus,
TraefikResourceSummary,
} from "./traefik-types";
@@ -24,6 +26,8 @@ type ResourcePath =
| "/api/udp/services"
| "/api/entrypoints";
+const MAX_RESOURCE_DETAILS = 200;
+
export class TraefikIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise {
const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() });
@@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration {
throw new ResponseError(response);
}
- traefikVersionSchema.parse(await response.json());
+ traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return { success: true };
}
@@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration {
this.getResourcesAsync("/api/udp/routers"),
this.getResourcesAsync("/api/udp/services"),
]);
+
+ const allResults = [
+ versionResult,
+ entryPointsResult,
+ httpRoutersResult,
+ httpServicesResult,
+ httpMiddlewaresResult,
+ tcpRoutersResult,
+ tcpServicesResult,
+ tcpMiddlewaresResult,
+ udpRoutersResult,
+ udpServicesResult,
+ ];
+ if (allResults.every((result) => result.status === "rejected")) {
+ throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed");
+ }
+
+ const endpointResults = [
+ ["version", versionResult],
+ ["entrypoints", entryPointsResult],
+ ["http/routers", httpRoutersResult],
+ ["http/services", httpServicesResult],
+ ["http/middlewares", httpMiddlewaresResult],
+ ["tcp/routers", tcpRoutersResult],
+ ["tcp/services", tcpServicesResult],
+ ["tcp/middlewares", tcpMiddlewaresResult],
+ ["udp/routers", udpRoutersResult],
+ ["udp/services", udpServicesResult],
+ ] as const;
+ const failedEndpoints = endpointResults
+ .filter(([, result]) => result.status === "rejected")
+ .map(([endpoint]) => endpoint);
const version = versionResult.status === "fulfilled" ? versionResult.value : null;
const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : [];
const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : [];
@@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration {
const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : [];
const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : [];
+ const resources: TraefikResourceDetail[] = [];
+ const resourceGroups = [
+ ["http", "router", httpRouters],
+ ["http", "service", httpServices],
+ ["http", "middleware", httpMiddlewares],
+ ["tcp", "router", tcpRouters],
+ ["tcp", "service", tcpServices],
+ ["tcp", "middleware", tcpMiddlewares],
+ ["udp", "router", udpRouters],
+ ["udp", "service", udpServices],
+ ] as const;
+ for (const [protocol, type, entries] of resourceGroups) {
+ const remaining = MAX_RESOURCE_DETAILS - resources.length;
+ if (remaining === 0) break;
+ resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining)));
+ }
+
return {
version,
- entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
+ entryPoints: entryPoints
+ .slice(0, MAX_RESOURCE_DETAILS)
+ .map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`),
+ resources,
+ failedEndpoints,
http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares),
tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares),
udp: {
@@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration {
};
}
+ private createResourceDetails(
+ protocol: TraefikResourceDetail["protocol"],
+ type: TraefikResourceDetail["type"],
+ resources: TraefikResource[],
+ ): TraefikResourceDetail[] {
+ return resources.map((resource, index) => ({
+ protocol,
+ type,
+ name: resource.name ?? `${type}-${index + 1}`,
+ provider: resource.provider ?? null,
+ status: this.normalizeStatus(resource.status),
+ }));
+ }
+
private async getVersionAsync() {
const response = await this.fetchOkAsync("/api/version");
- const version = traefikVersionSchema.parse(await response.json());
+ const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response));
return version.Version ?? null;
}
private async getResourcesAsync(path: ResourcePath) {
const response = await this.fetchOkAsync(path);
- return traefikResourcesSchema.parse(await response.json());
+ return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response));
}
private createProtocolSummary(
@@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration {
const response = await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
timeout: 10_000,
+ bodyTimeout: 10_000,
});
if (!response.ok) {
diff --git a/packages/integrations/src/traefik/traefik-types.ts b/packages/integrations/src/traefik/traefik-types.ts
index 5874ef41b..188941465 100644
--- a/packages/integrations/src/traefik/traefik-types.ts
+++ b/packages/integrations/src/traefik/traefik-types.ts
@@ -31,9 +31,19 @@ export interface TraefikProtocolSummary {
middlewares: TraefikResourceSummary;
}
+export interface TraefikResourceDetail {
+ protocol: "http" | "tcp" | "udp";
+ type: "router" | "service" | "middleware";
+ name: string;
+ provider: string | null;
+ status: TraefikResourceStatus;
+}
+
export interface TraefikDashboardData {
version: string | null;
entryPoints: string[];
+ resources: TraefikResourceDetail[];
+ failedEndpoints: string[];
http: TraefikProtocolSummary;
tcp: TraefikProtocolSummary;
udp: Omit;
diff --git a/packages/request-handler/src/smart-home-entity-state.spec.ts b/packages/request-handler/src/smart-home-entity-state.spec.ts
new file mode 100644
index 000000000..bee81d2de
--- /dev/null
+++ b/packages/request-handler/src/smart-home-entity-state.spec.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from "vitest";
+
+import { toSafeEntityDetails } from "./smart-home-entity-state";
+
+describe("toSafeEntityDetails", () => {
+ it("keeps display attributes and removes sensitive Home Assistant metadata", () => {
+ const details = toSafeEntityDetails({
+ entity_id: "camera.front_door",
+ state: "streaming",
+ last_changed: new Date("2026-01-01T00:00:00Z"),
+ last_updated: new Date("2026-01-01T00:00:01Z"),
+ attributes: {
+ friendly_name: "Front door",
+ icon: "mdi:camera",
+ access_token: "secret-camera-token",
+ latitude: 48.85,
+ },
+ });
+
+ expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" });
+ expect(details.attributes).not.toHaveProperty("access_token");
+ expect(details.attributes).not.toHaveProperty("latitude");
+ });
+});
diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts
index 3c8851522..1d0d2471c 100644
--- a/packages/request-handler/src/smart-home-entity-state.ts
+++ b/packages/request-handler/src/smart-home-entity-state.ts
@@ -1,10 +1,29 @@
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
+import type { EntityState } from "@homarr/integrations";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
+const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const;
+
+export const toSafeEntityDetails = (entity: EntityState) => {
+ const attributes: Partial> = {};
+ for (const key of safeAttributeKeys) {
+ const value = entity.attributes[key];
+ if (typeof value === "string") attributes[key] = value;
+ }
+
+ return {
+ attributes,
+ entity_id: entity.entity_id,
+ last_changed: entity.last_changed,
+ last_updated: entity.last_updated,
+ state: entity.state,
+ };
+};
+
export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler<
- string,
+ EntityState,
IntegrationKindByCategory<"smartHomeServer">,
{ entityId: string }
>({
@@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`);
}
- return result.data.state;
+ return result.data;
},
});
diff --git a/packages/request-handler/src/timetable-url.ts b/packages/request-handler/src/timetable-url.ts
new file mode 100644
index 000000000..f7241a542
--- /dev/null
+++ b/packages/request-handler/src/timetable-url.ts
@@ -0,0 +1,26 @@
+import type { Response } from "undici";
+
+import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
+
+export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch";
+export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024;
+
+export const normalizeTimetableBaseUrl = (baseUrl: string) => {
+ if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long");
+
+ const url = new URL(baseUrl);
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
+ throw new Error("Timetable base URL must use HTTP or HTTPS");
+ }
+ if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials");
+ if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment");
+
+ return url.toString().replace(/\/$/, "");
+};
+
+export async function readBoundedTimetableJsonAsync(
+ response: Response,
+ maxBytes = MAX_TIMETABLE_RESPONSE_BYTES,
+): Promise {
+ return await readBoundedJsonResponse(response, maxBytes, "Timetable response");
+}
diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts
new file mode 100644
index 000000000..cb08cb313
--- /dev/null
+++ b/packages/request-handler/src/timetable.spec.ts
@@ -0,0 +1,79 @@
+import { Response } from "undici";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+
+import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
+import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
+
+vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
+
+const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+
+beforeEach(() => vi.clearAllMocks());
+
+describe("normalizeTimetableBaseUrl", () => {
+ it("normalizes an HTTP endpoint", () => {
+ expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch");
+ });
+
+ it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])(
+ "rejects unsafe base URL %s",
+ (url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(),
+ );
+});
+
+describe("timetable response bounds", () => {
+ it("rejects a response once its byte budget is exceeded", async () => {
+ const response = new Response(JSON.stringify({ value: "x".repeat(100) }));
+ await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes");
+ });
+
+ it("caps and caches station results and rejects redirects", async () => {
+ mockFetch.mockResolvedValue(
+ new Response(
+ JSON.stringify(
+ Array.from({ length: 125 }, (_, index) => ({
+ id: String(index),
+ label: `Station ${index}`,
+ iconclass: "train",
+ })),
+ ),
+ ),
+ );
+ const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" };
+
+ const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
+ const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync();
+
+ expect(first.data).toHaveLength(100);
+ expect(second.data).toHaveLength(100);
+ expect(mockFetch).toHaveBeenCalledOnce();
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }),
+ );
+ });
+
+ it("never caches more departures than requested", async () => {
+ mockFetch.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ connections: Array.from({ length: 5 }, (_, index) => ({
+ time: `2026-01-01T00:0${index}:00Z`,
+ line: "1",
+ color: "ffffff",
+ terminal: { name: `Stop ${index}` },
+ dep_delay: "0",
+ })),
+ }),
+ ),
+ );
+
+ const result = await timetableGetTimetableRequestHandler
+ .handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 })
+ .getDataAsync();
+
+ expect(result.data.entries).toHaveLength(2);
+ });
+});
diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts
index 2be7355d8..b55a688f7 100644
--- a/packages/request-handler/src/timetable.ts
+++ b/packages/request-handler/src/timetable.ts
@@ -4,6 +4,7 @@ import { ResponseError } from "@homarr/common/server";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
+import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
export interface Station {
id: string;
@@ -31,6 +32,9 @@ export interface Timetable {
}
const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"];
+const MAX_STATION_RESULTS = 100;
+const MAX_TIMETABLE_ENTRIES = 100;
+const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
@@ -53,7 +57,7 @@ export const timetableGetTimetableRequestHandler = createRequestHandler<
});
const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => {
- const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`);
+ const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`);
for (const [key, value] of Object.entries(queryParams)) {
url.searchParams.set(key, value.toString());
}
@@ -63,10 +67,12 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record => {
const response = await fetchWithTrustedCertificatesAsync(
buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
+ timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
- const data = await searchSchema.parseAsync(await response.json());
+ const body = await readBoundedTimetableJsonAsync(response);
+ const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
return data
.filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
.map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
@@ -99,10 +105,15 @@ const getTimetableAsync = async (
date,
time,
}),
+ timetableFetchOptions,
);
if (!response.ok) throw new ResponseError(response);
- const data = await timetableSchema.parseAsync(await response.json());
+ const body = await readBoundedTimetableJsonAsync(response);
+ const envelope = timetableEnvelopeSchema.parse(body);
+ const data = await timetableSchema.parseAsync({
+ connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)),
+ });
return {
stationId: options.stationId,
timestamp: now,
@@ -145,6 +156,8 @@ const timetableSchema = z.object({
),
});
+const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) });
+
const searchSchema = z.array(
z.object({
id: z.string().optional(),
diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts
index ebca0fbeb..97182ab11 100644
--- a/packages/request-handler/src/vpn.ts
+++ b/packages/request-handler/src/vpn.ts
@@ -1,28 +1,16 @@
-import { createLogger } from "@homarr/core/infrastructure/logs";
import type { IntegrationKindByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { createIntegrationRequestHandler } from "./lib/integration-request-handler";
-const logger = createLogger({ module: "vpnRequestHandler" });
-
export const vpnSummaryHandler = createIntegrationRequestHandler<
- VpnSummary | null,
+ VpnSummary,
IntegrationKindByCategory<"vpn">,
Record
>({
async requestAsync(integration, _input) {
- try {
- const integrationInstance = await createIntegrationAsync(integration);
- return await integrationInstance.getSummaryAsync();
- } catch (error) {
- logger.warn("Failed to fetch VPN summary, marking as unavailable", {
- integrationId: integration.id,
- integrationKind: integration.kind,
- error: error instanceof Error ? error.message : String(error),
- });
- return null;
- }
+ const integrationInstance = await createIntegrationAsync(integration);
+ return await integrationInstance.getSummaryAsync();
},
});
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index de211bf66..e8a9192a7 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -1632,12 +1632,33 @@
"error": "Error",
"loading": "Loading",
"idle": "Idle"
+ },
+ "notification": {
+ "saveError": {
+ "title": "Unable to save widget",
+ "message": "The widget changes were rolled back"
+ }
}
},
"create": {
"title": "Choose item to add",
"search": "Filter items",
- "addToBoard": "Add to board"
+ "addToBoard": "Add to board",
+ "addAtPosition": "Add item here",
+ "integrationAvailable": "Connected integration available",
+ "notification": {
+ "error": {
+ "title": "Unable to add item",
+ "message": "The item could not be added to this board"
+ }
+ }
+ },
+ "advancedFocus": {
+ "open": "Open advanced view",
+ "close": "Close advanced view",
+ "pin": "Keep advanced view open",
+ "pinned": "Advanced view is pinned",
+ "dialog": "{widget} advanced view"
},
"moveResize": {
"title": "Move / resize item",
@@ -1791,6 +1812,26 @@
"showBroadcastMessage": {
"label": "Show broadcast messages"
}
+ },
+ "noProjectSelected": "No project selected",
+ "runnerStatus": "Runner: {status}",
+ "updatedAt": "Updated {date}",
+ "metric": {
+ "running": "Running",
+ "completed": "Done",
+ "failed": "Failed",
+ "canceled": "Canceled",
+ "download": "Download",
+ "upload": "Upload"
+ },
+ "status": {
+ "running": "Running",
+ "completed": "Completed",
+ "failed": "Failed",
+ "canceled": "Canceled",
+ "stopped": "Stopped",
+ "idle": "Idle",
+ "unknown": "Unknown"
}
},
"dnsHoleSummary": {
@@ -1825,6 +1866,11 @@
"dnsQueriesToday": "Queries today",
"domainsBeingBlocked": "Domains on blocklist"
},
+ "status": {
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "unknown": "Unknown"
+ },
"domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked"
},
"dnsHoleControls": {
@@ -1850,7 +1896,8 @@
}
},
"error": {
- "internalServerError": "Failed to control DNS Hole"
+ "internalServerError": "Failed to control DNS Hole",
+ "bulkActionsFailed": "{count, plural, one {# action failed} other {# actions failed}}"
},
"controls": {
"enableAll": "Enable All",
@@ -2014,6 +2061,8 @@
"height": "Height"
},
"placeholder": "Start writing your notes",
+ "saveFailed": "Unable to save note",
+ "documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}",
"dismiss": {
"title": "Dismiss changes?",
"message": "You have unsaved changes in your notebook. Are you sure you want to discard them?",
@@ -2043,13 +2092,23 @@
"noNotes": "No notes found",
"integrationRequired": "Select an Anchor integration first",
"updatedAt": "Updated {date}",
+ "createdAt": "Created {date}",
"untitled": "Untitled",
"edit": "Edit",
"save": "Save",
"cancel": "Cancel",
"readOnlyViewer": "You have viewer access to this note. Editing is disabled.",
"saveForbidden": "You do not have permission to edit this note.",
- "saveFailed": "Failed to save this note. Please try again."
+ "saveFailed": "Failed to save this note. Please try again.",
+ "permission": {
+ "owner": "Owner",
+ "editor": "Editor",
+ "viewer": "Viewer"
+ },
+ "status": {
+ "pinned": "Pinned",
+ "archived": "Archived"
+ }
},
"iframe": {
"name": "iFrame",
@@ -2108,6 +2167,18 @@
"clickable": {
"label": "Clickable"
}
+ },
+ "advanced": {
+ "entityId": "Entity ID: {id}",
+ "lastUpdated": "Updated {date}",
+ "attribute": {
+ "unit": "Unit",
+ "deviceClass": "Device class",
+ "icon": "Icon"
+ }
+ },
+ "error": {
+ "toggleFailed": "Unable to update entity"
}
},
"smartHome-executeAutomation": {
@@ -2123,6 +2194,13 @@
},
"spotlightAction": {
"run": "Run {name}"
+ },
+ "advanced": {
+ "lastExecuted": "Last run at {time}",
+ "automationId": "Automation ID: {id}"
+ },
+ "error": {
+ "executeFailed": "Unable to run automation"
}
},
"stockPrice": {
@@ -2498,7 +2576,13 @@
"runtime": "Runtime",
"load": "Load",
"input": "Input",
- "output": "Output"
+ "output": "Output",
+ "batteryVoltage": "Battery voltage",
+ "power": "Power",
+ "temperature": "Temperature",
+ "manufacturer": "Manufacturer",
+ "model": "Model",
+ "serial": "Serial"
},
"error": {
"internalServerError": "Failed to fetch UPS data"
@@ -2755,6 +2839,11 @@
"target": "Target codec"
}
}
+ },
+ "summary": {
+ "sessions": "{count, plural, one {# session} other {# sessions}}",
+ "playing": "{count} playing",
+ "transcoding": "{count} transcoding"
}
},
"paperlessNgx": {
@@ -3037,7 +3126,16 @@
}
},
"description": "Slideshow of pictures from an Immich album or your full library",
- "noPhotos": "No photos found"
+ "noPhotos": "No photos found",
+ "albumPhoto": "Album photo",
+ "actions": {
+ "previousPhoto": "Previous photo",
+ "nextPhoto": "Next photo",
+ "pauseSlideshow": "Pause slideshow",
+ "resumeSlideshow": "Resume slideshow",
+ "toggleSlideshow": "Pause or resume slideshow",
+ "photo": "Photo {number}"
+ }
},
"downloads": {
"name": "Download Client",
@@ -3610,6 +3708,13 @@
"error": {
"internalServerError": "Unable to get data from firewall"
},
+ "versionUnknown": "Unknown version",
+ "metricAccessible": "{metric}: {value}%, {status}",
+ "status": {
+ "normal": "Normal",
+ "warning": "Warning",
+ "critical": "Critical"
+ },
"option": {
"interfaces": "Network interfaces to display"
},
@@ -3673,6 +3778,11 @@
"unhealthy": "Unhealthy",
"ok": "OK",
"fail": "Fail",
+ "range": {
+ "twelveHours": "12h",
+ "oneDay": "24h",
+ "all": "All"
+ },
"noSectionsEnabled": "Enable at least one section in widget options.",
"error": {
"internalServerError": "Failed to fetch speedtest data"
@@ -3714,6 +3824,11 @@
"downCount": "Down",
"pausedCount": "Paused"
},
+ "status": {
+ "up": "Up",
+ "down": "Down",
+ "paused": "Paused"
+ },
"error": {
"internalServerError": "Failed to fetch uptime data"
}
@@ -3743,6 +3858,19 @@
"services": "Services",
"middlewares": "Middlewares"
},
+ "resourceType": {
+ "router": "Router",
+ "service": "Service",
+ "middleware": "Middleware"
+ },
+ "resourceStatus": {
+ "enabled": "Enabled",
+ "disabled": "Disabled",
+ "warning": "Warning",
+ "error": "Error",
+ "unknown": "Unknown"
+ },
+ "failedEndpoint": "Failed: {endpoint}",
"status": {
"healthy": "Healthy",
"errors": "{count} errors",
@@ -3889,11 +4017,30 @@
},
"noDefinition": "No definition selected",
"definitionNotFound": "Widget definition not found. It may have been deleted.",
+ "fetchFailed": "Unable to load custom API data",
"noDisplayOutput": "No display output",
"executeConfirm": "Are you sure you want to execute this action?",
"executing": "Executing...",
"executeSuccess": "Action executed successfully",
- "executeFailed": "Action failed. Check the widget configuration."
+ "executeFailed": "Action failed. Check the widget configuration.",
+ "actions": {
+ "pausePolling": "Pause polling",
+ "resumePolling": "Resume polling",
+ "togglePolling": "Pause or resume polling"
+ },
+ "displayType": {
+ "singleValue": "Single value",
+ "keyValue": "Key/value",
+ "table": "Table",
+ "statGrid": "Stat grid",
+ "progressBars": "Progress bars",
+ "statusIndicator": "Status indicator",
+ "countGrid": "Count grid",
+ "raw": "Raw data",
+ "actionButton": "Action button",
+ "customJsx": "Custom JSX",
+ "unknown": "Custom data"
+ }
},
"beszelSystemTable": {
"name": "Beszel Systems (Table)",
@@ -4081,6 +4228,14 @@
}
},
"selectSystem": "Select system",
+ "period": {
+ "live": "Live",
+ "oneHour": "1h",
+ "twelveHours": "12h",
+ "oneDay": "24h",
+ "oneWeek": "1w",
+ "thirtyDays": "30d"
+ },
"empty": {
"noSystems": "No systems found"
},
diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx
index 3d575bfb0..abeb7e350 100644
--- a/packages/widgets/src/_inputs/common.tsx
+++ b/packages/widgets/src/_inputs/common.tsx
@@ -9,6 +9,7 @@ export interface CommonWidgetInputProps {
options: Omit, "defaultValue" | "type">;
initialOptions: Record;
itemId?: string;
+ boardId?: string;
}
type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx
index 3b5b636aa..532fd04f8 100644
--- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx
+++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx
@@ -17,7 +17,13 @@ export interface DynamicSelectOption {
label: string;
}
-export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => {
+export const WidgetDynamicSelectInput = ({
+ property,
+ kind,
+ options,
+ itemId,
+ boardId,
+}: CommonWidgetInputProps<"dynamicSelect">) => {
const t = useI18n();
const tWidget = useWidgetInputTranslation(kind, property);
const form = useFormContext();
@@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg
debouncedSearch,
form.values.integrationIds,
form.values.options,
+ itemId,
+ boardId,
);
const currentOption = inputProps.value as DynamicSelectOption | null;
const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void;
diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx
index 90a1c998c..781a09930 100644
--- a/packages/widgets/src/anchor-note/component.tsx
+++ b/packages/widgets/src/anchor-note/component.tsx
@@ -3,13 +3,14 @@
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
-import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
+import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
+import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useTimeAgo } from "@homarr/common";
import type { AnchorNotePermission } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
@@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => {
return JSON.stringify(emptyDelta());
};
+export const storedContentToPlainText = (content?: string | null): string => {
+ return parseStoredContent(content)
+ .ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
+ .join("")
+ .trim();
+};
+
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
};
@@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => {
return (data as { code?: unknown }).code === "FORBIDDEN";
};
-export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) {
+export default function AnchorNoteWidget({
+ options,
+ integrationIds,
+ width,
+ height,
+ displayMode,
+ widgetStateRef,
+}: WidgetComponentProps<"anchorNote">) {
const t = useScopedI18n("widget.anchorNote");
const noteId = options.noteId.trim();
if (!noteId) {
@@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
- return ;
+ return (
+
+ );
+}
+
+interface AnchorNotePersistedState {
+ integrationId: string;
+ noteId: string;
+ isEditing: boolean;
+ draftTitle: string;
+ draftContent: string;
+ saveError: string | null;
}
interface AnchorNoteWidgetContentProps {
options: WidgetComponentProps<"anchorNote">["options"];
integrationId: string;
noteId: string;
+ width: number;
+ height: number;
+ isAdvanced: boolean;
+ widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"];
}
-const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => {
+const AnchorNoteWidgetContent = ({
+ options,
+ integrationId,
+ noteId,
+ width,
+ height,
+ isAdvanced,
+ widgetStateRef,
+}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
+ const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined;
+ const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId;
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
- const [isEditing, setIsEditing] = useState(false);
- const [draftTitle, setDraftTitle] = useState(note?.title ?? "");
- const [draftContent, setDraftContent] = useState(note?.content ?? "");
- const [saveError, setSaveError] = useState(null);
+ const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
+ const [draftTitle, setDraftTitle] = useState(
+ canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""),
+ );
+ const [draftContent, setDraftContent] = useState(
+ canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""),
+ );
+ const [saveError, setSaveError] = useState(
+ canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
+ );
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError },
+ };
+ }, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]);
useEffect(() => {
if (isEditing || !note) return;
@@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
});
}, [isEditing, note]);
- const canEdit = canEditPermission(note?.permission ?? "viewer");
+ const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
+ const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess;
const isViewer = !note || note.permission === "viewer";
const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]);
const updatedAtRelative = useTimeAgo(updatedAt, 30000);
@@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
+ const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
@@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
if (!note) return ;
return (
-
+
{isEditing ? (
@@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
{t("readOnlyViewer")}
)}
+ {!isEditing && isAdvanced && (
+
+
+ {t(`permission.${note.permission}`)}
+
+ {note.isPinned && {t("status.pinned")} }
+ {note.isArchived && (
+
+ {t("status.archived")}
+
+ )}
+
+ {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })}
+
+
+ )}
{saveError && (
{saveError}
@@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW
)}
-
-
-
+ {isEditing || isAdvanced ? (
+
+
+
+ ) : (
+
+ {plainText || t("emptyContent")}
+
+ )}
);
};
diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts
index a54aada3f..9baaccf2d 100644
--- a/packages/widgets/src/anchor-note/index.ts
+++ b/packages/widgets/src/anchor-note/index.ts
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("anchorNote", {
icon: IconNotes,
+ queryKey: [["widget", "anchorNotes"]],
+ maxIntegrations: 1,
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx
index 56e079a8c..aa3dad356 100644
--- a/packages/widgets/src/app/component.tsx
+++ b/packages/widgets/src/app/component.tsx
@@ -19,7 +19,7 @@ import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
import { PingIndicator } from "./ping/ping-indicator";
-export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) {
+export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) {
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
@@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
if (!app) return ;
const isTiny = height < 100 || width < 100;
+ const isAdvanced = displayMode === "advanced";
const isColumnLayout = options.layout.startsWith("column");
return (
@@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
- {options.showTitle && (
+ {(options.showTitle || isAdvanced) && (
)}
- {options.descriptionDisplayMode === "normal" && (
+ {(options.descriptionDisplayMode === "normal" || isAdvanced) && (
{app.description?.split("\n").map((line, index) => (
@@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
))}
)}
+ {isAdvanced && app.href && (
+
+ {getDisplayHost(app.href)}
+
+ )}
@@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren {
+ try {
+ return new URL(href).host;
+ } catch {
+ return href;
+ }
+};
diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts
index 62f01287d..7755ac105 100644
--- a/packages/widgets/src/app/index.ts
+++ b/packages/widgets/src/app/index.ts
@@ -15,6 +15,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("app", {
icon: IconApps,
+ queryKeys: [[["app", "byId"]], [["widget", "app"]]],
createOptions(settings) {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx
index 5ac03eadc..50b213257 100644
--- a/packages/widgets/src/archive-team-warrior/component.tsx
+++ b/packages/widgets/src/archive-team-warrior/component.tsx
@@ -1,16 +1,20 @@
"use client";
-import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core";
+import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
+import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
export default function ArchiveTeamWarriorWidget({
integrationIds,
options,
+ width,
+ height,
+ displayMode,
}: WidgetComponentProps<"archiveTeamWarrior">) {
const integrationId = integrationIds[0];
@@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({
return null;
}
- return ;
+ return (
+
+ );
}
const ArchiveTeamWarriorWidgetContent = ({
integrationId,
options,
+ width,
+ height,
+ isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"archiveTeamWarrior">["options"];
+ width: number;
+ height: number;
+ isAdvanced: boolean;
}) => {
+ const t = useScopedI18n("widget.archiveTeamWarrior");
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
- const projectName = status.project?.title ?? status.selectedProject ?? "No project selected";
+ const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected");
+ const statusKey = getStatusKey(status.status);
return (
-
+
@@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({
-
- {status.status}
+
+ {t(`status.${statusKey}`)}
- {options.showBroadcastMessage && status.broadcastMessage && (
+ {options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && (
{status.broadcastMessage}
@@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({
)}
-
-
-
-
-
+
+
+
+
+
{status.bandwidth && (
-
-
+
+
)}
+ {isAdvanced && (
+ <>
+
+
+ {t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
+
+
+ {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
+
+
+
+
+ {status.items.map((item) => (
+
+
+
+
+ {item.name}
+
+
+ {item.project ?? projectName}
+
+
+
+ {t(`status.${item.status}`)}
+
+
+
+ ))}
+
+
+ >
+ )}
);
};
@@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) =>
);
const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0));
+
+type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown";
+
+const getStatusKey = (status?: string | null): WarriorStatusKey => {
+ switch (status?.trim().toLowerCase()) {
+ case "running":
+ return "running";
+ case "done":
+ case "completed":
+ return "completed";
+ case "failed":
+ return "failed";
+ case "canceled":
+ case "cancelled":
+ return "canceled";
+ case "stopped":
+ return "stopped";
+ case "idle":
+ return "idle";
+ default:
+ return "unknown";
+ }
+};
+
+const getStatusColor = (status: WarriorStatusKey): string => {
+ if (status === "running") return "green";
+ if (status === "failed") return "red";
+ if (status === "canceled" || status === "stopped") return "yellow";
+ return "gray";
+};
diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts
index 5e0998ec2..e639c7e9f 100644
--- a/packages/widgets/src/archive-team-warrior/index.ts
+++ b/packages/widgets/src/archive-team-warrior/index.ts
@@ -5,6 +5,8 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", {
icon: IconArchive,
+ queryKey: [["widget", "archiveTeamWarrior"]],
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showBroadcastMessage: factory.switch({
diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx
index 9e410f527..d31aca8fe 100644
--- a/packages/widgets/src/audio-stats/component.tsx
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -1,17 +1,91 @@
"use client";
+import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
+
import { clientApi } from "@homarr/api/client";
+import { formatBytes, formatDuration } from "@homarr/common";
+import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
-export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) {
+export default function AudioStatsWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"audioStats">) {
const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
+ const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
+ { integrationIds, showOnlyPlaying: false },
+ { enabled: displayMode === "advanced" && response?.kind === "navidrome" },
+ );
if (!response) return ;
- return ;
+ const summary = (
+
+ );
+ if (displayMode === "compact") return summary;
+
+ const sessions = streamResults.flatMap((result) =>
+ result.sessions.map((session) => ({ integrationId: result.integrationId, session })),
+ );
+ const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null;
+ return (
+
+ {summary}
+
+ {response.kind === "navidrome" ? (
+ = 760 ? 2 : 1} spacing="xs">
+ {sessions.map(({ integrationId, session }) => {
+ const currentlyPlaying = session.currentlyPlaying;
+ if (!currentlyPlaying) return null;
+ return (
+
+
+
+ {currentlyPlaying.name}
+
+
+ {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username}
+
+
+ {session.sessionName}
+
+ );
+ })}
+
+ ) : (
+ = 760 ? 2 : 1} spacing="xs">
+ {(audiobookStats?.libraries ?? []).map((library) => (
+
+
+
+ {library.name}
+
+
+ {library.totalItems}
+
+
+
+ {library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "}
+ {library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)}
+
+
+ ))}
+
+ )}
+
+
+ );
}
diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts
index ffcd2dd93..c5783fb89 100644
--- a/packages/widgets/src/audio-stats/index.ts
+++ b/packages/widgets/src/audio-stats/index.ts
@@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
+ queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx
index 094c32ee1..5e4d32ffd 100644
--- a/packages/widgets/src/bazarr/component.tsx
+++ b/packages/widgets/src/bazarr/component.tsx
@@ -36,7 +36,13 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
-export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) {
+export default function BazarrWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode,
+}: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
@@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
- .filter(([optionKey]) => options[optionKey as keyof typeof options])
+ .filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
- const gridCols = getGridCols(width);
- const iconSize = getIconSize(width);
+ const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
+ const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
return (
@@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC
);
}
-function getGridCols(width: number): number {
- const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth);
- return match?.cols ?? 1;
+export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
+ if (itemCount <= 1) return 1;
+ if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
+
+ const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
+ const rowsAtWidth = Math.ceil(itemCount / widthMatch);
+ const rowHeight = height / rowsAtWidth;
+ return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
}
-function getIconSize(width: number): number {
+export function getIconSize(width: number): number {
const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth);
return match?.size ?? 16;
}
diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts
index f2e79f570..7ac60e0de 100644
--- a/packages/widgets/src/bazarr/index.ts
+++ b/packages/widgets/src/bazarr/index.ts
@@ -5,8 +5,10 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
icon: IconBadgeCc,
+ queryKey: [["widget", "bazarr"]],
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissingEpisodes: factory.switch({
diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts
new file mode 100644
index 000000000..2401ce4e6
--- /dev/null
+++ b/packages/widgets/src/bazarr/layout.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "vitest";
+
+import { getGridCols, getIconSize } from "./component";
+
+describe("Bazarr responsive layout", () => {
+ test("uses two columns for a short widget so all stats remain visible", () => {
+ expect(getGridCols(220, 100, 4)).toBe(2);
+ });
+
+ test("uses four columns in a wide advanced surface", () => {
+ expect(getGridCols(800, 400, 4, true)).toBe(4);
+ });
+
+ test("sizes icons from the constrained dimension", () => {
+ expect(getIconSize(180)).toBe(16);
+ expect(getIconSize(340)).toBe(22);
+ });
+});
diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx
index 3f934423d..f81835739 100644
--- a/packages/widgets/src/beszel-alerts/component.tsx
+++ b/packages/widgets/src/beszel-alerts/component.tsx
@@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({
options,
integrationIds,
isEditMode,
+ height,
+ displayMode = "compact",
}: WidgetComponentProps<"beszelAlerts">) {
const t = useScopedI18n("widget.beszelAlerts");
const alertsInput = useMemo(
@@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({
const triggeredAlerts = alerts.filter((a) => a.triggered);
const okAlerts = alerts.filter((a) => !a.triggered);
+ const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260;
+ const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
if (alertsError) throw alertsError;
@@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
)}
- {triggeredAlerts.length > 0 && okAlerts.length > 0 && }
+ {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && }
- {okAlerts.length > 0 && (
+ {showOkAlerts && okAlerts.length > 0 && (
@@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({
)}
- {options.showHistory && history.length > 0 && (
+ {showHistory && history.length > 0 && (
<>
diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts
index 5e3799a90..f7dd6dee7 100644
--- a/packages/widgets/src/beszel-alerts/index.ts
+++ b/packages/widgets/src/beszel-alerts/index.ts
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
+ queryKey: [["widget", "beszel"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index dd2f56c54..8359b2353 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -330,7 +330,9 @@ const metricRenderers = [
] as const;
const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => {
- const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics);
+ const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options));
+ const visibleMetrics = enabledMetrics.slice(0, maxMetrics);
+ const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length;
return (
{visibleMetrics.map((m) => m.render(system, t, size))}
+ {hiddenMetricCount > 0 && (
+
+ +{hiddenMetricCount}
+
+ )}
);
};
@@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({
isEditMode,
width,
height,
+ displayMode = "compact",
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
@@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({
);
}
- const cols = getColCount(width, height, filteredSystems.length);
+ const advanced = displayMode === "advanced";
+ const cols = advanced
+ ? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
+ : getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
- const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT;
- const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight;
+ const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT;
+ const scrollEnabled = rawCellHeight < minimumCellHeight;
+ const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
- const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
+ const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
return (
@@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({
style={{
display: "grid",
gridTemplateColumns: `repeat(${cols}, 1fr)`,
- gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`,
+ gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`,
gap: size.gap,
overflow: scrollEnabled ? "auto" : "hidden",
}}
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index 91dcd7292..1b964c409 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo } from "react";
-import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core";
+import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core";
import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
+import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
export default function BeszelSystemStatsWidget({
options,
integrationIds,
isEditMode,
width,
+ height,
+ displayMode = "compact",
boardId,
itemId,
setOptions,
@@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
- const systems = useMemo(
- () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))),
- [systemsResult],
- );
- const selectedSystem = options.systemId || systems[0]?.value || "";
- const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label;
- const systemExists = systems.some((system) => system.value === selectedSystem);
+ const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
+ const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
+ const selectedValue = selectedSystem?.value ?? "";
+ const selectedLabel = selectedSystem?.label;
+ const systemExists = selectedSystem !== undefined;
const handleSelectSystem = useCallback(
(value: string) => {
@@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({
[setOptions, hasChangeAccess, boardId, itemId, saveItemOptions],
);
+ const handleTimePeriod = useCallback(
+ (value: string) => {
+ setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } });
+ if (hasChangeAccess && boardId && itemId) {
+ saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } });
+ }
+ },
+ [boardId, hasChangeAccess, itemId, saveItemOptions, setOptions],
+ );
+
if (systemsError) throw systemsError;
if (systemsPending)
return (
@@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({
value && handleSelectSystem(value)}
/>
@@ -127,8 +138,8 @@ export default function BeszelSystemStatsWidget({
handleSelectSystem(system.value)}
>
{system.label}
@@ -137,20 +148,35 @@ export default function BeszelSystemStatsWidget({
)}
+ {!isEditMode && displayMode === "advanced" && (
+
+ )}
600 ? 2 : 1}
+ columns={displayMode === "advanced" || width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
- diskIO: options.showDiskIO,
- network: options.showNetwork,
- dockerCpu: options.showDockerCpu,
- dockerMemory: options.showDockerMemory,
- dockerNetwork: options.showDockerNetwork,
+ diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
+ network: options.showNetwork && (displayMode === "advanced" || height >= 260),
+ dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
+ dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
+ dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
/>
diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts
index af2907b25..3a885f563 100644
--- a/packages/widgets/src/beszel-system-stats/index.ts
+++ b/packages/widgets/src/beszel-system-stats/index.ts
@@ -4,6 +4,7 @@ import { clientApi } from "@homarr/api/client";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
+import { createBeszelSystemChoices } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -30,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("beszelSys
isPending,
isError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }, { enabled: integrationIds.length > 0 });
- const selectData = data.flatMap((r) => r.systems.map((s) => ({ value: s.id, label: s.name })));
+ const selectData = createBeszelSystemChoices(data).map(({ value, label }) => ({ value, label }));
return { data: selectData, isPending, isError };
},
}),
diff --git a/packages/widgets/src/beszel-system-stats/selection.spec.ts b/packages/widgets/src/beszel-system-stats/selection.spec.ts
new file mode 100644
index 000000000..8280761ad
--- /dev/null
+++ b/packages/widgets/src/beszel-system-stats/selection.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
+
+const choices = createBeszelSystemChoices([
+ { integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
+ { integrationId: "two", integrationName: "Backup", systems: [{ id: "same", name: "Host B" }] },
+]);
+
+describe("Beszel system selection", () => {
+ it("uses a composite integration and system identity", () => {
+ expect(choices.map((choice) => choice.value)).toEqual(["one:same", "two:same"]);
+ expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
+ });
+
+ it("keeps legacy plain system ids working", () => {
+ expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
+ });
+});
diff --git a/packages/widgets/src/beszel-system-stats/selection.ts b/packages/widgets/src/beszel-system-stats/selection.ts
new file mode 100644
index 000000000..bd7445704
--- /dev/null
+++ b/packages/widgets/src/beszel-system-stats/selection.ts
@@ -0,0 +1,32 @@
+export interface BeszelSystemChoice {
+ value: string;
+ label: string;
+ integrationId: string;
+ systemId: string;
+}
+
+interface BeszelSystemsResult {
+ integrationId: string;
+ integrationName: string;
+ systems: { id: string; name: string }[];
+}
+
+export function createBeszelSystemChoices(results: BeszelSystemsResult[]): BeszelSystemChoice[] {
+ const includeIntegrationName = results.length > 1;
+ return results.flatMap((result) =>
+ result.systems.map((system) => ({
+ value: `${result.integrationId}:${system.id}`,
+ label: includeIntegrationName ? `${result.integrationName} · ${system.name}` : system.name,
+ integrationId: result.integrationId,
+ systemId: system.id,
+ })),
+ );
+}
+
+export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
+ return (
+ choices.find((choice) => choice.value === storedValue) ??
+ choices.find((choice) => choice.systemId === storedValue) ??
+ choices[0]
+ );
+}
diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx
index 5352eb170..14ac594df 100644
--- a/packages/widgets/src/beszel-system-table/component.tsx
+++ b/packages/widgets/src/beszel-system-table/component.tsx
@@ -3,7 +3,7 @@
import "./styles.css";
import { useMemo, useState } from "react";
-import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
+import { Center, Group, Indicator, Loader, Progress, ScrollArea, Stack, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable } from "mantine-datatable";
import {
@@ -38,6 +38,7 @@ import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
+import { BeszelStatsView } from "../beszel/_shared/stats-view";
const directionMultiplier: Record = { asc: 1, desc: -1 };
@@ -75,6 +76,7 @@ export default function BeszelSystemTableWidget({
integrationIds,
isEditMode,
width,
+ displayMode = "compact",
}: WidgetComponentProps<"beszelSystemTable">) {
const t = useScopedI18n("widget.beszelSystemTable");
const { openModal } = useModalAction(BeszelSystemStatsModal);
@@ -84,6 +86,34 @@ export default function BeszelSystemTableWidget({
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const size = getSizeConfig(width);
+ const [selectedSystem, setSelectedSystem] = useState(null);
+
+ const visibleMetricKeys = useMemo(() => {
+ const enabled = [
+ "showCpu",
+ "showMemory",
+ "showDisk",
+ "showGpu",
+ "showLoadAvg",
+ "showNet",
+ "showTemp",
+ "showBattery",
+ "showServices",
+ "showUptime",
+ "showAgent",
+ ].filter((key) => options[key as keyof typeof options]) as string[];
+ const budget =
+ displayMode === "advanced"
+ ? enabled.length
+ : width < 360
+ ? 1
+ : width < 560
+ ? 2
+ : width < 760
+ ? 4
+ : enabled.length;
+ return new Set(enabled.slice(0, budget));
+ }, [displayMode, options, width]);
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
@@ -138,7 +168,7 @@ export default function BeszelSystemTableWidget({
),
},
- options.showCpu && {
+ visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
title: (
@@ -149,7 +179,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => ,
},
- options.showMemory && {
+ visibleMetricKeys.has("showMemory") && {
accessor: "memory",
title: (
@@ -160,7 +190,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => ,
},
- options.showDisk && {
+ visibleMetricKeys.has("showDisk") && {
accessor: "disk",
title: (
@@ -179,7 +209,7 @@ export default function BeszelSystemTableWidget({
/>
),
},
- options.showGpu && {
+ visibleMetricKeys.has("showGpu") && {
accessor: "gpu",
title: (
@@ -190,7 +220,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => ,
},
- options.showLoadAvg && {
+ visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
title: (
@@ -206,7 +236,7 @@ export default function BeszelSystemTableWidget({
),
},
- options.showNet && {
+ visibleMetricKeys.has("showNet") && {
accessor: "netBytes",
title: (
@@ -221,7 +251,7 @@ export default function BeszelSystemTableWidget({
),
},
- options.showTemp && {
+ visibleMetricKeys.has("showTemp") && {
accessor: "temp",
title: (
@@ -232,7 +262,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => {formatTemp(record.temp, false)} ,
},
- options.showBattery && {
+ visibleMetricKeys.has("showBattery") && {
accessor: "battery",
title: (
@@ -242,7 +272,7 @@ export default function BeszelSystemTableWidget({
),
render: (record) => {record.battery ? `${record.battery[0]}%` : "—"} ,
},
- options.showServices && {
+ visibleMetricKeys.has("showServices") && {
accessor: "services",
title: (
@@ -253,7 +283,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => {record.services} ,
},
- options.showUptime && {
+ visibleMetricKeys.has("showUptime") && {
accessor: "uptime",
title: (
@@ -264,7 +294,7 @@ export default function BeszelSystemTableWidget({
sortable: true,
render: (record) => {formatUptime(record.uptime)} ,
},
- options.showAgent && {
+ visibleMetricKeys.has("showAgent") && {
accessor: "agentVersion",
title: (
@@ -278,9 +308,13 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn[];
- }, [options, t, size]);
+ }, [t, size, visibleMetricKeys]);
const handleRowClick = ({ record }: { record: SystemRowWithKey }) => {
+ if (displayMode === "advanced") {
+ setSelectedSystem(record);
+ return;
+ }
const integrationId = record._key.split(":")[0] ?? "";
openModal({ integrationId, systemId: record.id }, { title: record.name });
};
@@ -295,7 +329,7 @@ export default function BeszelSystemTableWidget({
);
}
- return (
+ const table = (
@@ -318,4 +352,45 @@ export default function BeszelSystemTableWidget({
/>
);
+
+ if (displayMode === "compact") return table;
+
+ const activeSystem = selectedSystem ?? sortedSystems[0];
+ const activeIntegrationId = activeSystem?._key.split(":")[0] ?? "";
+ return (
+
= 900 ? "minmax(0, 3fr) minmax(320px, 2fr)" : "1fr",
+ height: "100%",
+ gap: 12,
+ padding: 12,
+ }}
+ >
+ {table}
+ {activeSystem && (
+
+
+ {activeSystem.name}
+
+
+
+ )}
+
+ );
}
diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx
index e6b7eb7c0..875394ea8 100644
--- a/packages/widgets/src/bookmarks/component.tsx
+++ b/packages/widgets/src/bookmarks/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Anchor, Card, Flex, Group, Stack, Text, Title, UnstyledButton } from "@mantine/core";
+import { Anchor, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Title, UnstyledButton } from "@mantine/core";
import combineClasses from "clsx";
import type { RouterOutputs } from "@homarr/api";
@@ -12,11 +12,11 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import classes from "./bookmark.module.css";
-export default function BookmarksWidget({ options, itemId }: WidgetComponentProps<"bookmarks">) {
+export default function BookmarksWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
- select(data) {
- return data.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
+ select(apps) {
+ return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
@@ -48,7 +48,16 @@ export default function BookmarksWidget({ options, itemId }: WidgetComponentProp
{options.title}
)}
- {(options.layout === "grid" || options.layout === "gridHorizontal") && (
+ {displayMode === "advanced" && (
+
+ )}
+ {displayMode !== "advanced" && (options.layout === "grid" || options.layout === "gridHorizontal") && (
)}
- {options.layout !== "grid" && options.layout !== "gridHorizontal" && (
+ {displayMode !== "advanced" && options.layout !== "grid" && options.layout !== "gridHorizontal" && (
+ Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
+
+export const getBookmarkHostname = (href: string | null): string | undefined => {
+ if (!href) return undefined;
+ try {
+ return new URL(href).hostname;
+ } catch {
+ return href;
+ }
+};
+
+const AdvancedBookmarksLayout = ({
+ data,
+ width,
+ openNewTab,
+ withBorder,
+ hasIconColor,
+}: AdvancedBookmarksLayoutProps) => {
+ const board = useRequiredBoard();
+
+ return (
+
+
+ {data.map((app) => (
+
+
+
+
+
+
+ {app.name}
+
+
+ {getBookmarkHostname(app.href)}
+
+ {app.description && (
+
+ {app.description}
+
+ )}
+
+
+
+
+ ))}
+
+
+ );
+};
+
interface GridLayoutProps {
data: RouterOutputs["app"]["byIds"];
hideTitle: boolean;
@@ -247,7 +327,7 @@ const VerticalItem = ({
)}
{!hideHostname && (
- {app.href ? new URL(app.href).hostname : undefined}
+ {getBookmarkHostname(app.href)}
)}
@@ -294,7 +374,7 @@ const HorizontalItem = ({
{!hideHostname && (
- {app.href ? new URL(app.href).hostname : undefined}
+ {getBookmarkHostname(app.href)}
)}
diff --git a/packages/widgets/src/bookmarks/index.tsx b/packages/widgets/src/bookmarks/index.tsx
index c57b909fd..c18ff796e 100644
--- a/packages/widgets/src/bookmarks/index.tsx
+++ b/packages/widgets/src/bookmarks/index.tsx
@@ -10,6 +10,7 @@ import { BookmarkAddButton } from "./add-button";
export const { definition, componentLoader } = createWidgetDefinition("bookmarks", {
icon: IconBookmark,
+ queryKey: [["app", "byIds"]],
createOptions() {
return optionsBuilder.from((factory) => ({
title: factory.text(),
diff --git a/packages/widgets/src/bookmarks/layout.spec.ts b/packages/widgets/src/bookmarks/layout.spec.ts
new file mode 100644
index 000000000..adb35a554
--- /dev/null
+++ b/packages/widgets/src/bookmarks/layout.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "vitest";
+
+import { getAdvancedBookmarkColumns, getBookmarkHostname } from "./component";
+
+describe("bookmark display helpers", () => {
+ test("uses bounded advanced columns", () => {
+ expect(getAdvancedBookmarkColumns(900, 8)).toBe(3);
+ expect(getAdvancedBookmarkColumns(120, 8)).toBe(1);
+ expect(getAdvancedBookmarkColumns(900, 0)).toBe(1);
+ });
+
+ test("does not crash on malformed bookmark URLs", () => {
+ expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
+ expect(getBookmarkHostname("not a URL")).toBe("not a URL");
+ expect(getBookmarkHostname(null)).toBeUndefined();
+ });
+});
diff --git a/packages/widgets/src/calendar/calendar-event-list.tsx b/packages/widgets/src/calendar/calendar-event-list.tsx
index 992da9d71..3fadb5a98 100644
--- a/packages/widgets/src/calendar/calendar-event-list.tsx
+++ b/packages/widgets/src/calendar/calendar-event-list.tsx
@@ -1,3 +1,4 @@
+import { useId } from "react";
import {
Badge,
Box,
@@ -18,15 +19,25 @@ import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
+import { groupEventsByDate } from "./calendar-events";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
events: CalendarEvent[];
+ groupByDate?: boolean;
+ locale?: string;
}
-export const CalendarEventList = ({ events }: CalendarEventListProps) => {
- const { colorScheme } = useMantineColorScheme();
- const t = useI18n();
+export const CalendarEventList = ({ events, groupByDate = false, locale }: CalendarEventListProps) => {
+ const headingIdPrefix = useId();
+ const dateFormatter = new Intl.DateTimeFormat(locale, {
+ weekday: "long",
+ year: "numeric",
+ month: "long",
+ day: "numeric",
+ });
+ const groups = groupByDate ? [...groupEventsByDate(events).entries()] : null;
+
return (
{
},
}}
>
-
- {events.map((event, eventIndex) => (
-
- {event.image !== null && (
-
-
- {event.image.badge !== undefined && (
-
- {event.image.badge.content}
-
- )}
-
- )}
-
-
-
- {event.subTitle !== null && (
-
- {event.subTitle}
-
- )}
-
- {event.title}
-
-
- {event.metadata?.type === "radarr" && (
-
-
- {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
-
-
- )}
-
-
-
- {isAllDay(event) ? (
-
- {t("widget.calendar.duration.allDay")}
-
- ) : (
- <>
-
- {dayjs(event.startDate).format("HH:mm")}
-
-
- {event.endDate !== null && (
- <>
- -{" "}
-
- {dayjs(event.endDate).format("HH:mm")}
-
- >
- )}
- >
- )}
-
-
-
- {event.location !== null && (
-
-
-
- {event.location}
-
-
- )}
-
- {!isNullOrWhitespace(event.description) && (
-
- {event.description}
+
+ {groups ? (
+ groups.map(([date, groupedEvents]) => {
+ const headingId = `${headingIdPrefix}-${date}`;
+ return (
+
+
+ {dateFormatter.format(groupedEvents[0]?.startDate)}
- )}
-
- {event.links.length > 0 && (
-
- {event.links
- .filter((link) => link.href)
- .map((link) => (
- : undefined}
- >
- {link.name}
-
- ))}
-
- )}
-
-
- ))}
+
+
+
+
+ );
+ })
+ ) : (
+
+ )}
);
};
+const CalendarEventRows = ({ events }: Pick) => {
+ const { colorScheme } = useMantineColorScheme();
+ const t = useI18n();
+ return (
+ <>
+ {events.map((event, eventIndex) => (
+
+ {event.image !== null && (
+
+
+ {event.image.badge !== undefined && (
+
+ {event.image.badge.content}
+
+ )}
+
+ )}
+
+
+
+ {event.subTitle !== null && (
+
+ {event.subTitle}
+
+ )}
+
+ {event.title}
+
+
+ {event.metadata?.type === "radarr" && (
+
+
+ {t(`widget.calendar.option.releaseType.options.${event.metadata.releaseType}`)}
+
+
+ )}
+
+
+
+ {isAllDay(event) ? (
+
+ {t("widget.calendar.duration.allDay")}
+
+ ) : (
+ <>
+
+ {dayjs(event.startDate).format("HH:mm")}
+
+
+ {event.endDate !== null && (
+ <>
+ -{" "}
+
+ {dayjs(event.endDate).format("HH:mm")}
+
+ >
+ )}
+ >
+ )}
+
+
+
+ {event.location !== null && (
+
+
+
+ {event.location}
+
+
+ )}
+
+ {!isNullOrWhitespace(event.description) && (
+
+ {event.description}
+
+ )}
+
+ {event.links.length > 0 && (
+
+ {event.links
+ .filter((link) => link.href)
+ .map((link) => (
+ : undefined}
+ >
+ {link.name}
+
+ ))}
+
+ )}
+
+
+ ))}
+ >
+ );
+};
+
const isAllDay = (event: Pick) => {
if (!event.endDate) return false;
diff --git a/packages/widgets/src/calendar/calendar-events.ts b/packages/widgets/src/calendar/calendar-events.ts
new file mode 100644
index 000000000..0b15e0098
--- /dev/null
+++ b/packages/widgets/src/calendar/calendar-events.ts
@@ -0,0 +1,77 @@
+import dayjs from "dayjs";
+
+import type { CalendarEvent } from "@homarr/integrations/types";
+
+export const toCalendarDateKey = (date: Date) => dayjs(date).format("YYYY-MM-DD");
+
+const matchesReleaseType = (event: CalendarEvent, releaseTypes: readonly string[]) =>
+ event.metadata?.type !== "radarr" || releaseTypes.includes(event.metadata.releaseType);
+
+export const getCalendarAgendaEvents = (
+ events: CalendarEvent[],
+ viewedMonth: Date,
+ releaseTypes: readonly string[],
+): CalendarEvent[] => {
+ const monthStart = dayjs(viewedMonth).startOf("month");
+ const monthEnd = monthStart.add(1, "month");
+
+ return events
+ .filter((event) => {
+ if (!matchesReleaseType(event, releaseTypes)) return false;
+
+ const start = dayjs(event.startDate);
+ if (!event.endDate) return !start.isBefore(monthStart) && start.isBefore(monthEnd);
+
+ // Calendar intervals are half-open: an all-day event ending at midnight does not occupy the next day.
+ return start.isBefore(monthEnd) && dayjs(event.endDate).isAfter(monthStart);
+ })
+ .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
+};
+
+export const groupEventsByDate = (events: CalendarEvent[]): Map => {
+ const result = new Map();
+ for (const event of events) {
+ const key = toCalendarDateKey(event.startDate);
+ const entries = result.get(key) ?? [];
+ entries.push(event);
+ result.set(key, entries);
+ }
+
+ return new Map(
+ [...result.entries()]
+ .toSorted(([dateA], [dateB]) => dateA.localeCompare(dateB))
+ .map(([date, entries]) => [
+ date,
+ entries.toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime()),
+ ]),
+ );
+};
+
+/** Splits multi-day events into single-day calendar tiles. */
+export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
+ const splitEventEntries: CalendarEvent[] = [];
+ for (const event of events) {
+ if (!event.endDate) {
+ splitEventEntries.push(event);
+ continue;
+ }
+
+ if (dayjs(event.startDate).isSame(event.endDate, "day")) {
+ splitEventEntries.push(event);
+ continue;
+ }
+
+ if (dayjs(event.startDate).isAfter(event.endDate)) continue;
+
+ let currentStart = dayjs(event.startDate);
+ while (currentStart.isBefore(event.endDate)) {
+ splitEventEntries.push({
+ ...event,
+ startDate: currentStart.toDate(),
+ endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
+ });
+ currentStart = currentStart.add(1, "day").startOf("day");
+ }
+ }
+ return splitEventEntries;
+};
diff --git a/packages/widgets/src/calendar/calendar.spec.ts b/packages/widgets/src/calendar/calendar.spec.ts
index 0d655adc6..84ba92b86 100644
--- a/packages/widgets/src/calendar/calendar.spec.ts
+++ b/packages/widgets/src/calendar/calendar.spec.ts
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { CalendarEvent } from "@homarr/integrations/types";
-import { splitEvents } from "./component";
+import { getCalendarAgendaEvents, groupEventsByDate, splitEvents } from "./calendar-events";
describe("splitEvents should split multi-day events into multiple single-day events", () => {
test("2 day all-day event should be split up into two all-day events", () => {
@@ -53,6 +53,45 @@ describe("splitEvents should split multi-day events into multiple single-day eve
});
});
+describe("groupEventsByDate", () => {
+ test("groups once per day and sorts each bucket", () => {
+ const later = createEvent(new Date(2025, 0, 1, 18));
+ const earlier = createEvent(new Date(2025, 0, 1, 9));
+ const nextDay = createEvent(new Date(2025, 0, 2, 9));
+
+ const result = groupEventsByDate([later, nextDay, earlier]);
+
+ expect(result.size).toBe(2);
+ expect([...result.values()][0]).toEqual([earlier, later]);
+ });
+});
+
+describe("getCalendarAgendaEvents", () => {
+ test("keeps overlapping original events once and excludes adjacent-month events", () => {
+ const spansIntoMonth = createEvent(new Date(2024, 11, 31, 22), new Date(2025, 0, 2, 8));
+ const inMonth = createEvent(new Date(2025, 0, 20, 9));
+ const endsAtMonthStart = createEvent(new Date(2024, 11, 31), new Date(2025, 0, 1));
+ const nextMonth = createEvent(new Date(2025, 1, 1));
+
+ const result = getCalendarAgendaEvents(
+ [nextMonth, spansIntoMonth, endsAtMonthStart, inMonth],
+ new Date(2025, 0, 15),
+ [],
+ );
+
+ expect(result).toEqual([spansIntoMonth, inMonth]);
+ });
+
+ test("applies Radarr release filters without splitting multi-day events", () => {
+ const cinema = createEvent(new Date(2025, 0, 5), new Date(2025, 0, 8));
+ cinema.metadata = { type: "radarr", releaseType: "inCinemas" };
+ const digital = createEvent(new Date(2025, 0, 7));
+ digital.metadata = { type: "radarr", releaseType: "digitalRelease" };
+
+ expect(getCalendarAgendaEvents([cinema, digital], new Date(2025, 0, 1), ["inCinemas"])).toEqual([cinema]);
+ });
+});
+
const createEvent = (startDate: Date, endDate: Date | null = null): CalendarEvent => ({
title: "Test",
subTitle: null,
diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx
index 6d309ce42..275c913b4 100644
--- a/packages/widgets/src/calendar/component.tsx
+++ b/packages/widgets/src/calendar/component.tsx
@@ -1,8 +1,8 @@
"use client";
-import { useMemo, useState } from "react";
+import { useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
-import { useMantineTheme } from "@mantine/core";
+import { Box, Stack, Text, useMantineTheme } from "@mantine/core";
import { Calendar } from "@mantine/dates";
import { useElementSize } from "@mantine/hooks";
import dayjs from "dayjs";
@@ -14,10 +14,24 @@ import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
import { CalendarDay } from "./calender-day";
+import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
+import { CalendarEventList } from "./calendar-event-list";
import classes from "./component.module.css";
export default function CalendarWidget(props: WidgetComponentProps<"calendar">) {
- const [month, setMonth] = useState(new Date());
+ const persistedState = props.widgetStateRef?.current?.calendar as { monthTimestamp?: number } | undefined;
+ const [month, setMonth] = useState(() => {
+ const timestamp = persistedState?.monthTimestamp;
+ return typeof timestamp === "number" && Number.isFinite(timestamp) ? new Date(timestamp) : new Date();
+ });
+
+ useEffect(() => {
+ if (!props.widgetStateRef) return;
+ props.widgetStateRef.current = {
+ ...props.widgetStateRef.current,
+ calendar: { monthTimestamp: month.getTime() },
+ };
+ }, [month, props.widgetStateRef]);
if (props.integrationIds.length === 0) {
return ;
@@ -31,7 +45,7 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
-const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }: FetchCalendarProps) => {
+const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -43,7 +57,16 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options }:
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
- return ;
+ return (
+
+ );
};
interface CalendarBaseProps {
@@ -52,9 +75,10 @@ interface CalendarBaseProps {
month: Date;
setMonth: (date: Date) => void;
options: WidgetComponentProps<"calendar">["options"];
+ displayMode?: WidgetComponentProps<"calendar">["displayMode"];
}
-const CalendarBase = ({ isEditMode, events, month, setMonth, options }: CalendarBaseProps) => {
+const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => {
const params = useParams();
const locale = params.locale as string;
const { firstDayOfWeek } = useSettings();
@@ -65,124 +89,101 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options }: Calendar
const isSmall = width < 256;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
+ const visibleEvents = useMemo(
+ () =>
+ normalizedEvents.filter(
+ (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
+ ),
+ [normalizedEvents, options.releaseType],
+ );
+ const eventsByDate = useMemo(() => groupEventsByDate(visibleEvents), [visibleEvents]);
+ const agendaEvents = useMemo(
+ () => getCalendarAgendaEvents(events, month, options.releaseType),
+ [events, month, options.releaseType],
+ );
return (
- setMonth(new Date(month))}
- onNextMonth={(month) => setMonth(new Date(month))}
- highlightToday
- locale={locale}
- hideWeekdays={false}
- date={month}
- maxLevel="month"
- firstDayOfWeek={firstDayOfWeek}
- static={isEditMode}
- className={classes.calendar}
- w="100%"
- h="100%"
- ref={ref}
- styles={{
- calendarHeaderControl: {
- pointerEvents: isEditMode ? "none" : undefined,
- borderRadius: "md",
- height: isSmall ? "1.5rem" : undefined,
- width: isSmall ? "1.5rem" : undefined,
- },
- calendarHeaderLevel: {
- pointerEvents: "none",
- fontSize: isSmall ? "0.75rem" : undefined,
- height: "100%",
- },
- levelsGroup: {
- height: "100%",
- padding: "md",
- },
- calendarHeader: {
- maxWidth: "unset",
- marginBottom: 0,
- },
- monthCell: {
- textAlign: "center",
- position: "relative",
- },
- day: {
- borderRadius: actualItemRadius,
- width: "100%",
- height: "100%",
- position: "absolute",
- top: 0,
- left: 0,
- bottom: 0,
- right: 0,
- },
- month: {
- height: "100%",
- },
- weekday: {
- padding: 0,
- },
- weekdaysRow: {
- height: 22,
- },
- }}
- renderDay={(tileDate) => {
- const eventsForDate = normalizedEvents
- .filter((event) => dayjs(event.startDate).isSame(tileDate, "day"))
- .filter(
- (event) => event.metadata?.type !== "radarr" || options.releaseType.includes(event.metadata.releaseType),
- )
- .toSorted((eventA, eventB) => eventA.startDate.getTime() - eventB.startDate.getTime());
+
+ setMonth(new Date(previousMonth))}
+ onNextMonth={(nextMonth) => setMonth(new Date(nextMonth))}
+ highlightToday
+ locale={locale}
+ hideWeekdays={false}
+ date={month}
+ maxLevel="month"
+ firstDayOfWeek={firstDayOfWeek}
+ static={isEditMode}
+ className={classes.calendar}
+ w="100%"
+ h={displayMode === "advanced" ? "62%" : "100%"}
+ styles={{
+ calendarHeaderControl: {
+ pointerEvents: isEditMode ? "none" : undefined,
+ borderRadius: "md",
+ height: isSmall ? "1.5rem" : undefined,
+ width: isSmall ? "1.5rem" : undefined,
+ },
+ calendarHeaderLevel: {
+ pointerEvents: "none",
+ fontSize: isSmall ? "0.75rem" : undefined,
+ height: "100%",
+ },
+ levelsGroup: {
+ height: "100%",
+ padding: "md",
+ },
+ calendarHeader: {
+ maxWidth: "unset",
+ marginBottom: 0,
+ },
+ monthCell: {
+ textAlign: "center",
+ position: "relative",
+ },
+ day: {
+ borderRadius: actualItemRadius,
+ width: "100%",
+ height: "100%",
+ position: "absolute",
+ top: 0,
+ left: 0,
+ bottom: 0,
+ right: 0,
+ },
+ month: {
+ height: "100%",
+ },
+ weekday: {
+ padding: 0,
+ },
+ weekdaysRow: {
+ height: 22,
+ },
+ }}
+ renderDay={(tileDate) => {
+ const eventsForDate = eventsByDate.get(toCalendarDateKey(dayjs(tileDate).toDate())) ?? [];
- return (
-
- );
- }}
- />
+ return (
+
+ );
+ }}
+ />
+ {displayMode === "advanced" && (
+
+
+ {new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(month)} · {agendaEvents.length}
+
+
+
+ )}
+
);
};
-
-/**
- * Splits multi-day events into multiple single-day events.
- * @param events The events to split.
- * @returns The split events.
- */
-export const splitEvents = (events: CalendarEvent[]): CalendarEvent[] => {
- const splitEvents: CalendarEvent[] = [];
- for (const event of events) {
- if (!event.endDate) {
- splitEvents.push(event);
- continue;
- }
-
- if (dayjs(event.startDate).isSame(event.endDate, "day")) {
- splitEvents.push(event);
- continue;
- }
-
- if (dayjs(event.startDate).isAfter(event.endDate)) {
- // Invalid event, skip it
- continue;
- }
-
- // Event spans multiple days, split it
- let currentStart = dayjs(event.startDate);
-
- while (currentStart.isBefore(event.endDate)) {
- splitEvents.push({
- ...event,
- startDate: currentStart.toDate(),
- endDate: currentStart.endOf("day").isAfter(event.endDate) ? event.endDate : currentStart.endOf("day").toDate(),
- });
-
- currentStart = currentStart.add(1, "day").startOf("day");
- }
- }
- return splitEvents;
-};
diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts
index d03b2f610..3f0abfea0 100644
--- a/packages/widgets/src/calendar/index.ts
+++ b/packages/widgets/src/calendar/index.ts
@@ -9,6 +9,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
+ queryKey: [["widget", "calendar"]],
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
diff --git a/packages/widgets/src/clock/component.tsx b/packages/widgets/src/clock/component.tsx
index dd43e0380..dad04fe80 100644
--- a/packages/widgets/src/clock/component.tsx
+++ b/packages/widgets/src/clock/component.tsx
@@ -16,7 +16,7 @@ dayjs.extend(advancedFormat);
dayjs.extend(utc);
dayjs.extend(timezones);
-export default function ClockWidget({ options, width }: WidgetComponentProps<"clock">) {
+export default function ClockWidget({ options, width, height, displayMode }: WidgetComponentProps<"clock">) {
const secondsFormat = options.showSeconds ? ":ss" : "";
const timeFormat = options.is24HourFormat ? `HH:mm${secondsFormat}` : `hh:mm${secondsFormat} A`;
const dateFormat = options.dateFormat;
@@ -25,7 +25,8 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
const timezone = options.useCustomTimezone ? options.timezone : Intl.DateTimeFormat().resolvedOptions().timeZone;
const time = useCurrentTime(options);
- const sizing = width < 128 ? "xs" : width < 196 ? "sm" : "md";
+ const minimumAxis = Math.min(width, height * 1.5);
+ const sizing = minimumAxis < 128 ? "xs" : minimumAxis < 196 ? "sm" : "md";
const showWeatherCorner = options.showWeather && sizing !== "xs";
return (
@@ -35,6 +36,7 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
latitude={options.weatherLocation.latitude}
longitude={options.weatherLocation.longitude}
isFahrenheit={options.isWeatherFormatFahrenheit}
+ showDetails={displayMode === "advanced"}
/>
)}
@@ -44,9 +46,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
)}
- {options.customTimeFormat
- ? dayjs(time).tz(timezone).format(customTimeFormat)
- : dayjs(time).tz(timezone).format(timeFormat)}
+
+ {options.customTimeFormat
+ ? dayjs(time).tz(timezone).format(customTimeFormat)
+ : dayjs(time).tz(timezone).format(timeFormat)}
+
{options.showDate && (
@@ -55,6 +59,11 @@ export default function ClockWidget({ options, width }: WidgetComponentProps<"cl
: dayjs(time).tz(timezone).format(dateFormat)}
)}
+ {displayMode === "advanced" && (
+
+ {timezone} · UTC{dayjs(time).tz(timezone).format("Z")}
+
+ )}
);
@@ -64,9 +73,10 @@ interface ClockWeatherCornerProps {
latitude: number;
longitude: number;
isFahrenheit: boolean;
+ showDetails: boolean;
}
-const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherCornerProps) => {
+const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => {
const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude });
if (!weather) return null;
@@ -81,6 +91,11 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit }: ClockWeatherC
{Math.round(temp)}
{unit}
+ {showDetails && (
+
+ {Math.round(weather.current.windspeed)} km/h
+
+ )}
);
};
diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts
index 20b22d6a0..3fe152b73 100644
--- a/packages/widgets/src/clock/index.ts
+++ b/packages/widgets/src/clock/index.ts
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
+ queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx
index 46940f56e..0e0a5c801 100644
--- a/packages/widgets/src/coolify/component.tsx
+++ b/packages/widgets/src/coolify/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ScrollArea, Stack, Text } from "@mantine/core";
+import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
@@ -10,7 +10,12 @@ import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
-export default function CoolifyWidget({ options, integrationIds, width }: WidgetComponentProps<"coolify">) {
+export default function CoolifyWidget({
+ options,
+ integrationIds,
+ width,
+ displayMode,
+}: WidgetComponentProps<"coolify">) {
const t = useScopedI18n("widget.coolify");
if (integrationIds.length === 0) {
@@ -21,29 +26,45 @@ export default function CoolifyWidget({ options, integrationIds, width }: Widget
);
}
- return ;
+ return (
+
+ );
}
interface CoolifyContentProps {
integrationIds: string[];
options: WidgetComponentProps<"coolify">["options"];
width: number;
+ isAdvanced: boolean;
}
-function CoolifyContent({ integrationIds, options, width }: CoolifyContentProps) {
+function CoolifyContent({ integrationIds, options, width, isAdvanced }: CoolifyContentProps) {
const { data: instancesData = [] } = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds });
- const isTiny = width < 256;
+ const isTiny = !isAdvanced && width < 256;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
if (instancesData.length === 1 && firstInstance) {
- return ;
+ return (
+
+ );
}
return (
-
+ = 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
))}
-
+
);
}
diff --git a/packages/widgets/src/coolify/index.ts b/packages/widgets/src/coolify/index.ts
index cebf9de05..e68e4c839 100644
--- a/packages/widgets/src/coolify/index.ts
+++ b/packages/widgets/src/coolify/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
icon: IconCloud,
+ queryKey: [["widget", "coolify"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showServers: factory.switch({
diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx
index 0f4522dc4..2cc694f9c 100644
--- a/packages/widgets/src/coolify/instance-card.tsx
+++ b/packages/widgets/src/coolify/instance-card.tsx
@@ -1,5 +1,6 @@
"use client";
+import { useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -18,9 +19,10 @@ interface InstanceCardProps {
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
+ isAdvanced: boolean;
}
-export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceCardProps) {
+export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced }: InstanceCardProps) {
const t = useScopedI18n("widget.coolify");
const cardKey = `${widgetKey}-${instance.integrationId}`;
const [showIp, setShowIp] = useLocalStorage({
@@ -31,6 +33,12 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
+ const visibleSections = [
+ options.showServers ? "servers" : null,
+ options.showApplications ? "applications" : null,
+ options.showServices ? "services" : null,
+ ].filter((section): section is string => section !== null);
+ const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -90,7 +98,13 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
-
+
{options.showServers && (
section !== null);
+ const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
@@ -56,7 +64,13 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey }: S
-
+
{options.showServers && (
}) {
);
}
-function TableDisplay({ data }: { data: Record }) {
+interface CustomDisplayProps {
+ data: Record;
+ displayMode: "compact" | "advanced";
+ width: number;
+ height: number;
+}
+
+function TableDisplay({ data, displayMode, width, height }: CustomDisplayProps) {
const columns = (data.columns as string[]) ?? [];
const rows = (data.rows as unknown[][]) ?? [];
const striped = (data.striped as boolean) ?? true;
const compact = (data.compact as boolean) ?? false;
+ const visibleColumnCount = displayMode === "advanced" ? columns.length : Math.max(1, Math.floor(width / 120));
+ const visibleRowCount = displayMode === "advanced" ? rows.length : Math.max(1, Math.floor(height / 32) - 1);
+ const visibleColumns = columns.slice(0, visibleColumnCount);
+
return (
-
+
- {columns.map((col, i) => (
+ {visibleColumns.map((col, i) => (
{col}
@@ -115,9 +128,9 @@ function TableDisplay({ data }: { data: Record }) {
- {rows.map((row, i) => (
+ {rows.slice(0, visibleRowCount).map((row, i) => (
- {row.map((cell, j) => (
+ {row.slice(0, visibleColumnCount).map((cell, j) => (
{String(cell ?? "—")}
@@ -181,9 +194,11 @@ function StatGridCard({
);
}
-function StatGridDisplay({ data }: { data: Record }) {
+function StatGridDisplay({ data, displayMode, width }: CustomDisplayProps) {
const items = (data.items as Array<{ label: string; unit: string; color: string; value: unknown }>) ?? [];
- const columns = (data.columns as number) ?? 2;
+ const configuredColumns = (data.columns as number) ?? 2;
+ const columns =
+ displayMode === "advanced" ? configuredColumns : Math.min(configuredColumns, Math.max(1, Math.floor(width / 120)));
const cardStyle = (data.cardStyle as string) ?? "filled";
return (
@@ -304,12 +319,12 @@ function CountGridDisplay({ data }: { data: Record }) {
function openJsonInBrowser(json: unknown) {
const blob = new Blob([JSON.stringify(json, null, 2)], { type: "application/json" });
const blobUrl = URL.createObjectURL(blob);
- window.open(blobUrl);
+ window.open(blobUrl, "_blank", "noopener,noreferrer");
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
-function RawDisplay({ data }: { data: Record }) {
- const maxHeight = (data.maxHeight as number) ?? 300;
+function RawDisplay({ data, displayMode, height }: CustomDisplayProps) {
+ const maxHeight = displayMode === "advanced" ? Math.max(160, height - 80) : ((data.maxHeight as number) ?? 300);
const jsonString = JSON.stringify(data.data, null, 2);
return (
@@ -387,7 +402,9 @@ function ActionButtonDisplay({ data }: { data: Record }) {
);
}
-export const displayComponents: Record }>> = {
+const CustomJsxAdapter = ({ data }: CustomDisplayProps) => ;
+
+export const displayComponents: Record> = {
singleValue: SingleValueDisplay,
keyValue: KeyValueDisplay,
table: TableDisplay,
@@ -397,10 +414,29 @@ export const displayComponents: Record) {
+const displayTypeTranslationKeys = {
+ singleValue: "displayType.singleValue",
+ keyValue: "displayType.keyValue",
+ table: "displayType.table",
+ statGrid: "displayType.statGrid",
+ progressBars: "displayType.progressBars",
+ statusIndicator: "displayType.statusIndicator",
+ countGrid: "displayType.countGrid",
+ raw: "displayType.raw",
+ actionButton: "displayType.actionButton",
+ customJsx: "displayType.customJsx",
+} as const;
+
+export default function CustomApiWidget({
+ options,
+ width,
+ height,
+ displayMode = "compact",
+ widgetStateRef,
+}: WidgetComponentProps<"customApi">) {
const t = useScopedI18n("widget.customApi");
const { definitionId, refreshInterval } = options;
@@ -417,20 +453,45 @@ export default function CustomApiWidget({ options }: WidgetComponentProps<"custo
);
}
- return ;
+ return (
+
+ );
}
-function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId: string; refreshInterval: number }) {
+function CustomApiWidgetInner({
+ definitionId,
+ refreshInterval,
+ width,
+ height,
+ displayMode,
+ widgetStateRef,
+}: {
+ definitionId: string;
+ refreshInterval: number;
+ width: number;
+ height: number;
+ displayMode: "compact" | "advanced";
+ widgetStateRef?: WidgetComponentProps<"customApi">["widgetStateRef"];
+}) {
const t = useScopedI18n("widget.customApi");
const tCustomWidget = useScopedI18n("customWidget");
const safeInterval = Number.isFinite(refreshInterval) ? refreshInterval : 30;
const intervalMs = Math.max(1000, safeInterval * 1000);
- const { data, isLoading, error } = clientApi.widget.customApi.getData.useQuery(
+ const persistedState = widgetStateRef?.current?.customApi as { pollingPaused?: boolean } | undefined;
+ const [pollingPaused, setPollingPaused] = useState(persistedState?.pollingPaused === true);
+ const { data, isLoading, error, refetch, isFetching } = clientApi.widget.customApi.getData.useQuery(
{ definitionId },
{
refetchInterval: (query) => {
const result = query.state.data as Record | undefined;
- if (result?.type === "actionButton" || result?.type === "disabled") return false;
+ if (pollingPaused || result?.type === "actionButton" || result?.type === "disabled") return false;
return intervalMs;
},
retry: (failureCount, err) => {
@@ -440,6 +501,33 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
},
);
+ const widgetData = (data ?? {}) as Record;
+ const dataType = widgetData.type as string | undefined;
+ const canTogglePolling = Boolean(data) && dataType !== "actionButton" && dataType !== "disabled";
+ const togglePolling = useCallback(() => setPollingPaused((value) => !value), []);
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ customApi: { pollingPaused },
+ };
+ }, [pollingPaused, widgetStateRef]);
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ if (canTogglePolling) {
+ widgetStateRef.current = { ...widgetStateRef.current, togglePolling };
+ } else if (widgetStateRef.current) {
+ delete widgetStateRef.current.togglePolling;
+ }
+ return () => {
+ if (widgetStateRef.current?.togglePolling === togglePolling) {
+ delete widgetStateRef.current.togglePolling;
+ }
+ };
+ }, [canTogglePolling, togglePolling, widgetStateRef]);
+
if (isLoading) {
return (
@@ -454,9 +542,17 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
-
- {isNotFound ? t("definitionNotFound") : error.message}
-
+ {isNotFound ? (
+
+ {t("definitionNotFound")}
+
+ ) : (
+
+
+ {t("fetchFailed")}
+
+
+ )}
);
@@ -464,9 +560,6 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
if (!data) return null;
- const widgetData = data as Record;
- const dataType = widgetData.type as string | undefined;
-
if (dataType === "disabled") {
return (
@@ -479,8 +572,34 @@ function CustomApiWidgetInner({ definitionId, refreshInterval }: { definitionId:
const Component = dataType ? displayComponents[dataType] : undefined;
if (Component) {
+ const displayTypeKey = displayTypeTranslationKeys[dataType as keyof typeof displayTypeTranslationKeys];
+ const pollingLabel = pollingPaused ? t("actions.resumePolling") : t("actions.pausePolling");
const enrichedData = dataType === "actionButton" ? { ...widgetData, widgetDefinitionId: definitionId } : widgetData;
- return ;
+ const content = ;
+ if (displayMode === "compact") return content;
+ return (
+
+
+ {displayTypeKey ? t(displayTypeKey) : t("displayType.unknown")}
+ {dataType !== "actionButton" && (
+
+ {
+ if (pollingPaused) void refetch();
+ setPollingPaused((value) => !value);
+ }}
+ >
+ {pollingPaused ? : }
+
+
+ )}
+
+ {content}
+
+ );
}
return (
diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts
index 2bb9d3867..ed8242cab 100644
--- a/packages/widgets/src/custom-api/index.ts
+++ b/packages/widgets/src/custom-api/index.ts
@@ -1,4 +1,4 @@
-import { IconApi } from "@tabler/icons-react";
+import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition } from "../definition";
@@ -6,6 +6,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
+ queryKey: [["widget", "customApi"]],
+ contextActions({ options, widgetStateRef }) {
+ return [
+ {
+ key: "togglePolling",
+ label: (t) => t("widget.customApi.actions.togglePolling"),
+ icon: IconPlayerPause,
+ hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
+ disabled: typeof widgetStateRef.current?.togglePolling !== "function",
+ onClick: () => {
+ const action = widgetStateRef.current?.togglePolling;
+ if (typeof action === "function") action();
+ },
+ },
+ ];
+ },
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts
new file mode 100644
index 000000000..ce08a0ace
--- /dev/null
+++ b/packages/widgets/src/definition.spec.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "vitest";
+
+import { getWidgetQueryKeys } from "./definition";
+
+describe("getWidgetQueryKeys", () => {
+ test("falls back to the widget router namespace", () => {
+ expect(getWidgetQueryKeys({ kind: "example" })).toEqual([[["widget", "example"]]]);
+ });
+
+ test("wraps a singular explicit key", () => {
+ expect(getWidgetQueryKeys({ kind: "example", queryKey: [["app", "byId"]] })).toEqual([[["app", "byId"]]]);
+ });
+
+ test("preserves every explicit key for multi-root widgets", () => {
+ const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
+
+ expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
+ });
+});
diff --git a/packages/widgets/src/definition.ts b/packages/widgets/src/definition.ts
index 3ba53987e..775963b28 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -26,6 +26,7 @@ export interface WidgetContextMenuContext {
isEditMode: boolean;
boardId: string | undefined;
itemId: string | undefined;
+ canInteractWithSelectedIntegrations: boolean;
}
export interface WidgetContextActionProps<
@@ -70,6 +71,7 @@ export const createWidgetDefinition = , "kind">) => WidgetContextMenuAction[];
}
+export const getWidgetQueryKeys = (definition: {
+ kind: string;
+ queryKey?: QueryKey;
+ queryKeys?: readonly QueryKey[];
+}): readonly QueryKey[] => {
+ if (definition.queryKeys && definition.queryKeys.length > 0) return definition.queryKeys;
+ return [definition.queryKey ?? [["widget", definition.kind]]];
+};
+
export interface WidgetProps {
options: inferOptionsFromCreator>;
integrationIds: string[];
@@ -99,6 +110,7 @@ export interface WidgetProps {
export type WidgetComponentProps = WidgetProps & {
boardId: string | undefined; // undefined when in preview mode
isEditMode: boolean;
+ displayMode?: "compact" | "advanced";
setOptions: ({ newOptions }: { newOptions: Partial>> }) => void;
width: number;
height: number;
diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx
index f46c3d746..e6072ff95 100644
--- a/packages/widgets/src/dns-hole/controls/component.tsx
+++ b/packages/widgets/src/dns-hole/controls/component.tsx
@@ -44,6 +44,8 @@ export default function DnsHoleControlsWidget({
integrationIds,
isEditMode,
width,
+ height,
+ displayMode,
}: WidgetComponentProps) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -56,13 +58,21 @@ export default function DnsHoleControlsWidget({
});
const utils = clientApi.useUtils();
- const { mutate: enableDns } = clientApi.widget.dnsHole.enable.useMutation({
+ const {
+ mutateAsync: enableDns,
+ isPending: isEnabling,
+ error: enableError,
+ } = clientApi.widget.dnsHole.enable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
- const { mutate: disableDns } = clientApi.widget.dnsHole.disable.useMutation({
+ const {
+ mutateAsync: disableDns,
+ isPending: isDisabling,
+ error: disableError,
+ } = clientApi.widget.dnsHole.disable.useMutation({
onSettled: () => void utils.widget.dnsHole.summary.invalidate(),
});
- const toggleDns = (integrationId: string) => {
+ const toggleDns = async (integrationId: string) => {
const integrationStatus = summaries.find(({ integration }) => integration.id === integrationId);
if (!integrationStatus?.summary.status) return;
utils.widget.dnsHole.summary.setData(
@@ -86,9 +96,9 @@ export default function DnsHoleControlsWidget({
},
);
if (integrationStatus.summary.status === "enabled") {
- disableDns({ integrationId, duration: 0 });
+ await disableDns({ integrationId, duration: 0 });
} else {
- enableDns({ integrationId });
+ await enableDns({ integrationId });
}
};
@@ -103,9 +113,21 @@ export default function DnsHoleControlsWidget({
// Timer modal setup
const [selectedIntegrationIds, setSelectedIntegrationIds] = useState([]);
+ const [bulkPending, setBulkPending] = useState(false);
+ const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
- const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
+ const controlAllButtonsVisible =
+ (options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
+ const actionsPending = bulkPending || isEnabling || isDisabling;
+ const actionError = enableError ?? disableError;
+ const runBulkToggle = async (integrationIdsToToggle: string[]) => {
+ setBulkPending(true);
+ setBulkFailureCount(0);
+ const results = await Promise.allSettled(integrationIdsToToggle.map(toggleDns));
+ setBulkFailureCount(results.filter(({ status }) => status === "rejected").length);
+ setBulkPending(false);
+ };
return (
integrationsSummaries.disabled.forEach((integrationId) => toggleDns(integrationId))}
- disabled={integrationsSummaries.disabled.length === 0}
+ onClick={() => void runBulkToggle(integrationsSummaries.disabled)}
+ disabled={integrationsSummaries.disabled.length === 0 || actionsPending}
variant="light"
color="green"
bd={0}
@@ -144,7 +166,7 @@ export default function DnsHoleControlsWidget({
setSelectedIntegrationIds(integrationsSummaries.enabled);
open();
}}
- disabled={integrationsSummaries.enabled.length === 0}
+ disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="yellow"
bd={0}
@@ -160,8 +182,8 @@ export default function DnsHoleControlsWidget({
size="xs"
p={0}
className="dns-hole-controls-disable-all-button"
- onClick={() => integrationsSummaries.enabled.forEach((integrationId) => toggleDns(integrationId))}
- disabled={integrationsSummaries.enabled.length === 0}
+ onClick={() => void runBulkToggle(integrationsSummaries.enabled)}
+ disabled={integrationsSummaries.enabled.length === 0 || actionsPending}
variant="light"
color="red"
bd={0}
@@ -192,16 +214,32 @@ export default function DnsHoleControlsWidget({
t={t}
hasIconColor={board.iconColor !== null}
rootWidth={width}
+ rootHeight={height}
+ isAdvanced={displayMode === "advanced"}
+ actionsPending={actionsPending}
/>
))}
+ {bulkFailureCount > 0 && (
+
+ {t("widget.dnsHoleControls.error.bulkActionsFailed", { count: bulkFailureCount })}
+
+ )}
+ {actionError && (
+
+
+ {t("widget.dnsHoleControls.error.internalServerError")}
+
+
+ )}
+
void disableDns(input).catch(() => undefined)}
/>
);
@@ -209,13 +247,16 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
- toggleDns: (integrationId: string) => void;
+ toggleDns: (integrationId: string) => Promise;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
hasIconColor: boolean;
rootWidth: number;
+ rootHeight: number;
+ isAdvanced: boolean;
+ actionsPending: boolean;
}
const ControlsCard: React.FC = ({
@@ -227,16 +268,19 @@ const ControlsCard: React.FC = ({
t,
hasIconColor,
rootWidth,
+ rootHeight,
+ isAdvanced,
+ actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
const isEnabled = data.summary.status ? data.summary.status === "enabled" : undefined;
const isInteractPermitted = integrationsWithInteractions.includes(data.integration.id);
// Use all factors to infer the state of the action buttons
- const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected;
+ const controlEnabled = isInteractPermitted && isEnabled !== undefined && isConnected && !actionsPending;
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
- const layout = rootWidth < 256 ? "sm" : "md";
+ const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
return (
= ({
{!isEnabled ? (
toggleDns(data.integration.id)}
+ onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="green"
@@ -306,7 +350,7 @@ const ControlsCard: React.FC = ({
) : (
toggleDns(data.integration.id)}
+ onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
disabled={!controlEnabled}
size="sm"
color="red"
@@ -323,6 +367,8 @@ const ControlsCard: React.FC = ({
size="sm"
color="yellow"
variant="light"
+ display={isInteractPermitted ? undefined : "none"}
+ disabled={!controlEnabled || !isEnabled}
>
@@ -334,7 +380,7 @@ const ControlsCard: React.FC = ({
disabled={!controlEnabled}
display="contents"
style={{ cursor: controlEnabled ? "pointer" : "default" }}
- onClick={() => toggleDns(data.integration.id)}
+ onClick={() => void toggleDns(data.integration.id).catch(() => undefined)}
>
) {
+export default function DnsHoleSummaryWidget({
+ options,
+ integrationIds,
+ displayMode,
+}: WidgetComponentProps) {
const { data: summaries = [] } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
@@ -27,28 +43,44 @@ export default function DnsHoleSummaryWidget({ options, integrationIds }: Widget
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
+ const layoutProps = boxPropsByLayout(options.layout);
return (
-
- {data.length > 0 ? (
- stats.map((item) => (
-
- ))
- ) : (
-
-
- {summaries.map(({ integration }) => (
-
-
-
- ))}
-
-
- {t("widget.dnsHoleSummary.error.integrationsDisconnected")}
-
-
+
+
+ {data.length > 0 ? (
+ stats.map((item) => (
+
+ ))
+ ) : (
+
+
+ {summaries.map(({ integration }) => (
+
+
+
+ ))}
+
+
+ {t("widget.dnsHoleSummary.error.integrationsDisconnected")}
+
+
+ )}
+
+ {displayMode === "advanced" && summaries.length > 0 && (
+
+ {summaries.map(({ integration, summary }) => (
+
+ {integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
+
+ ))}
+
)}
-
+
);
}
diff --git a/packages/widgets/src/docker/component.tsx b/packages/widgets/src/docker/component.tsx
index e56cd5cf7..12e8ca092 100644
--- a/packages/widgets/src/docker/component.tsx
+++ b/packages/widgets/src/docker/component.tsx
@@ -17,6 +17,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
+import { getDockerColumnVisibility } from "./layout";
const ContainerStateBadge = ({ state }: { state: ContainerState }) => {
const t = useScopedI18n("docker.field.state.option");
@@ -35,6 +36,7 @@ const actionIconIconStyle: IconProps["style"] = {
const createColumns = (
t: ReturnType>,
+ isEditMode: boolean,
): MRT_ColumnDef[] => [
{
id: "name",
@@ -160,6 +162,7 @@ const createColumns = (
variant="subtle"
size="xs"
radius="100%"
+ disabled={isEditMode}
onClick={() => handleActionAsync(row.original.state === "running" ? "stop" : "start")}
>
{row.original.state === "running" ? (
@@ -170,7 +173,13 @@ const createColumns = (
- handleActionAsync("restart")}>
+ handleActionAsync("restart")}
+ >
@@ -180,12 +189,18 @@ const createColumns = (
},
];
-export default function DockerWidget({ options, width, isEditMode }: WidgetComponentProps<"dockerContainers">) {
+export default function DockerWidget({
+ options,
+ width,
+ isEditMode,
+ displayMode,
+}: WidgetComponentProps<"dockerContainers">) {
const t = useScopedI18n("docker");
- const isTiny = width <= 256;
+ const isAdvanced = displayMode === "advanced";
+ const isTiny = !isAdvanced && width <= 256;
const { data, refetch, isFetching } = clientApi.docker.getContainers.useQuery();
- const containers = data?.containers ?? [];
+ const containers = useMemo(() => data?.containers ?? [], [data?.containers]);
const timestamp = useMemo(() => data?.timestamp ?? new Date(), [data?.timestamp]);
const relativeTime = useTimeAgo(timestamp);
@@ -202,33 +217,26 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
);
}, [containers]);
- const columns = useMemo(() => createColumns(t), [t]);
+ const columns = useMemo(() => createColumns(t, isEditMode), [isEditMode, t]);
- const columnVisibility: MRT_VisibilityState = {
- name: options.columns.includes("name"),
- state: options.columns.includes("state"),
- host: options.columns.includes("host"),
- cpuUsage: options.columns.includes("cpuUsage"),
- memoryUsage: options.columns.includes("memoryUsage"),
- actions: options.columns.includes("actions"),
- };
+ const columnVisibility: MRT_VisibilityState = getDockerColumnVisibility(options.columns, width, isAdvanced);
const table = useTranslatedMantineReactTable({
columns,
data: containers,
enablePagination: false,
- enableTopToolbar: false,
+ enableTopToolbar: isAdvanced,
enableBottomToolbar: false,
- enableColumnActions: false,
- enableSorting: options.enableRowSorting && !isEditMode,
- enableStickyHeader: false,
- enableColumnOrdering: false,
+ enableColumnActions: isAdvanced,
+ enableSorting: (isAdvanced || options.enableRowSorting) && !isEditMode,
+ enableStickyHeader: isAdvanced,
+ enableColumnOrdering: isAdvanced && !isEditMode,
enableRowSelection: false,
enableFullScreenToggle: false,
- enableGlobalFilter: false,
+ enableGlobalFilter: isAdvanced,
enableDensityToggle: false,
enableFilters: false,
- enableHiding: false,
+ enableHiding: isAdvanced,
initialState: {
sorting: [{ id: options.defaultSort, desc: options.descendingDefaultSort }],
density: "xs",
@@ -259,11 +267,12 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
mantineTableContainerProps: {
style: {
height: "100%",
+ pointerEvents: isEditMode ? "none" : undefined,
},
},
});
- if (options.columns.length === 0)
+ if (!isAdvanced && options.columns.length === 0)
return (
{t("error.noColumns")}
@@ -274,7 +283,7 @@ export default function DockerWidget({ options, width, isEditMode }: WidgetCompo
- {!isTiny && (
+ {(isAdvanced || !isTiny) && (
{
+ test("keeps only essential columns in a narrow compact widget", () => {
+ expect(getDockerColumnVisibility(columns, 240, false)).toEqual({
+ name: true,
+ state: true,
+ host: false,
+ cpuUsage: false,
+ memoryUsage: false,
+ actions: true,
+ });
+ });
+
+ test("reveals compact metrics as width becomes available", () => {
+ expect(getDockerColumnVisibility(columns, 380, false)).toMatchObject({
+ host: false,
+ cpuUsage: true,
+ memoryUsage: true,
+ });
+ });
+
+ test("uses every expert column in advanced mode", () => {
+ expect(getDockerColumnVisibility([], 240, true)).toEqual({
+ name: true,
+ state: true,
+ host: true,
+ cpuUsage: true,
+ memoryUsage: true,
+ actions: true,
+ });
+ });
+});
diff --git a/packages/widgets/src/docker/layout.ts b/packages/widgets/src/docker/layout.ts
new file mode 100644
index 000000000..4d3b1029b
--- /dev/null
+++ b/packages/widgets/src/docker/layout.ts
@@ -0,0 +1,19 @@
+export type DockerColumn = "name" | "state" | "host" | "cpuUsage" | "memoryUsage" | "actions";
+
+export const getDockerColumnVisibility = (
+ configuredColumns: readonly DockerColumn[],
+ width: number,
+ isAdvanced: boolean,
+): Record => {
+ const configured = new Set(configuredColumns);
+ const isVisible = (column: DockerColumn) => isAdvanced || configured.has(column);
+
+ return {
+ name: isVisible("name"),
+ state: isVisible("state"),
+ host: isVisible("host") && (isAdvanced || width >= 440),
+ cpuUsage: isVisible("cpuUsage") && (isAdvanced || width >= 280),
+ memoryUsage: isVisible("memoryUsage") && (isAdvanced || width >= 360),
+ actions: isVisible("actions"),
+ };
+};
diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx
index e03c937bf..967288fc4 100644
--- a/packages/widgets/src/downloads/component.tsx
+++ b/packages/widgets/src/downloads/component.tsx
@@ -44,7 +44,9 @@ import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import { DataTable, useDataTableColumns } from "mantine-datatable";
import { clientApi } from "@homarr/api/client";
-import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
+import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client";
+import { constructBoardPermissions } from "@homarr/auth/shared";
+import { useOptionalBoard } from "@homarr/boards/context";
import { formatByteRate, formatBytes, useIntegrationConnected } from "@homarr/common";
import { getIconUrl, getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedClientStatus, ExtendedDownloadClientItem } from "@homarr/integrations";
@@ -52,9 +54,12 @@ import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
+import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
dayjs.extend(relativeTime);
+const COLUMN_WIDTH_SAVE_DELAY_MS = 200;
+
type DownloadsT = ReturnType>;
type DownloadState = ExtendedDownloadClientItem["state"];
@@ -73,6 +78,14 @@ interface ContextMenuState {
item: ExtendedDownloadClientItem;
}
+interface DownloadsWidgetState {
+ integrationKey: string;
+ clientFilter: string[];
+ statusFilter: string[];
+ sortStatus: DataTableSortStatus;
+ showStats: boolean;
+}
+
interface ColumnContext {
optionsColumnSet: Set;
hasTorrents: boolean;
@@ -149,7 +162,10 @@ const speedColumnConfig = {
up: { Icon: IconUpload, color: "green" as const },
} as const;
-function getSizeConfig(width: number): SizeConfig {
+export function getSizeConfig(width: number, height = Number.POSITIVE_INFINITY, isAdvanced = false): SizeConfig {
+ if (isAdvanced) return DEFAULT_SIZE_CONFIG;
+ if (height < 120) return SIZE_BREAKPOINTS[0]?.config ?? DEFAULT_SIZE_CONFIG;
+ if (height < 180) return SIZE_BREAKPOINTS[1]?.config ?? DEFAULT_SIZE_CONFIG;
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
if (width < maxWidth) return config;
}
@@ -286,7 +302,13 @@ export default function DownloadClientsWidget({
boardId,
itemId,
width,
+ height,
+ displayMode,
+ widgetStateRef,
}: WidgetComponentProps<"downloads">) {
+ const board = useOptionalBoard();
+ const { data: session } = useSession();
+ const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false;
const allInteractAccess = useIntegrationsWithInteractAccess();
const integrationInteractSet = useMemo(
() => new Set(allInteractAccess.filter(({ id }) => integrationIds.includes(id)).map(({ id }) => id)),
@@ -304,10 +326,19 @@ export default function DownloadClientsWidget({
const t = useScopedI18n("widget.downloads");
- const [clientFilter, setClientFilter] = useState([]);
- const [statusFilter, setStatusFilter] = useState([]);
+ const integrationKey = integrationIds.join("\u0000");
+ const storedWidgetState = widgetStateRef?.current?.downloads as Partial | undefined;
+ const persistedWidgetState = storedWidgetState?.integrationKey === integrationKey ? storedWidgetState : undefined;
+
+ const [clientFilter, setClientFilter] = useState(() =>
+ Array.isArray(persistedWidgetState?.clientFilter) ? [...persistedWidgetState.clientFilter] : [],
+ );
+ const [statusFilter, setStatusFilter] = useState(() =>
+ Array.isArray(persistedWidgetState?.statusFilter) ? [...persistedWidgetState.statusFilter] : [],
+ );
const [contextMenu, setContextMenu] = useState(null);
- const [showStats, { toggle: toggleStats }] = useDisclosure(false);
+ const [showStats, { toggle: toggleStats }] = useDisclosure(persistedWidgetState?.showStats === true);
+ const isAdvanced = displayMode === "advanced";
const utils = clientApi.useUtils();
const mutationOptions = {
@@ -321,14 +352,21 @@ export default function DownloadClientsWidget({
const { mutate: mutatePauseQueue } = clientApi.widget.downloads.pause.useMutation(mutationOptions);
const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({
+ scope: { id: `downloads-options:${boardId ?? "preview"}:${itemId ?? "preview"}` },
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
});
+ const saveOptionToServer = useCallback(
+ (newOptions: Partial>) => {
+ if (hasChangeAccess && boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
+ },
+ [boardId, hasChangeAccess, itemId, saveItemOptions],
+ );
const persistOption = useCallback(
(newOptions: Partial>) => {
setOptions({ newOptions });
- if (boardId && itemId) saveItemOptions({ boardId, itemId, newOptions });
+ saveOptionToServer(newOptions);
},
- [setOptions, boardId, itemId, saveItemOptions],
+ [saveOptionToServer, setOptions],
);
const savedOrder = useMemo(() => parseJsonOption(options.columnOrder, [] as string[]), [options.columnOrder]);
@@ -339,26 +377,28 @@ export default function DownloadClientsWidget({
let defaultSortDirection: DataTableSortStatus["direction"] = "asc";
if (options.descendingDefaultSort) defaultSortDirection = "desc";
- const [sortStatus, setSortStatus] = useState>({
- columnAccessor: options.defaultSort,
- direction: defaultSortDirection,
- });
+ const [sortStatus, setSortStatus] = useState>(() =>
+ persistedWidgetState?.sortStatus
+ ? { ...persistedWidgetState.sortStatus }
+ : { columnAccessor: options.defaultSort, direction: defaultSortDirection },
+ );
+ const hasMountedSortDefaults = useRef(false);
useEffect(() => {
+ if (!hasMountedSortDefaults.current) {
+ hasMountedSortDefaults.current = true;
+ return;
+ }
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
}, [options.defaultSort, defaultSortDirection]);
- const data = useMemo(() => {
+ const eligibleData = useMemo(() => {
return currentItems
.filter(({ integration }) => integrationIds.includes(integration.id))
.flatMap((pair) =>
pair.data.items
.filter(({ category }) => matchesCategoryFilter(category, options.categoryFilter, options.filterIsWhitelist))
.filter((item) => showCompletedItem(item, options))
- .filter(
- ({ state }) =>
- (clientFilter.length === 0 || clientFilter.includes(pair.integration.id)) &&
- (statusFilter.length === 0 || statusFilter.includes(state)),
- )
+ .filter(() => clientFilter.length === 0 || clientFilter.includes(pair.integration.id))
.map((item): ExtendedDownloadClientItem => {
const received = item.received ?? Math.floor(item.size * item.progress);
const ids = [pair.integration.id];
@@ -389,9 +429,10 @@ export default function DownloadClientsWidget({
mutateResumeItem,
options,
clientFilter,
- statusFilter,
]);
+ const data = useMemo(() => filterDownloadItemsByStatus(eligibleData, statusFilter), [eligibleData, statusFilter]);
+
const sortedData = useMemo(() => {
const accessor = sortStatus.columnAccessor as keyof ExtendedDownloadClientItem;
const multiplier = getSortMultiplier(sortStatus.direction);
@@ -452,7 +493,7 @@ export default function DownloadClientsWidget({
const hasMultipleClients = clients.length > 1;
const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1;
- const size = useMemo(() => getSizeConfig(width), [width]);
+ const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]);
const optionsColumnSet = useMemo(() => new Set(options.columns), [options.columns]);
const columnContext = useMemo(
() => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }),
@@ -660,7 +701,7 @@ export default function DownloadClientsWidget({
}, [columnContext, t, size, progressColumnWidth]);
const storeKey = `downloads-${itemId ?? "preview"}-${[...options.columns].toSorted().join(",")}`;
- const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
+ const { effectiveColumns, columnsOrder, columnsWidth, isResizing, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns({
key: storeKey,
columns,
@@ -685,28 +726,46 @@ export default function DownloadClientsWidget({
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
const prevOrder = useRef(columnsOrder);
- const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
- const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
- prevWidths.current = columnsWidth;
-
- if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
- if (widthsChanged) {
- const widthMap: Record = {};
- for (const entry of columnsWidth) {
- const key = Object.keys(entry)[0];
- if (!key) continue;
- const w = entry[key as keyof typeof entry];
- if (typeof w === "number") widthMap[key] = w;
- else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
- }
- persistOption({ columnWidths: JSON.stringify(widthMap) });
- }
- }, [columnsOrder, columnsWidth, persistOption]);
+ }, [columnsOrder, persistOption]);
+
+ const prevWidths = useRef(columnsWidth);
+ const pendingColumnWidths = useRef(null);
+ const columnWidthSaveTimer = useRef | null>(null);
+ const saveOptionToServerRef = useRef(saveOptionToServer);
+ useEffect(() => {
+ saveOptionToServerRef.current = saveOptionToServer;
+ }, [saveOptionToServer]);
+ useEffect(() => {
+ if (!hydrated.current || isResizing) return;
+ const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
+ prevWidths.current = columnsWidth;
+ if (!widthsChanged) return;
+
+ const serializedWidths = JSON.stringify(columnWidthsToRecord(columnsWidth));
+ setOptions({ newOptions: { columnWidths: serializedWidths } });
+ pendingColumnWidths.current = serializedWidths;
+ if (columnWidthSaveTimer.current !== null) clearTimeout(columnWidthSaveTimer.current);
+ columnWidthSaveTimer.current = setTimeout(() => {
+ const value = pendingColumnWidths.current;
+ pendingColumnWidths.current = null;
+ columnWidthSaveTimer.current = null;
+ if (value !== null) saveOptionToServerRef.current({ columnWidths: value });
+ }, COLUMN_WIDTH_SAVE_DELAY_MS);
+ }, [columnsWidth, isResizing, setOptions]);
+ useEffect(
+ () => () => {
+ if (columnWidthSaveTimer.current !== null) clearTimeout(columnWidthSaveTimer.current);
+ const value = pendingColumnWidths.current;
+ pendingColumnWidths.current = null;
+ if (value !== null) saveOptionToServerRef.current({ columnWidths: value });
+ },
+ [],
+ );
const handleContextMenu = useCallback(
({ record, event }: { record: ExtendedDownloadClientItem; event: React.MouseEvent }) => {
@@ -731,7 +790,7 @@ export default function DownloadClientsWidget({
return traffic.up / traffic.down;
}, [clients]);
- const availableStatuses = useMemo(() => [...new Set(data.map(({ state }) => state))], [data]);
+ const availableStatuses = useMemo(() => getAvailableDownloadStates(eligibleData), [eligibleData]);
const queueStats = useMemo(() => {
const stateCounts: Record = {};
@@ -745,6 +804,20 @@ export default function DownloadClientsWidget({
return { stateCounts, totalSize, completedSize, totalItems: data.length };
}, [data]);
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ downloads: {
+ integrationKey,
+ clientFilter,
+ statusFilter,
+ sortStatus,
+ showStats,
+ } satisfies DownloadsWidgetState,
+ };
+ }, [clientFilter, integrationKey, showStats, sortStatus, statusFilter, widgetStateRef]);
+
if (options.columns.length === 0) {
return (
@@ -769,7 +842,7 @@ export default function DownloadClientsWidget({
return (
- {showStats && (
+ {(showStats || isAdvanced) && (
-
+ {(isAdvanced || height >= 96) && (
+
+ )}
{contextMenu && }
diff --git a/packages/widgets/src/downloads/helpers.spec.ts b/packages/widgets/src/downloads/helpers.spec.ts
new file mode 100644
index 000000000..09a4aa042
--- /dev/null
+++ b/packages/widgets/src/downloads/helpers.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, test } from "vitest";
+
+import { columnWidthsToRecord, filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers";
+
+describe("downloads filters", () => {
+ const eligibleItems = [{ state: "downloading" as const }, { state: "seeding" as const }];
+
+ test("keeps all eligible statuses available after applying a status filter", () => {
+ expect(filterDownloadItemsByStatus(eligibleItems, ["downloading"])).toEqual([{ state: "downloading" }]);
+ expect(getAvailableDownloadStates(eligibleItems)).toEqual(["downloading", "seeding"]);
+ });
+
+ test("deduplicates available statuses", () => {
+ expect(getAvailableDownloadStates([...eligibleItems, { state: "downloading" }])).toEqual([
+ "downloading",
+ "seeding",
+ ]);
+ });
+});
+
+describe("downloads column widths", () => {
+ test("serializes numeric and pixel widths while ignoring automatic widths", () => {
+ expect(columnWidthsToRecord([{ name: 240 }, { progress: "120px" }, { state: "auto" }])).toEqual({
+ name: 240,
+ progress: 120,
+ });
+ });
+});
diff --git a/packages/widgets/src/downloads/helpers.ts b/packages/widgets/src/downloads/helpers.ts
new file mode 100644
index 000000000..2763d57ee
--- /dev/null
+++ b/packages/widgets/src/downloads/helpers.ts
@@ -0,0 +1,27 @@
+import type { ExtendedDownloadClientItem } from "@homarr/integrations";
+
+type DownloadState = ExtendedDownloadClientItem["state"];
+
+export function filterDownloadItemsByStatus(
+ items: readonly T[],
+ statusFilter: readonly string[],
+): T[] {
+ if (statusFilter.length === 0) return [...items];
+ return items.filter(({ state }) => statusFilter.includes(state));
+}
+
+export function getAvailableDownloadStates(items: readonly { state: DownloadState }[]): DownloadState[] {
+ return [...new Set(items.map(({ state }) => state))];
+}
+
+export function columnWidthsToRecord(columnsWidth: readonly Record[]): Record {
+ const result: Record = {};
+ for (const entry of columnsWidth) {
+ const key = Object.keys(entry)[0];
+ if (!key) continue;
+ const width = entry[key];
+ if (typeof width === "number") result[key] = width;
+ else if (typeof width === "string" && width.endsWith("px")) result[key] = parseInt(width, 10);
+ }
+ return result;
+}
diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts
index cc8f9a5b0..3f051110b 100644
--- a/packages/widgets/src/downloads/index.ts
+++ b/packages/widgets/src/downloads/index.ts
@@ -42,6 +42,7 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
+ queryKey: [["widget", "downloads"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/filter-storage-volumes.spec.ts b/packages/widgets/src/filter-storage-volumes.spec.ts
index 51d84169a..25a459c06 100644
--- a/packages/widgets/src/filter-storage-volumes.spec.ts
+++ b/packages/widgets/src/filter-storage-volumes.spec.ts
@@ -1,10 +1,6 @@
import { describe, expect, test } from "vitest";
-import {
- filterStorageVolumes,
- normalizeStorageDeviceName,
- toScopedStorageVolumeValue,
-} from "./filter-storage-volumes";
+import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
diff --git a/packages/widgets/src/filter-storage-volumes.ts b/packages/widgets/src/filter-storage-volumes.ts
index 5e88584be..9c406c65e 100644
--- a/packages/widgets/src/filter-storage-volumes.ts
+++ b/packages/widgets/src/filter-storage-volumes.ts
@@ -35,11 +35,7 @@ const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string
);
};
-const matchesVisibleStorageVolume = (
- visibleVolume: string,
- integrationId: string,
- deviceName: string,
-): boolean => {
+const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
diff --git a/packages/widgets/src/firewall/bandwidth.spec.ts b/packages/widgets/src/firewall/bandwidth.spec.ts
new file mode 100644
index 000000000..9b9250d98
--- /dev/null
+++ b/packages/widgets/src/firewall/bandwidth.spec.ts
@@ -0,0 +1,40 @@
+import { describe, expect, test } from "vitest";
+
+import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
+
+describe("calculateBandwidth", () => {
+ test("sorts samples before calculating a rate", () => {
+ const result = calculateBandwidth([
+ { timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 200 }] },
+ { timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 300, transmit: 500 }] },
+ ]);
+
+ expect(result.data).toEqual([{ name: "wan", receive: 160, transmit: 240 }]);
+ });
+
+ test("treats counter resets as zero instead of negative traffic", () => {
+ const result = calculateBandwidth([
+ { timestamp: new Date("2026-01-01T00:00:10Z"), data: [{ name: "wan", receive: 10, transmit: 10 }] },
+ { timestamp: new Date("2026-01-01T00:00:00Z"), data: [{ name: "wan", receive: 100, transmit: 100 }] },
+ ]);
+
+ expect(result.data).toEqual([{ name: "wan", receive: 0, transmit: 0 }]);
+ });
+
+ test("rejects duplicate timestamps", () => {
+ const timestamp = new Date("2026-01-01T00:00:00Z");
+ expect(
+ calculateBandwidth([
+ { timestamp, data: [{ name: "wan", receive: 100, transmit: 100 }] },
+ { timestamp, data: [{ name: "wan", receive: 50, transmit: 50 }] },
+ ]).data,
+ ).toEqual([]);
+ });
+});
+
+describe("formatBitsPerSec", () => {
+ test("handles invalid and negative rates", () => {
+ expect(formatBitsPerSec(Number.NaN, 2)).toBe("0 b/s");
+ expect(formatBitsPerSec(-1, 2)).toBe("0 b/s");
+ });
+});
diff --git a/packages/widgets/src/firewall/bandwidth.ts b/packages/widgets/src/firewall/bandwidth.ts
new file mode 100644
index 000000000..79744660c
--- /dev/null
+++ b/packages/widgets/src/firewall/bandwidth.ts
@@ -0,0 +1,39 @@
+import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
+
+export function formatBitsPerSec(bitsPerSecond: number, decimals: number): string {
+ if (!Number.isFinite(bitsPerSecond) || bitsPerSecond <= 0) return "0 b/s";
+
+ const unitSize = 1024;
+ const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
+ const unitIndex = Math.min(Math.floor(Math.log(bitsPerSecond) / Math.log(unitSize)), sizes.length - 1);
+
+ return `${parseFloat((bitsPerSecond / Math.pow(unitSize, unitIndex)).toFixed(decimals))} ${sizes[unitIndex]}`;
+}
+
+export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
+ const samples = data
+ .filter((sample) => Number.isFinite(new Date(sample.timestamp).getTime()))
+ .toSorted((left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime());
+ const latest = samples[0];
+ const previous = samples[1];
+
+ if (!latest || !previous) return { data: [] };
+
+ const timeDiffInSeconds = (new Date(latest.timestamp).getTime() - new Date(previous.timestamp).getTime()) / 1000;
+ if (timeDiffInSeconds <= 0) return { data: [] };
+
+ return {
+ data: latest.data.flatMap((latestInterface) => {
+ const previousInterface = previous.data.find((item) => item.name === latestInterface.name);
+ if (!previousInterface) return [];
+
+ return [
+ {
+ name: latestInterface.name,
+ receive: Math.max(0, (8 * (latestInterface.receive - previousInterface.receive)) / timeDiffInSeconds),
+ transmit: Math.max(0, (8 * (latestInterface.transmit - previousInterface.transmit)) / timeDiffInSeconds),
+ },
+ ];
+ }),
+ };
+}
diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx
index 523eadd8d..66a65b6d1 100644
--- a/packages/widgets/src/firewall/component.tsx
+++ b/packages/widgets/src/firewall/component.tsx
@@ -1,32 +1,51 @@
"use client";
import { useCallback, useState } from "react";
-import { Accordion, Box, Center, Flex, Group, RingProgress, ScrollArea, Text } from "@mantine/core";
+import {
+ Accordion,
+ Badge,
+ Center,
+ Flex,
+ Group,
+ Paper,
+ RingProgress,
+ ScrollArea,
+ SimpleGrid,
+ Stack,
+ Text,
+ Tooltip,
+} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import { IconArrowBarDown, IconArrowBarUp, IconBrain, IconCpu, IconTopologyBus } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
-import type { FirewallInterface, FirewallInterfacesSummary } from "@homarr/integrations";
+import type { FirewallInterfacesSummary } from "@homarr/integrations";
+import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
+import { calculateBandwidth, formatBitsPerSec } from "./bandwidth";
import { FirewallMenu } from "./firewall-menu";
-import { FirewallVersion } from "./firewall-version";
+import { FirewallVersion, formatVersion } from "./firewall-version";
export interface Firewall {
label: string;
value: string;
}
-export default function FirewallWidget({ integrationIds, width, itemId }: WidgetComponentProps<"firewall">) {
- const [selectedFirewall, setSelectedFirewall] = useState("");
+export default function FirewallWidget({
+ integrationIds,
+ width,
+ itemId,
+ displayMode,
+}: WidgetComponentProps<"firewall">) {
+ const [selectedFirewall, setSelectedFirewall] = useState("");
+ const isAdvanced = displayMode === "advanced";
+ const isTiny = !isAdvanced && width < 256;
+ const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
- if (value !== null) {
- setSelectedFirewall(value);
- } else {
- setSelectedFirewall("default_value");
- }
+ setSelectedFirewall(value ?? "");
}, []);
const { data: firewallsCpuData = [] } = clientApi.widget.firewall.getFirewallCpuStatus.useQuery({ integrationIds });
@@ -40,202 +59,275 @@ export default function FirewallWidget({ integrationIds, width, itemId }: Widget
integrationIds,
});
- const initialSelectedFirewall = firewallsVersionData[0] ? firewallsVersionData[0].integration.id : "undefined";
- const isTiny = width < 256;
-
const [accordionValue, setAccordionValue] = useLocalStorage({
key: `homarr-${itemId}-firewall`,
defaultValue: "interfaces",
});
+ const initialSelectedFirewall = firewallsVersionData[0]?.integration.id ?? "";
+ const activeFirewall = firewallsVersionData.some(({ integration }) => integration.id === selectedFirewall)
+ ? selectedFirewall
+ : initialSelectedFirewall;
+ const displayedFirewallIds = isAdvanced
+ ? firewallsVersionData.map(({ integration }) => integration.id)
+ : activeFirewall
+ ? [activeFirewall]
+ : [];
const dropdownItems = firewallsVersionData.map((firewall) => ({
label: firewall.integration.name,
value: firewall.integration.id,
}));
- const t = useI18n();
-
return (
-
-
-
-
-
- {/* Render CPU and Memory data */}
- {firewallsCpuData
- .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
- .map(({ summary, integration }) => (
-
- {`${summary.total.toFixed(2)}%`}
-
-
- }
- sections={[
- {
- value: Number(summary.total.toFixed(1)),
- color: summary.total > 50 ? (summary.total < 75 ? "yellow" : "red") : "green",
- },
- ]}
+
+ {!isAdvanced && (
+
+
- ))}
- {firewallsMemoryData
- .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
- .map(({ summary, integration }) => (
-
- {`${summary.percent.toFixed(1)}%`}
-
-
- }
- sections={[
- {
- value: Number(summary.percent.toFixed(1)),
- color: summary.percent > 50 ? (summary.percent < 75 ? "yellow" : "red") : "green",
- },
- ]}
+
- ))}
-
- {firewallsInterfacesData
- .filter(({ integration }) => integration.id === (selectedFirewall || initialSelectedFirewall))
- .map(({ summary }) => (
-
-
- }>
- {t("widget.firewall.widget.interfaces.title")}
-
-
-
- {Array.isArray(summary) && summary.every((item) => Array.isArray(item.data)) ? (
- calculateBandwidth(summary).data.map(({ name, receive, transmit }) => (
-
-
-
- {name}
-
-
-
-
-
- {formatBitsPerSec(transmit, 2)}
-
-
-
-
-
- {formatBitsPerSec(receive, 2)}
-
-
-
- ))
- ) : (
- No data available
- )}
-
-
-
-
- ))}
+
+ )}
+
+ = 720 ? 2 : 1} spacing="xs">
+ {displayedFirewallIds.map((firewallId) => {
+ const cpu = firewallsCpuData.find(({ integration }) => integration.id === firewallId);
+ const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
+ const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
+ const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
+ const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
+
+ return (
+
+ );
+ })}
+
+
);
}
-export function formatBitsPerSec(bytes: number, decimals: number): string {
- if (bytes === 0) return "0 b/s";
-
- const kilobyte = 1024;
- const sizes = ["b/s", "kb/s", "Mb/s", "Gb/s", "Tb/s", "Pb/s", "Eb/s", "Zb/s", "Yb/s"];
-
- const i = Math.floor(Math.log(bytes) / Math.log(kilobyte));
-
- return `${parseFloat((bytes / Math.pow(kilobyte, i)).toFixed(decimals))} ${sizes[i]}`;
+interface FirewallPanelProps {
+ name: string;
+ kind?: string;
+ version?: string;
+ cpu?: number;
+ memory?: number;
+ interfaces?: FirewallInterfacesSummary[];
+ hasError: boolean;
+ isAdvanced: boolean;
+ isTiny: boolean;
+ accordionValue: string | null;
+ setAccordionValue: (value: string | null) => void;
+ noDataLabel: string;
+ interfacesLabel: string;
+ t: TranslationFunction;
}
-export function calculateBandwidth(data: FirewallInterfacesSummary[]): { data: FirewallInterface[] } {
- const result = {
- data: [] as FirewallInterface[],
- timestamp: new Date().toISOString(),
- };
+const FirewallPanel = ({
+ name,
+ kind,
+ version,
+ cpu,
+ memory,
+ interfaces,
+ hasError,
+ isAdvanced,
+ isTiny,
+ accordionValue,
+ setAccordionValue,
+ noDataLabel,
+ interfacesLabel,
+ t,
+}: FirewallPanelProps) => (
+
+
+ {isAdvanced && (
+
+
+
+ {name}
+
+ {kind && (
+
+ {kind}
+
+ )}
+
+
+ {hasError && (
+
+
+ {t("common.error")}
+
+
+ )}
+
+ {formatVersion(version ?? "", t("widget.firewall.versionUnknown"))}
+
+
+
+ )}
- if (data.length > 1) {
- const firstData = data[0];
- const secondData = data[1];
+
+ {cpu !== undefined && (
+
+ )}
+ {memory !== undefined && (
+
+ )}
+
- if (firstData && secondData) {
- const time1 = new Date(firstData.timestamp);
- const time2 = new Date(secondData.timestamp);
- const timeDiffInSeconds = (time1.getTime() - time2.getTime()) / 1000;
+
+
+
+);
- firstData.data.forEach((iface) => {
- const ifaceName = iface.name;
- const recv1 = iface.receive;
- const trans1 = iface.transmit;
-
- const iface2 = secondData.data.find((i) => i.name === ifaceName);
-
- if (iface2) {
- const recv2 = iface2.receive;
- const trans2 = iface2.transmit;
- const recvDiff = recv1 - recv2;
- const transDiff = trans1 - trans2;
-
- result.data.push({
- name: ifaceName,
- receive: (8 * recvDiff) / timeDiffInSeconds,
- transmit: (8 * transDiff) / timeDiffInSeconds,
- });
- }
- });
- }
- }
-
- return result;
+interface MetricRingProps {
+ value: number;
+ icon: typeof IconCpu;
+ isTiny: boolean;
+ label: string;
+ t: TranslationFunction;
}
+
+const MetricRing = ({ value, icon: Icon, isTiny, label, t }: MetricRingProps) => {
+ const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
+ const status = getMetricStatus(safeValue);
+ const statusLabel = t(`widget.firewall.status.${status}`);
+
+ return (
+
+ {safeValue.toFixed(1)}%
+
+ {!isTiny && {statusLabel} }
+
+ }
+ sections={[
+ {
+ value: safeValue,
+ color: status === "warning" ? "yellow" : status === "critical" ? "red" : "green",
+ },
+ ]}
+ />
+ );
+};
+
+const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
+ if (value >= 75) return "critical";
+ if (value > 50) return "warning";
+ return "normal";
+};
+
+interface InterfacesPanelProps {
+ summary: FirewallInterfacesSummary[];
+ isAdvanced: boolean;
+ isTiny: boolean;
+ accordionValue: string | null;
+ setAccordionValue: (value: string | null) => void;
+ noDataLabel: string;
+ label: string;
+}
+
+const InterfacesPanel = ({
+ summary,
+ isAdvanced,
+ isTiny,
+ accordionValue,
+ setAccordionValue,
+ noDataLabel,
+ label,
+}: InterfacesPanelProps) => {
+ const bandwidth = calculateBandwidth(summary).data;
+
+ return (
+
+
+ }>
+ {label}
+
+
+
+ {bandwidth.length > 0 ? (
+ bandwidth.map(({ name, receive, transmit }) => (
+
+
+ {name}
+
+
+
+
+ {formatBitsPerSec(transmit, 2)}
+
+
+
+
+
+ {formatBitsPerSec(receive, 2)}
+
+
+
+ ))
+ ) : (
+
+ {noDataLabel}
+
+ )}
+
+
+
+
+ );
+};
diff --git a/packages/widgets/src/firewall/firewall-version.tsx b/packages/widgets/src/firewall/firewall-version.tsx
index 8a85312fb..3fae1fa2a 100644
--- a/packages/widgets/src/firewall/firewall-version.tsx
+++ b/packages/widgets/src/firewall/firewall-version.tsx
@@ -1,6 +1,7 @@
import { Badge, Box } from "@mantine/core";
import type { FirewallVersionSummary } from "@homarr/integrations";
+import { useI18n } from "@homarr/translation/client";
interface FirewallVersionProps {
firewallsVersionData: {
@@ -18,24 +19,34 @@ export interface FirewallIntegration {
updatedAt: Date;
}
-export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => (
-
-
- {firewallsVersionData
- .filter(({ integration }) => integration.id === selectedFirewall)
- .map(({ summary, integration }) => (
- {formatVersion(summary.version)}
- ))}
-
-
-);
+export const FirewallVersion = ({ firewallsVersionData, selectedFirewall, isTiny }: FirewallVersionProps) => {
+ const t = useI18n();
-function formatVersion(inputString: string): string {
+ return (
+
+
+ {firewallsVersionData
+ .filter(({ integration }) => integration.id === selectedFirewall)
+ .map(({ summary, integration }) => (
+ {formatVersion(summary.version, t("widget.firewall.versionUnknown"))}
+ ))}
+
+
+ );
+};
+
+export function formatVersion(inputString: string, fallback: string): string {
const regex = /([\d._]+)/;
const match = regex.exec(inputString);
if (match?.[1]) {
return match[1];
} else {
- return "Unknown Version";
+ return fallback;
}
}
diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts
index e154fd3ac..800874812 100644
--- a/packages/widgets/src/firewall/index.ts
+++ b/packages/widgets/src/firewall/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
+ queryKey: [["widget", "firewall"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(() => ({}));
diff --git a/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts b/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts
new file mode 100644
index 000000000..2004a4b01
--- /dev/null
+++ b/packages/widgets/src/health-monitoring/cluster/accordion-state.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "vitest";
+
+import { getClusterAccordionDefault } from "./accordion-state";
+
+describe("getClusterAccordionDefault", () => {
+ test("opens only the first visible section in compact mode", () => {
+ expect(getClusterAccordionDefault("compact", ["node", "qemu", "storage"])).toEqual(["node"]);
+ });
+
+ test("opens every visible section in advanced mode", () => {
+ expect(getClusterAccordionDefault("advanced", ["node", "qemu", "storage"])).toEqual(["node", "qemu", "storage"]);
+ });
+
+ test("does not invent a section when none are visible", () => {
+ expect(getClusterAccordionDefault("compact", [])).toEqual([]);
+ });
+});
diff --git a/packages/widgets/src/health-monitoring/cluster/accordion-state.ts b/packages/widgets/src/health-monitoring/cluster/accordion-state.ts
new file mode 100644
index 000000000..425e4431b
--- /dev/null
+++ b/packages/widgets/src/health-monitoring/cluster/accordion-state.ts
@@ -0,0 +1,4 @@
+export const getClusterAccordionDefault = (
+ displayMode: "compact" | "advanced" | undefined,
+ visibleSections: readonly string[],
+): string[] => (displayMode === "advanced" ? [...visibleSections] : visibleSections.slice(0, 1));
diff --git a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx
index 1ac7922d5..a8981150e 100644
--- a/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx
+++ b/packages/widgets/src/health-monitoring/cluster/cluster-health.tsx
@@ -1,3 +1,4 @@
+import { useState } from "react";
import { Accordion, Center, Flex, Group, RingProgress, Stack, Text } from "@mantine/core";
import { IconBrain, IconCpu, IconCube, IconDatabase, IconDeviceLaptop, IconServer } from "@tabler/icons-react";
@@ -8,6 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
+import { getClusterAccordionDefault } from "./accordion-state";
import { ResourceAccordionItem } from "./resource-accordion-item";
import { ResourceTable } from "./resource-table";
@@ -33,11 +35,16 @@ export const ClusterHealthMonitoring = ({
integrationId,
options,
width,
+ displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
+ const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
+ const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
+ const [accordionValues, setAccordionValues] = useState>({});
+ const accordionValue = accordionValues[accordionScope] ?? accordionDefault;
if (!healthData) return ;
@@ -53,15 +60,13 @@ export const ClusterHealthMonitoring = ({
(sum, item) => (item.isRunning ? item.cpu.utilization * item.cpu.cores + sum : sum),
0,
);
- const uptime = healthData.nodes.reduce((sum, { uptime }) => (sum > uptime ? sum : uptime), 0);
+ const uptime = healthData.nodes.reduce((sum, { uptime: nodeUptime }) => (sum > nodeUptime ? sum : nodeUptime), 0);
const cpuPercent = maxCpu ? (usedCpu / maxCpu) * 100 : 0;
const memPercent = maxMem ? (usedMem / maxMem) * 100 : 0;
- const defaultValue = [options.visibleClusterSections.at(0) ?? "node"];
-
- const isTiny = width < 256;
+ const isTiny = displayMode !== "advanced" && width < 256;
return (
-
+
{options.showUptime && (
@@ -81,7 +86,13 @@ export const ClusterHealthMonitoring = ({
isTiny={isTiny}
/>
{options.visibleClusterSections.length >= 1 && (
-
+ setAccordionValues((current) => ({ ...current, [accordionScope]: value }))}
+ >
{options.visibleClusterSections.includes("node") && (
- integration.kind === "proxmox" || integration.kind === "mock";
-
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
- const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
+ const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
- if (!clusterIntegrationId) {
+ if (clusterIntegrationIds.length === 0) {
return ;
}
- const otherIntegrationIds = integrations
- // We want to have the mock integration also in the system tab, so we use it for both
- .filter((integration) => integration.kind !== "proxmox")
- .map((integration) => integration.id);
- if (otherIntegrationIds.length === 0) {
- return ;
+ const clusters = (
+
+ {clusterIntegrationIds.map((integrationId) => (
+
+ ))}
+
+ );
+
+ if (systemIntegrationIds.length === 0) {
+ const onlyClusterIntegrationId = clusterIntegrationIds[0];
+ if (clusterIntegrationIds.length === 1 && onlyClusterIntegrationId) {
+ return ;
+ }
+ return {clusters} ;
+ }
+
+ if (props.displayMode === "advanced") {
+ return (
+ = 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
+
+
+
+ {clusters}
+
+ );
}
return (
@@ -47,11 +63,9 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
-
-
-
-
+
+ {clusters}
);
diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts
index ff8eb87ab..b63603468 100644
--- a/packages/widgets/src/health-monitoring/index.ts
+++ b/packages/widgets/src/health-monitoring/index.ts
@@ -8,6 +8,8 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
+ queryKey: [["widget", "healthMonitoring"]],
+ queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/health-monitoring/integration-selection.spec.ts b/packages/widgets/src/health-monitoring/integration-selection.spec.ts
new file mode 100644
index 000000000..3d7b3c916
--- /dev/null
+++ b/packages/widgets/src/health-monitoring/integration-selection.spec.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "vitest";
+
+import { partitionHealthMonitoringIntegrations } from "./integration-selection";
+
+describe("partitionHealthMonitoringIntegrations", () => {
+ test("preserves every Proxmox integration", () => {
+ expect(
+ partitionHealthMonitoringIntegrations([
+ { id: "proxmox-a", kind: "proxmox" },
+ { id: "proxmox-b", kind: "proxmox" },
+ { id: "system", kind: "glances" },
+ ]),
+ ).toEqual({
+ clusterIntegrationIds: ["proxmox-a", "proxmox-b"],
+ systemIntegrationIds: ["system"],
+ });
+ });
+
+ test("keeps mock integrations in both views", () => {
+ expect(partitionHealthMonitoringIntegrations([{ id: "mock", kind: "mock" }])).toEqual({
+ clusterIntegrationIds: ["mock"],
+ systemIntegrationIds: ["mock"],
+ });
+ });
+});
diff --git a/packages/widgets/src/health-monitoring/integration-selection.ts b/packages/widgets/src/health-monitoring/integration-selection.ts
new file mode 100644
index 000000000..ee2f679df
--- /dev/null
+++ b/packages/widgets/src/health-monitoring/integration-selection.ts
@@ -0,0 +1,12 @@
+import type { IntegrationKind } from "@homarr/definitions";
+
+interface HealthMonitoringIntegration {
+ id: string;
+ kind: IntegrationKind;
+}
+
+export const partitionHealthMonitoringIntegrations = (integrations: HealthMonitoringIntegration[]) => ({
+ clusterIntegrationIds: integrations.filter(({ kind }) => kind === "proxmox" || kind === "mock").map(({ id }) => id),
+ // Mock implements both contracts and remains visible in both views.
+ systemIntegrationIds: integrations.filter(({ kind }) => kind !== "proxmox").map(({ id }) => id),
+});
diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx
index 3614a570c..b52ba7fc5 100644
--- a/packages/widgets/src/health-monitoring/system-health.tsx
+++ b/packages/widgets/src/health-monitoring/system-health.tsx
@@ -1,5 +1,7 @@
"use client";
+import { useState } from "react";
+
import {
ActionIcon,
Box,
@@ -11,10 +13,10 @@ import {
List,
Modal,
Progress,
+ ScrollArea,
Stack,
Text,
} from "@mantine/core";
-import { useDisclosure } from "@mantine/hooks";
import {
IconBrain,
IconClock,
@@ -30,6 +32,7 @@ import combineClasses from "clsx";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
+import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
@@ -51,206 +54,222 @@ export const SystemHealthMonitoring = ({
options,
integrationIds,
width,
+ displayMode,
}: WidgetComponentProps<"healthMonitoring">) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
- const [opened, { open, close }] = useDisclosure(false);
+ const [openedIntegrationId, setOpenedIntegrationId] = useState(null);
const board = useRequiredBoard();
- const isTiny = width < 256;
+ const isAdvanced = displayMode === "advanced";
+ const isTiny = !isAdvanced && width < 256;
if (healthData.length === 0) return ;
return (
-
- {healthData.map(({ integrationId, integrationName, healthInfo }) => {
- const filteredFileSystem = filterStorageVolumes(
- healthInfo.fileSystem,
- options.visibleStorageVolumes,
- integrationId,
- );
- const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
- const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
- const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
- return (
-
-
- 0 ? "blue" : "gray"}
- position="top-end"
- size={16}
- label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
- disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
- >
-
+
+ {healthData.map(({ integrationId, integrationName, healthInfo }) => {
+ const filteredFileSystem = filterStorageVolumes(
+ healthInfo.fileSystem,
+ options.visibleStorageVolumes,
+ integrationId,
+ );
+ const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
+ const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
+ const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
+ return (
+
+
+ 0 ? "blue" : "gray"}
+ position="top-end"
+ size={16}
+ label={healthInfo.availablePkgUpdates > 0 ? healthInfo.availablePkgUpdates : undefined}
+ disabled={!healthInfo.rebootRequired && healthInfo.availablePkgUpdates === 0}
>
-
-
-
-
-
-
-
- }>
- {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
-
- }>
- {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
-
- }>
- {t("widget.healthMonitoring.popover.memoryAvailable", {
- memoryAvailable: memoryUsage.memFree.GB,
- percent: String(memoryUsage.memFree.percent),
- })}
-
- }>
- {t("widget.healthMonitoring.popover.version", {
- version: healthInfo.version,
- })}
-
- }>
- {formatUptime(healthInfo.uptime, t)}
-
- {healthInfo.loadAverage && (
- <>
- }>
- {t("widget.healthMonitoring.popover.loadAverage")}
-
-
}>
-
- {t("widget.healthMonitoring.popover.minute")} {healthInfo.loadAverage["1min"]}%
-
-
- {t("widget.healthMonitoring.popover.minutes", { count: "5" })}{" "}
- {healthInfo.loadAverage["5min"]}%
-
-
- {t("widget.healthMonitoring.popover.minutes", { count: "15" })}{" "}
- {healthInfo.loadAverage["15min"]}%
-
-
- >
- )}
-
-
-
-
-
- {options.cpu && }
- {options.cpu && (
-
- )}
- {options.memory && (
-
- )}
- {options.gpu &&
- healthInfo.gpu.map((gpu) => (
-
- ))}
-
- {options.fileSystem &&
- disksData.map((disk) => {
- return (
- setOpenedIntegrationId(integrationId)}
+ aria-label={t("widget.healthMonitoring.popover.information")}
>
-
-
-
-
-
- {disk.deviceName}
-
-
- {disk.temperature !== null && (
+
+
+
+ setOpenedIntegrationId(null)}
+ size="auto"
+ title={t("widget.healthMonitoring.popover.information")}
+ centered
+ >
+
+
+
+
+
+
+
+ {options.cpu && }
+ {options.cpu && (
+
+ )}
+ {options.memory && (
+
+ )}
+ {options.gpu &&
+ healthInfo.gpu.map((gpu) => (
+
+ ))}
+
+ {isAdvanced && (
+
+
+
+ )}
+ {options.fileSystem &&
+ disksData.map((disk) => {
+ return (
+
+
+
-
-
- {options.fahrenheit
- ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
- : `${disk.temperature}°C`}
+
+
+ {disk.deviceName}
- )}
-
-
-
- {disk.overallStatus ? disk.overallStatus : "N/A"}
+ {disk.temperature !== null && (
+
+
+
+ {options.fahrenheit
+ ? `${(disk.temperature * 1.8 + 32).toFixed(1)}°F`
+ : `${disk.temperature}°C`}
+
+
+ )}
+
+
+
+ {disk.overallStatus ? disk.overallStatus : "N/A"}
+
+
+
+
+
+
+
+
+
+ {t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
+
+
+ {formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
-
-
-
-
-
-
-
- {t("widget.healthMonitoring.popover.used")} {formatFileSize(disk.used)}
-
-
- {formatFileSize(disk.available)} {t("widget.healthMonitoring.popover.available")}
-
-
-
-
- );
- })}
-
- );
- })}
-
+
+
+ );
+ })}
+
+ );
+ })}
+
+
);
};
+type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
+
+const SystemInformationList = ({
+ healthInfo,
+ memoryUsage,
+ t,
+ compact = false,
+}: {
+ healthInfo: HealthInfo;
+ memoryUsage: ReturnType;
+ t: TranslationFunction;
+ compact?: boolean;
+}) => (
+
+ }>
+ {t("widget.healthMonitoring.popover.processor", { cpuModelName: healthInfo.cpuModelName })}
+
+ }>
+ {t("widget.healthMonitoring.popover.memory", { memory: memoryUsage.memTotal.GB })}
+
+ }>
+ {t("widget.healthMonitoring.popover.memoryAvailable", {
+ memoryAvailable: memoryUsage.memFree.GB,
+ percent: String(memoryUsage.memFree.percent),
+ })}
+
+ }>
+ {t("widget.healthMonitoring.popover.version", { version: healthInfo.version })}
+
+ }>
+ {formatUptime(healthInfo.uptime, t)}
+
+ {healthInfo.loadAverage && (
+ }>
+ {t("widget.healthMonitoring.popover.loadAverage")}: {healthInfo.loadAverage["1min"]}% /{" "}
+ {healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
+
+ )}
+
+);
+
export const formatUptime = (uptimeInSeconds: number, t: TranslationFunction) => {
const uptimeDuration = dayjs.duration(uptimeInSeconds, "seconds");
const months = uptimeDuration.months();
diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts
new file mode 100644
index 000000000..97f9abf45
--- /dev/null
+++ b/packages/widgets/src/iframe/component.spec.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it } from "vitest";
+
+import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
+
+const permissions = {
+ allowFullScreen: true,
+ allowPayment: false,
+ allowAutoPlay: true,
+ allowMicrophone: false,
+ allowCamera: false,
+ allowGeolocation: false,
+ allowModals: true,
+};
+
+describe("iframe policy", () => {
+ it("keeps sandbox-only modal permission out of Permissions Policy", () => {
+ expect(getAllowedPermissions(permissions)).toBe("fullscreen *; autoplay *");
+ expect(getSandboxFlags(permissions)).toContain("allow-modals");
+ });
+
+ it("accepts only HTTP protocols", () => {
+ expect(isSupportedProtocol("https://example.com")).toBe(true);
+ expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
+ expect(isSupportedProtocol("not a url")).toBe(false);
+ });
+});
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index ac794a631..068b5dc45 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Box, Stack, Text, Title } from "@mantine/core";
+import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
@@ -9,7 +9,7 @@ import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
-export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
+export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const allowedPermissions = getAllowedPermissions(permissions);
@@ -21,25 +21,37 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
}
return (
-
-
-
+
+ {displayMode === "advanced" && (
+
+
+ {getFrameTitle(embedUrl)}
+
+
+ {embedUrl}
+
+
+ )}
+
+
+
+
);
}
const supportedProtocols = ["http", "https"];
-const isSupportedProtocol = (url: string) => {
+export const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
@@ -74,19 +86,22 @@ const UnsupportedProtocol = () => {
);
};
-const getAllowedPermissions = (
+export const getAllowedPermissions = (
permissions: Omit["options"], "embedUrl" | "allowScrolling">,
) => {
return (
objectEntries(permissions)
- .filter(([_key, value]) => value)
- // * means it applies to all origins
- .map(([key]) => `${permissionMapping[key]} *`)
+ // * means it applies to all origins. Sandbox-only flags such as
+ // allow-modals intentionally have no Permissions Policy mapping.
+ .flatMap(([key, value]) => {
+ const permission = permissionMapping[key];
+ return value && permission ? [`${permission} *`] : [];
+ })
.join("; ")
);
};
-const getSandboxFlags = (
+export const getSandboxFlags = (
permissions: Omit["options"], "embedUrl" | "allowScrolling">,
) => {
const baseSandbox = [
@@ -112,12 +127,21 @@ const getSandboxFlags = (
return baseSandbox;
};
-const permissionMapping = {
+const permissionMapping: Partial<
+ Record["options"], "embedUrl" | "allowScrolling">, string>
+> = {
allowAutoPlay: "autoplay",
allowCamera: "camera",
allowFullScreen: "fullscreen",
allowGeolocation: "geolocation",
allowMicrophone: "microphone",
allowPayment: "payment",
- allowModals: "allow-modals",
-} satisfies Record["options"], "embedUrl" | "allowScrolling">, string>;
+};
+
+const getFrameTitle = (url: string) => {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return "iframe";
+ }
+};
diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx
index 49bb4ceec..f219e9eb1 100644
--- a/packages/widgets/src/immich/album-carousel/component.tsx
+++ b/packages/widgets/src/immich/album-carousel/component.tsx
@@ -2,11 +2,19 @@
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
-import { Box, Center, Group, Image, Stack, Text } from "@mantine/core";
-import { IconAlertCircle, IconCalendar } from "@tabler/icons-react";
+import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
+import { useReducedMotion } from "@mantine/hooks";
+import {
+ IconAlertCircle,
+ IconCalendar,
+ IconChevronLeft,
+ IconChevronRight,
+ IconPlayerPause,
+ IconPlayerPlay,
+} from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
-import { useI18n } from "@homarr/translation/client";
+import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
@@ -16,8 +24,16 @@ import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export default function ImmichAlbumCarouselWidget({
integrationIds,
options,
+ displayMode = "compact",
+ widgetStateRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
- const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
+ const persistedState = widgetStateRef?.current?.immichAlbumCarousel as
+ | { currentPhotoIndex?: number; paused?: boolean }
+ | undefined;
+ const [currentPhotoIndex, setCurrentPhotoIndex] = useState(() =>
+ typeof persistedState?.currentPhotoIndex === "number" ? persistedState.currentPhotoIndex : 0,
+ );
+ const [paused, setPaused] = useState(persistedState?.paused === true);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
@@ -33,8 +49,42 @@ export default function ImmichAlbumCarouselWidget({
}, [album?.assets, options.randomizePhotos]);
useEffect(() => {
- setCurrentPhotoIndex(0);
- }, [photoAssets]);
+ if (photoAssets.length === 0) return;
+ setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
+ }, [photoAssets.length]);
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ immichAlbumCarousel: { currentPhotoIndex, paused },
+ };
+ }, [currentPhotoIndex, paused, widgetStateRef]);
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ const clearActions = () => {
+ if (!widgetStateRef.current) return;
+ delete widgetStateRef.current.previousPhoto;
+ delete widgetStateRef.current.nextPhoto;
+ delete widgetStateRef.current.toggleSlideshow;
+ };
+ if (photoAssets.length <= 1) {
+ clearActions();
+ return;
+ }
+ const previousPhoto = () =>
+ setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
+ const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
+ const toggleSlideshow = () => setPaused((value) => !value);
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ previousPhoto,
+ nextPhoto,
+ toggleSlideshow,
+ };
+ return clearActions;
+ }, [photoAssets.length, widgetStateRef]);
if (!album) return ;
@@ -53,6 +103,10 @@ export default function ImmichAlbumCarouselWidget({
setCurrentIndex={setCurrentPhotoIndex}
rotationInterval={options.rotationIntervalSeconds}
showPhotoInfo={options.showPhotoInfo}
+ albumName={album.albumName}
+ advanced={displayMode === "advanced"}
+ paused={paused}
+ setPaused={setPaused}
/>
);
}
@@ -73,7 +127,6 @@ function shuffle(items: T[]) {
interface CarouselProps {
assets: {
id: string;
- originalPath: string;
fileModifiedAt: string;
publicLink: string;
}[];
@@ -81,36 +134,126 @@ interface CarouselProps {
setCurrentIndex: Dispatch>;
rotationInterval: number;
showPhotoInfo: boolean;
+ albumName: string;
+ advanced: boolean;
+ paused: boolean;
+ setPaused: Dispatch>;
}
-function Carousel({ assets, currentIndex, setCurrentIndex, rotationInterval, showPhotoInfo }: CarouselProps) {
+function Carousel({
+ assets,
+ currentIndex,
+ setCurrentIndex,
+ rotationInterval,
+ showPhotoInfo,
+ albumName,
+ advanced,
+ paused,
+ setPaused,
+}: CarouselProps) {
+ const t = useScopedI18n("widget.immich-albumCarousel");
+ const reduceMotion = useReducedMotion();
+
useEffect(() => {
+ if (paused || reduceMotion || assets.length <= 1) return;
const interval = setInterval(() => {
setCurrentIndex((prev) => (prev + 1) % assets.length);
}, rotationInterval * 1000);
return () => clearInterval(interval);
- }, [assets.length, rotationInterval, setCurrentIndex]);
+ }, [assets.length, paused, reduceMotion, rotationInterval, setCurrentIndex]);
+ const safeCurrentIndex = Math.min(currentIndex, assets.length - 1);
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
- const currentAsset = assets[currentIndex]!;
+ const currentAsset = assets[safeCurrentIndex]!;
+
+ const move = (offset: number) => setCurrentIndex((current) => (current + offset + assets.length) % assets.length);
return (
-
-
+
+
+
- {showPhotoInfo && (
-
-
-
- {new Date(currentAsset.fileModifiedAt).toLocaleDateString()}
+ {advanced && (
+ <>
+ move(-1)}
+ >
+
+
+
+ setPaused((value) => !value)}
+ >
+ {paused ? : }
+
+
+ move(1)}
+ >
+
+
+ >
+ )}
+
+ {(showPhotoInfo || advanced) && (
+
+ {albumName && (
+
+ {albumName}
+
+ )}
+
+
+ {new Date(currentAsset.fileModifiedAt).toLocaleDateString()}
+
+
+ {safeCurrentIndex + 1} / {assets.length}
+
+
+ )}
+
+ {advanced && (
+
+
+ {assets.slice(0, 24).map((asset, index) => (
+ setCurrentIndex(index)}
+ aria-label={t("actions.photo", { number: index + 1 })}
+ aria-pressed={index === safeCurrentIndex}
+ aria-current={index === safeCurrentIndex ? "true" : undefined}
+ >
+
+
+ ))}
-
- {currentIndex + 1} / {assets.length}
-
-
+
)}
-
+
);
}
diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts
index 8c4223ef3..ef07d245e 100644
--- a/packages/widgets/src/immich/album-carousel/index.ts
+++ b/packages/widgets/src/immich/album-carousel/index.ts
@@ -1,4 +1,4 @@
-import { IconPhoto } from "@tabler/icons-react";
+import { IconChevronLeft, IconChevronRight, IconPhoto, IconPlayerPause } from "@tabler/icons-react";
import z from "zod";
import { clientApi } from "@homarr/api/client";
@@ -14,6 +14,37 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
+ maxIntegrations: 1,
+ contextActions({ widgetStateRef }) {
+ const call = (key: string) => () => {
+ const action = widgetStateRef.current?.[key];
+ if (typeof action === "function") action();
+ };
+ const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
+ return [
+ {
+ key: "previousPhoto",
+ label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
+ icon: IconChevronLeft,
+ disabled: disabled("previousPhoto"),
+ onClick: call("previousPhoto"),
+ },
+ {
+ key: "nextPhoto",
+ label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
+ icon: IconChevronRight,
+ disabled: disabled("nextPhoto"),
+ onClick: call("nextPhoto"),
+ },
+ {
+ key: "toggleSlideshow",
+ label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
+ icon: IconPlayerPause,
+ disabled: disabled("toggleSlideshow"),
+ onClick: call("toggleSlideshow"),
+ },
+ ];
+ },
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx
index 3b82499f7..308e803a7 100644
--- a/packages/widgets/src/immich/server-stats/component.tsx
+++ b/packages/widgets/src/immich/server-stats/component.tsx
@@ -1,7 +1,7 @@
"use client";
import React from "react";
-import { Group, Stack, Text } from "@mantine/core";
+import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -15,16 +15,22 @@ import classes from "./component.module.css";
export default function ImmichServerStatsWidget({
integrationIds,
options,
+ displayMode = "compact",
+ width,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
integrationId: integrationIds[0] ?? "",
});
+ const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
+ { integrationId: integrationIds[0] ?? "" },
+ { enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
+ );
if (!stats) return ;
- return (
-
+ const statsContent = (
+ = 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -49,6 +55,39 @@ export default function ImmichServerStatsWidget({
value={formatBytes(stats.totalLibraryUsageInBytes)}
/>
)}
+
+ );
+
+ if (displayMode === "compact") {
+ return (
+
+ {statsContent}
+
+ );
+ }
+
+ const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
+ const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
+ return (
+
+ {statsContent}
+
+ = 900 ? 2 : 1} spacing="xs">
+ {sortedAlbums.map((album) => (
+
+
+
+ {album.albumName}
+
+
+ {album.assetCount.toLocaleString()}
+
+
+
+
+ ))}
+
+
);
}
diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts
index ed4e05a20..da9b6e1f2 100644
--- a/packages/widgets/src/immich/server-stats/index.ts
+++ b/packages/widgets/src/immich/server-stats/index.ts
@@ -9,6 +9,7 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-se
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showUsers: factory.switch({
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 061fc4b2d..f0e2e1bcf 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -71,6 +71,7 @@ export type {
WidgetContextActionProps,
WidgetOptionsSettings,
} from "./definition";
+export { getWidgetQueryKeys } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx
index 4e3fb7c7e..df0298b82 100644
--- a/packages/widgets/src/indexer-manager/component.tsx
+++ b/packages/widgets/src/indexer-manager/component.tsx
@@ -1,6 +1,7 @@
"use client";
-import { ActionIcon, Anchor, Button, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
+import { useEffect, useMemo } from "react";
+import { ActionIcon, Anchor, Badge, Button, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -16,6 +17,9 @@ export default function IndexerManagerWidget({
integrationIds,
width,
height,
+ displayMode,
+ isEditMode,
+ widgetStateRef,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
@@ -25,8 +29,22 @@ export default function IndexerManagerWidget({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const board = useRequiredBoard();
- const hasSmallWidth = width < 256;
- const hasSmallHeight = height < 256;
+ const isAdvanced = displayMode === "advanced";
+ const hasSmallWidth = !isAdvanced && width < 256;
+ const hasSmallHeight = !isAdvanced && height < 256;
+ const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
+ const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ testAllIndexers: () => testAll({ integrationIds }),
+ };
+ return () => {
+ if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
+ };
+ }, [integrationIds, testAll, widgetStateRef]);
return (
@@ -39,6 +57,18 @@ export default function IndexerManagerWidget({
{t("widget.indexerManager.title")}
+ {isAdvanced && (
+
+
+ {allIndexers.length - unavailableCount}
+
+ {unavailableCount > 0 && (
+
+ {unavailableCount}
+
+ )}
+
+ )}
{hasSmallHeight && (
{
testAll({ integrationIds });
@@ -63,8 +94,22 @@ export default function IndexerManagerWidget({
flex={1}
>
- {indexersData.map(({ integrationId, indexers }) => (
+ {indexersData.map(({ integrationId, integrationName, indexers, error }) => (
+ {(isAdvanced || indexersData.length > 1) && (
+
+
+ {integrationName}
+
+ {error && (
+
+
+ {t("common.error")}
+
+
+ )}
+
+ )}
{indexers.map((indexer) => (
+ {isAdvanced && (
+
+ {indexer.url}
+
+ )}
{indexer.status === false || indexer.enabled === false ? (
}
loading={isPending}
+ disabled={isEditMode}
loaderProps={{ type: "dots" }}
onClick={() => {
testAll({ integrationIds });
diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts
index e515cd697..a7fc8a0cd 100644
--- a/packages/widgets/src/indexer-manager/index.ts
+++ b/packages/widgets/src/indexer-manager/index.ts
@@ -1,4 +1,4 @@
-import { IconReportSearch, IconServerOff } from "@tabler/icons-react";
+import { IconReportSearch, IconServerOff, IconTestPipe } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -7,6 +7,22 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
icon: IconReportSearch,
+ queryKey: [["widget", "indexerManager"]],
+ contextActions: ({ widgetStateRef, context }) => [
+ {
+ key: "test-all-indexers",
+ label: (t) => t("widget.indexerManager.testAll"),
+ icon: IconTestPipe,
+ disabled:
+ context.isEditMode ||
+ !context.canInteractWithSelectedIntegrations ||
+ typeof widgetStateRef.current?.testAllIndexers !== "function",
+ onClick: () => {
+ const action = widgetStateRef.current?.testAllIndexers;
+ if (typeof action === "function") action();
+ },
+ },
+ ],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx
index 69c1edf21..b5bb9d320 100644
--- a/packages/widgets/src/media-missing/component.tsx
+++ b/packages/widgets/src/media-missing/component.tsx
@@ -1,7 +1,23 @@
"use client";
import type { ReactNode } from "react";
-import { Badge, Box, Center, Group, Image, Paper, RingProgress, ScrollArea, SimpleGrid, Stack, Tabs, Text, ThemeIcon } from "@mantine/core";
+import { useEffect, useState } from "react";
+import {
+ Badge,
+ Box,
+ Center,
+ Group,
+ Image,
+ Paper,
+ RingProgress,
+ ScrollArea,
+ SimpleGrid,
+ Stack,
+ Tabs,
+ Text,
+ ThemeIcon,
+ Tooltip,
+} from "@mantine/core";
import { IconDownload, IconMovie, IconQuestionMark, IconVideo } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -12,14 +28,29 @@ import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
+import type { MediaMissingTab } from "./tabs";
+import { resolveMediaMissingTab } from "./tabs";
-export default function MediaMissingWidget({ integrationIds, options, width, height }: WidgetComponentProps<"mediaMissing">) {
+export default function MediaMissingWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode,
+}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
- const pageSize = Number(options.pageSize);
+ const isAdvanced = displayMode === "advanced";
+ const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
{ integrationIds, pageSize },
{ staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
);
+ const [selectedTab, setSelectedTab] = useState(options.showMissing ? "missing" : "queued");
+ const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
+
+ useEffect(() => {
+ if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab);
+ }, [activeTab, selectedTab]);
if (!data) return ;
if (data.length === 0) throw new NoIntegrationDataError();
@@ -32,25 +63,26 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
);
- const missing = data.flatMap((entry) =>
- entry.missing.map((item) => ({ item, integrationId: entry.integrationId })),
- );
- const queued = data.flatMap((entry) =>
- entry.queued.map((item) => ({ item, integrationId: entry.integrationId })),
- );
+ const missing = data.flatMap((entry) => entry.missing.map((item) => ({ item, integrationId: entry.integrationId })));
+ const queued = data.flatMap((entry) => entry.queued.map((item) => ({ item, integrationId: entry.integrationId })));
const missingCount = data.reduce((sum, entry) => sum + entry.missingCount, 0);
const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0);
+ const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error));
- const isThin = width > 0 && width < 160;
- const isShort = height > 0 && height < 180;
+ const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued);
+ const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width;
+ const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160;
+ const isShort = !isAdvanced && height > 0 && height < 180;
const targetCardWidth = isShort ? 130 : 200;
- const columns = width > 0 ? Math.max(1, Math.min(Math.floor(width / targetCardWidth), 4)) : 1;
- const density: Density = isThin ? "thin" : width > 0 && width / columns < 180 ? "compact" : "comfortable";
+ const columns = panelWidth > 0 ? Math.max(1, Math.min(Math.floor(panelWidth / targetCardWidth), 4)) : 1;
+ const density: Density = isThin ? "thin" : panelWidth > 0 && panelWidth / columns < 180 ? "compact" : "comfortable";
- const tabLabel = (label: string, shown: number, total: number) =>
- isThin ? total : `${label} (${shown}/${total})`;
+ const tabLabel = (label: string, shown: number, total: number) => (isThin ? total : `${label} (${shown}/${total})`);
- const renderPanel = (entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[], emptyLabel: string) => (
+ const renderPanel = (
+ entries: { item: MissingMediaItem | QueuedMediaItem; integrationId: string }[],
+ emptyLabel: string,
+ ) => (
{entries.length === 0 ? (
@@ -68,12 +100,60 @@ export default function MediaMissingWidget({ integrationIds, options, width, hei
);
+ const partialFailures = failedIntegrations.length > 0 && (
+
+ {failedIntegrations.map((entry) => (
+
+
+ {entry.integrationName}
+
+
+ ))}
+
+ );
+
+ if (isAdvanced) {
+ return (
+
+ {partialFailures}
+
+ {options.showMissing && (
+
+
+
+
+ {tabLabel(t("tab.missing"), missing.length, missingCount)}
+
+
+ {renderPanel(missing, t("empty.missing"))}
+
+ )}
+ {options.showQueued && (
+
+
+
+
+ {tabLabel(t("tab.queued"), queued.length, queuedCount)}
+
+
+ {renderPanel(queued, t("empty.queued"))}
+
+ )}
+
+
+ );
+ }
+
return (
{
+ if (value === "missing" || value === "queued") setSelectedTab(value);
+ }}
h="100%"
style={{ display: "flex", flexDirection: "column" }}
>
+ {partialFailures}
{options.showMissing && (
}>
@@ -116,7 +196,14 @@ const Poster = ({ src, type, density }: { src?: string | null; type: "movie" | "
}
return (
-
+
{type === "movie" ? : }
);
@@ -183,8 +270,25 @@ const ProgressRing = ({ percent, density }: { percent: number; density: Density
);
};
-const CardShell = ({ item, density, children }: { item: MissingMediaItem | QueuedMediaItem; density: Density; children: ReactNode }) => (
-
+const CardShell = ({
+ item,
+ density,
+ children,
+}: {
+ item: MissingMediaItem | QueuedMediaItem;
+ density: Density;
+ children: ReactNode;
+}) => (
+
{item.imageUrl && (
)}
diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts
index eaa44be6d..fd325f801 100644
--- a/packages/widgets/src/media-missing/index.ts
+++ b/packages/widgets/src/media-missing/index.ts
@@ -9,6 +9,7 @@ const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
+ queryKey: [["widget", "mediaOrganizer"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
diff --git a/packages/widgets/src/media-missing/tabs.spec.ts b/packages/widgets/src/media-missing/tabs.spec.ts
new file mode 100644
index 000000000..4f3af6af1
--- /dev/null
+++ b/packages/widgets/src/media-missing/tabs.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "vitest";
+
+import { resolveMediaMissingTab } from "./tabs";
+
+describe("resolveMediaMissingTab", () => {
+ test("preserves the selected tab while it remains enabled", () => {
+ expect(resolveMediaMissingTab("queued", true, true)).toBe("queued");
+ });
+
+ test("falls back when the selected tab is disabled", () => {
+ expect(resolveMediaMissingTab("missing", false, true)).toBe("queued");
+ expect(resolveMediaMissingTab("queued", true, false)).toBe("missing");
+ });
+
+ test("returns no tab when both panels are disabled", () => {
+ expect(resolveMediaMissingTab("missing", false, false)).toBeNull();
+ });
+});
diff --git a/packages/widgets/src/media-missing/tabs.ts b/packages/widgets/src/media-missing/tabs.ts
new file mode 100644
index 000000000..8877dc15f
--- /dev/null
+++ b/packages/widgets/src/media-missing/tabs.ts
@@ -0,0 +1,13 @@
+export type MediaMissingTab = "missing" | "queued";
+
+export const resolveMediaMissingTab = (
+ current: MediaMissingTab,
+ showMissing: boolean,
+ showQueued: boolean,
+): MediaMissingTab | null => {
+ if (current === "missing" && showMissing) return current;
+ if (current === "queued" && showQueued) return current;
+ if (showMissing) return "missing";
+ if (showQueued) return "queued";
+ return null;
+};
diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx
index 05ba18fe5..f768f7ed4 100644
--- a/packages/widgets/src/media-releases/component.tsx
+++ b/packages/widgets/src/media-releases/component.tsx
@@ -1,7 +1,20 @@
"use client";
import { Fragment } from "react";
-import { Avatar, Badge, Box, Divider, Group, Image, Stack, Text, TooltipFloating, UnstyledButton } from "@mantine/core";
+import {
+ Avatar,
+ Badge,
+ Box,
+ Divider,
+ Group,
+ Image,
+ ScrollArea,
+ SimpleGrid,
+ Stack,
+ Text,
+ TooltipFloating,
+ UnstyledButton,
+} from "@mantine/core";
import { IconBook, IconCalendar, IconClock, IconStarFilled } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
@@ -17,26 +30,36 @@ import { OverflowBadge } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
-export default function MediaReleasesWidget({ options, integrationIds }: WidgetComponentProps<"mediaReleases">) {
+export default function MediaReleasesWidget({
+ options,
+ integrationIds,
+ width,
+ displayMode,
+}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
+ const isAdvanced = displayMode === "advanced";
+
return (
-
- {releases.map((item, index) => (
-
- {index !== 0 && options.layout === "poster" && }
-
-
- ))}
-
+
+
+ {releases.map((item, index) => (
+
+ {!isAdvanced && index !== 0 && options.layout === "poster" && }
+
+
+ ))}
+
+
);
}
interface ItemProps {
item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number];
options: WidgetComponentProps<"mediaReleases">["options"];
+ isAdvanced: boolean;
}
const formatReleaseDate = (value: unknown, locale: string) => {
@@ -52,7 +75,7 @@ const formatReleaseDate = (value: unknown, locale: string) => {
}).format(date);
};
-const Item = ({ item, options }: ItemProps) => {
+const Item = ({ item, options, isAdvanced }: ItemProps) => {
const locale = useCurrentLocale();
const t = useI18n();
const length = formatLength(item.length, item.type, t);
@@ -62,7 +85,12 @@ const Item = ({ item, options }: ItemProps) => {
label={item.description}
w={300}
multiline
- disabled={item.description === undefined || item.description.trim() === "" || !options.showDescriptionTooltip}
+ disabled={
+ isAdvanced ||
+ item.description === undefined ||
+ item.description.trim() === "" ||
+ !options.showDescriptionTooltip
+ }
>
{
target="_blank"
rel="noopener noreferrer"
pos="relative"
- p={options.layout === "poster" ? 0 : 4}
+ p={isAdvanced ? "sm" : options.layout === "poster" ? 0 : 4}
+ h="100%"
+ style={
+ isAdvanced
+ ? {
+ border: "1px solid var(--mantine-color-default-border)",
+ borderRadius: "var(--mantine-radius-sm)",
+ }
+ : undefined
+ }
>
- {options.layout === "backdrop" && (
+ {!isAdvanced && options.layout === "backdrop" && (
{
)}
- {options.layout === "poster" && }
+ {(isAdvanced || options.layout === "poster") && (
+
+ )}
@@ -136,15 +175,20 @@ const Item = ({ item, options }: ItemProps) => {
groupGap={4}
data={item.tags}
overflowCount={3}
- disablePopover
+ disablePopover={!isAdvanced}
style={{ cursor: "pointer" }}
/>
)}
+ {isAdvanced && item.description && (
+
+ {item.description}
+
+ )}
- {(options.showType || options.showSource) && (
+ {(isAdvanced || options.showType || options.showSource) && (
- {options.showType && (
+ {(isAdvanced || options.showType) && (
{
)}
- {options.showSource && (
+ {(isAdvanced || options.showSource) && (
)}
diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx
index c5dc5bf06..b8760eb56 100644
--- a/packages/widgets/src/media-requests/list/component.tsx
+++ b/packages/widgets/src/media-requests/list/component.tsx
@@ -18,6 +18,7 @@ import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react";
import type { RouterInputs, RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
+import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { toValidDate } from "@homarr/common";
import type { MediaRequestStatus } from "@homarr/integrations/types";
@@ -35,7 +36,14 @@ export default function MediaServerWidget({
isEditMode,
options,
width,
+ height,
+ displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
+ const interactIntegrationIds = new Set(
+ useIntegrationsWithInteractAccess()
+ .filter(({ id }) => integrationIds.includes(id))
+ .map(({ id }) => id),
+ );
const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
@@ -61,7 +69,9 @@ export default function MediaServerWidget({
))}
@@ -92,10 +102,12 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[]
interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number];
isTiny: boolean;
+ isAdvanced: boolean;
+ canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
-const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) => {
+const MediaRequestCard = ({ request, isTiny, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
@@ -152,6 +164,11 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
{t(`availability.${request.availability}`)}
)}
+ {isAdvanced && (
+
+ {request.integration.name}
+
+ )}
{request.status === "pending" ? (
-
+
) : (
)}
@@ -201,15 +222,21 @@ const MediaRequestCard = ({ request, isTiny, options }: MediaRequestCardProps) =
interface DecisionButtonsProps {
requestId: number;
integrationId: string;
+ canInteract: boolean;
}
-const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) => {
+const DecisionButtons = ({ requestId, integrationId, canInteract }: DecisionButtonsProps) => {
const utils = clientApi.useUtils();
- const { mutate: mutateRequestAnswer } = clientApi.widget.mediaRequests.answerRequest.useMutation({
+ const {
+ mutate: mutateRequestAnswer,
+ isPending,
+ error,
+ } = clientApi.widget.mediaRequests.answerRequest.useMutation({
onSettled: () => void utils.widget.mediaRequests.invalidate(),
});
const t = useScopedI18n("widget.mediaRequests-requestList");
const handleDecision = (answer: RouterInputs["widget"]["mediaRequests"]["answerRequest"]["answer"]) => {
+ if (!canInteract || isPending) return;
mutateRequestAnswer({
integrationId,
requestId,
@@ -218,13 +245,15 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
};
return (
-
+
{
handleDecision("approve");
}}
@@ -238,6 +267,8 @@ const DecisionButtons = ({ requestId, integrationId }: DecisionButtonsProps) =>
variant="light"
color="red"
size="xs"
+ disabled={!canInteract || isPending}
+ aria-label={t("pending.decline")}
onClick={() => {
handleDecision("decline");
}}
diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts
index 1f65cd0c3..bcc5dd4fd 100644
--- a/packages/widgets/src/media-requests/list/index.ts
+++ b/packages/widgets/src/media-requests/list/index.ts
@@ -11,6 +11,7 @@ const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
+ queryKey: [["widget", "mediaRequests"]],
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx
index 5348b845a..be9f2796b 100644
--- a/packages/widgets/src/media-requests/stats/component.tsx
+++ b/packages/widgets/src/media-requests/stats/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ActionIcon, Avatar, Box, Card, Grid, Group, Stack, Text, Tooltip } from "@mantine/core";
+import { ActionIcon, Avatar, Badge, Box, Card, Grid, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import type { Icon } from "@tabler/icons-react";
import {
IconDeviceTv,
@@ -34,6 +34,8 @@ export default function MediaServerWidget({
integrationIds,
isEditMode,
width,
+ height,
+ displayMode,
}: WidgetComponentProps<"mediaRequests-requestStats">) {
const t = useScopedI18n("widget.mediaRequests-requestStats");
const { data: requestStats } = clientApi.widget.mediaRequests.getStats.useQuery({
@@ -88,7 +90,12 @@ export default function MediaServerWidget({
},
] satisfies { name: keyof RequestStats; icon: Icon; number: number }[];
- const isTiny = width < 256;
+ const isTiny = displayMode !== "advanced" && (width < 256 || height < 180);
+ const visibleStats = displayMode === "advanced" ? data : data.slice(0, height < 120 ? 4 : 8);
+ const visibleUsers = requestStats.users.slice(
+ 0,
+ displayMode === "advanced" ? 50 : Math.max(1, Math.floor(height / 44) - 3),
+ );
return (
@@ -107,7 +114,7 @@ export default function MediaServerWidget({
{t("titles.stats.main")}
- {data.map((stat) => (
+ {visibleStats.map((stat) => (
{t("titles.users.main")} ({t("titles.users.requests")})
-
- {requestStats.users.slice(0, 10).map((user) => (
-
-
-
-
-
-
-
- {user.displayName}
+
+
+ {visibleUsers.map((user) => (
+
+
+
+
+
+
+
+ {user.displayName}
+
+
+
+
+ {user.requestCount}
-
-
- {user.requestCount}
-
-
-
- ))}
-
+
+ ))}
+
+
+ {displayMode === "advanced" && (
+
+ {requestStats.integrations.map((integration) => (
+
+ {integration.name}
+
+ ))}
+
+ )}
diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts
index d49cb8495..5efbd8909 100644
--- a/packages/widgets/src/media-requests/stats/index.ts
+++ b/packages/widgets/src/media-requests/stats/index.ts
@@ -7,6 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
+ queryKey: [["widget", "mediaRequests"]],
createOptions() {
return {};
},
diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx
index 439857a8f..d474b2edd 100644
--- a/packages/widgets/src/media-server/component.tsx
+++ b/packages/widgets/src/media-server/component.tsx
@@ -17,7 +17,7 @@ import { MantineReactTable } from "mantine-react-table";
import { clientApi } from "@homarr/api/client";
import { objectEntries } from "@homarr/common";
-import { getIconUrl, integrationDefs } from "@homarr/definitions";
+import { getIconUrl } from "@homarr/definitions";
import type { StreamSession } from "@homarr/integrations";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
@@ -26,38 +26,50 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
-export default function MediaServerWidget({ options, integrationIds }: WidgetComponentProps<"mediaServer">) {
+export default function MediaServerWidget({
+ options,
+ integrationIds,
+ width,
+ height,
+ isEditMode,
+ displayMode,
+}: WidgetComponentProps<"mediaServer">) {
const { data: currentStreams = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery({
integrationIds,
showOnlyPlaying: options.showOnlyPlaying,
});
const t = useScopedI18n("widget.mediaServer");
- const columns = useMemo[]>(
- () => [
- {
- accessorKey: "user.username",
- header: t("items.user"),
- size: 160,
+ const isAdvanced = displayMode === "advanced";
+ const columns = useMemo[]>(() => {
+ const result: MRT_ColumnDef[] = [
+ ...(width >= 300 || isAdvanced
+ ? [
+ {
+ accessorKey: "user.username",
+ header: t("items.user"),
+ size: 160,
- Cell: ({ row }) => (
-
-
-
-
- {row.original.user.username}
-
-
- {row.original.sessionName}
-
-
-
- ),
- },
+ Cell: ({ row }) => (
+
+
+
+
+ {row.original.user.username}
+
+
+ {row.original.sessionName}
+
+
+
+ ),
+ } satisfies MRT_ColumnDef,
+ ]
+ : []),
{
accessorKey: "currentlyPlaying", // currentlyPlaying.name can be undefined which results in a warning. This is why we use currentlyPlaying instead of currentlyPlaying.name
header: t("items.currentlyPlaying"),
@@ -77,7 +89,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
? Math.min(100, Math.round((positionMs / durationMs) * 100))
: null;
const remainingMinutes =
- positionMs !== null && durationMs !== null ? Math.max(0, Math.round((durationMs - positionMs) / 60_000)) : null;
+ positionMs !== null && durationMs !== null
+ ? Math.max(0, Math.round((durationMs - positionMs) / 60_000))
+ : null;
return (
@@ -112,42 +126,46 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
);
},
- },
- {
- id: "status",
- header: t("items.status"),
- size: 110,
+ } satisfies MRT_ColumnDef,
+ ...(width >= 420 || isAdvanced
+ ? [
+ {
+ id: "status",
+ header: t("items.status"),
+ size: 110,
- Cell: ({ row }) => {
- const currentlyPlaying = row.original.currentlyPlaying;
- if (!currentlyPlaying) return null;
+ Cell: ({ row }) => {
+ const currentlyPlaying = row.original.currentlyPlaying;
+ if (!currentlyPlaying) return null;
- const isTranscoding = Boolean(
- currentlyPlaying.metadata?.transcoding.target.videoCodec ??
- currentlyPlaying.metadata?.transcoding.target.audioCodec ??
- currentlyPlaying.metadata?.transcoding.container,
- );
+ const isTranscoding = Boolean(
+ currentlyPlaying.metadata?.transcoding.target.videoCodec ??
+ currentlyPlaying.metadata?.transcoding.target.audioCodec ??
+ currentlyPlaying.metadata?.transcoding.container,
+ );
- return (
-
-
- {isTranscoding ? t("items.transcoding") : t("items.directPlay")}
-
- {currentlyPlaying.location && (
-
- {currentlyPlaying.location === "lan" ? : }
-
- {currentlyPlaying.location}
-
-
- )}
-
- );
- },
- },
- ],
- [t],
- );
+ return (
+
+
+ {isTranscoding ? t("items.transcoding") : t("items.directPlay")}
+
+ {currentlyPlaying.location && (
+
+ {currentlyPlaying.location === "lan" ? : }
+
+ {currentlyPlaying.location}
+
+
+ )}
+
+ );
+ },
+ } satisfies MRT_ColumnDef,
+ ]
+ : []),
+ ];
+ return result;
+ }, [isAdvanced, t, width]);
// Only render the flat list of sessions when the currentStreams change
// Otherwise it will always create a new array reference and cause the table to re-render
@@ -156,8 +174,9 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
currentStreams.flatMap((pair) =>
pair.sessions.map((session) => ({
...session,
+ integrationId: pair.integrationId,
integrationKind: pair.integrationKind,
- integrationName: integrationDefs[pair.integrationKind].name,
+ integrationName: pair.integrationName,
integrationIcon: getIconUrl(pair.integrationKind),
})),
),
@@ -169,15 +188,15 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
columns,
data: flatSessions,
enablePagination: false,
- enableTopToolbar: false,
+ enableTopToolbar: isAdvanced,
enableBottomToolbar: false,
- enableSorting: false,
+ enableSorting: isAdvanced,
enableColumnActions: false,
enableStickyHeader: false,
enableColumnOrdering: false,
enableRowSelection: false,
enableFullScreenToggle: false,
- enableGlobalFilter: false,
+ enableGlobalFilter: isAdvanced,
enableDensityToggle: false,
enableFilters: false,
enableHiding: false,
@@ -208,6 +227,7 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
mantineTableContainerProps: {
style: {
height: "100%",
+ pointerEvents: isEditMode ? "none" : undefined,
},
},
mantineTableBodyCellProps: ({ row }) => ({
@@ -229,37 +249,59 @@ export default function MediaServerWidget({ options, integrationIds }: WidgetCom
}),
});
- const uniqueIntegrations = Array.from(new Set(flatSessions.map((session) => session.integrationKind))).map((kind) => {
- const session = flatSessions.find((session) => session.integrationKind === kind);
- return {
- integrationKind: kind,
- integrationIcon: session?.integrationIcon,
- integrationName: session?.integrationName,
- };
- });
+ const uniqueIntegrations = currentStreams.map((stream) => ({
+ integrationId: stream.integrationId,
+ integrationKind: stream.integrationKind,
+ integrationIcon: getIconUrl(stream.integrationKind),
+ integrationName: stream.integrationName,
+ }));
+
+ const playingCount = flatSessions.filter(
+ (session) => session.currentlyPlaying && session.currentlyPlaying.playback?.state !== "paused",
+ ).length;
+ const transcodingCount = flatSessions.filter((session) =>
+ Boolean(
+ session.currentlyPlaying?.metadata?.transcoding.target.videoCodec ??
+ session.currentlyPlaying?.metadata?.transcoding.target.audioCodec ??
+ session.currentlyPlaying?.metadata?.transcoding.container,
+ ),
+ ).length;
return (
+ {isAdvanced && (
+
+ {t("summary.sessions", { count: flatSessions.length })}
+
+ {t("summary.playing", { count: playingCount })}
+
+
+ {t("summary.transcoding", { count: transcodingCount })}
+
+
+ )}
-
- {uniqueIntegrations.map((integration) => (
-
-
-
- {integration.integrationName}
-
-
- ))}
-
+ {(isAdvanced || height >= 96) && (
+
+ {uniqueIntegrations.map((integration) => (
+
+
+
+ {integration.integrationName}
+
+
+ ))}
+
+ )}
);
}
diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts
index 54f00476c..3d6679688 100644
--- a/packages/widgets/src/media-server/index.ts
+++ b/packages/widgets/src/media-server/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
+ queryKey: [["widget", "mediaServer"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx
index fe4873e45..765c3cdcd 100644
--- a/packages/widgets/src/media-transcoding/component.tsx
+++ b/packages/widgets/src/media-transcoding/component.tsx
@@ -1,7 +1,19 @@
"use client";
-import { useState } from "react";
-import { Center, Divider, Group, Pagination, SegmentedControl, Stack, Text } from "@mantine/core";
+import { useEffect, useState } from "react";
+import type { ReactNode } from "react";
+import {
+ Center,
+ Divider,
+ Group,
+ Pagination,
+ Paper,
+ ScrollArea,
+ SegmentedControl,
+ SimpleGrid,
+ Stack,
+ Text,
+} from "@mantine/core";
import { IconClipboardList, IconCpu2, IconReportAnalytics } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
@@ -30,23 +42,73 @@ export default function MediaTranscodingWidget({
integrationIds,
options,
width,
+ height,
+ displayMode,
}: WidgetComponentProps<"mediaTranscoding">) {
- const [queuePage, setQueuePage] = useState(1);
- const queuePageSize = 10;
+ const isAdvanced = displayMode === "advanced";
+ const queuePageSize = getQueuePageSize(height, isAdvanced);
+ const [queuePagination, setQueuePagination] = useState(() => ({
+ page: 1,
+ pageSize: queuePageSize,
+ isAdvanced,
+ }));
+ const requestPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, null);
const input = {
integrationId: integrationIds[0] ?? "",
pageSize: queuePageSize,
- page: queuePage,
+ page: requestPagination.page,
};
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState(options.defaultView);
const t = useI18n("widget.mediaTranscoding");
+ const totalQueuePages = transcodingData
+ ? Math.max(1, Math.ceil(transcodingData.data.queue.totalCount / queuePageSize))
+ : null;
+ const resolvedPagination = resolveQueuePagination(queuePagination, queuePageSize, isAdvanced, totalQueuePages);
+ const queuePage = resolvedPagination.page;
+
+ useEffect(() => {
+ setQueuePagination((current) => {
+ const next = resolveQueuePagination(current, queuePageSize, isAdvanced, totalQueuePages);
+ return current.page === next.page && current.pageSize === next.pageSize && current.isAdvanced === next.isAdvanced
+ ? current
+ : next;
+ });
+ }, [isAdvanced, queuePageSize, totalQueuePages]);
+
+ const handleQueuePageChange = (page: number) => {
+ setQueuePagination({ page, pageSize: queuePageSize, isAdvanced });
+ };
if (!transcodingData) return ;
- const totalQueuePages = Math.ceil((transcodingData.data.queue.totalCount || 1) / queuePageSize);
- const isTiny = width < 256;
+ const queuePageCount = totalQueuePages ?? 1;
+ const isTiny = !isAdvanced && width < 256;
+
+ if (isAdvanced) {
+ return (
+
+
+ = 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+ }
return (
@@ -89,12 +151,12 @@ export default function MediaTranscodingWidget({
{view === "queue" && (
<>
-
+
- {!isTiny && }
-
-
- {!isTiny && }
+ {!isTiny && }
+
+
+ {!isTiny && }
@@ -113,3 +175,40 @@ export default function MediaTranscodingWidget({
);
}
+
+export const getQueuePageSize = (height: number, isAdvanced: boolean): number => {
+ if (isAdvanced) return 25;
+ return Math.max(3, Math.min(15, Math.floor((height - 84) / 28)));
+};
+
+interface QueuePaginationState {
+ page: number;
+ pageSize: number;
+ isAdvanced: boolean;
+}
+
+export const resolveQueuePagination = (
+ state: QueuePaginationState,
+ pageSize: number,
+ isAdvanced: boolean,
+ totalPages: number | null,
+): QueuePaginationState => ({
+ page:
+ state.pageSize !== pageSize || state.isAdvanced !== isAdvanced
+ ? 1
+ : Math.max(1, totalPages === null ? state.page : Math.min(state.page, totalPages)),
+ pageSize,
+ isAdvanced,
+});
+
+const AdvancedPanel = ({ title, icon: Icon, children }: { title: string; icon: TablerIcon; children: ReactNode }) => (
+
+
+
+
+ {title}
+
+
+ {children}
+
+);
diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts
index ff4922dad..de92a46ca 100644
--- a/packages/widgets/src/media-transcoding/index.ts
+++ b/packages/widgets/src/media-transcoding/index.ts
@@ -11,6 +11,8 @@ export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
+ queryKey: [["widget", "mediaTranscoding"]],
+ maxIntegrations: 1,
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-transcoding/layout.spec.ts b/packages/widgets/src/media-transcoding/layout.spec.ts
new file mode 100644
index 000000000..44f2d2306
--- /dev/null
+++ b/packages/widgets/src/media-transcoding/layout.spec.ts
@@ -0,0 +1,35 @@
+import { describe, expect, test } from "vitest";
+
+import { getQueuePageSize, resolveQueuePagination } from "./component";
+import { toRingProgressSections } from "./panels/statistics.panel";
+
+describe("media transcoding layout", () => {
+ test("keeps compact queues bounded to the available height", () => {
+ expect(getQueuePageSize(140, false)).toBe(3);
+ expect(getQueuePageSize(500, false)).toBe(14);
+ expect(getQueuePageSize(2000, false)).toBe(15);
+ });
+
+ test("loads a bounded deeper queue in advanced mode", () => {
+ expect(getQueuePageSize(200, true)).toBe(25);
+ });
+
+ test("resets the queue when display mode or page size changes", () => {
+ const compact = { page: 7, pageSize: 10, isAdvanced: false };
+
+ expect(resolveQueuePagination(compact, 25, true, 8)).toEqual({ page: 1, pageSize: 25, isAdvanced: true });
+ expect(resolveQueuePagination(compact, 12, false, 8)).toEqual({ page: 1, pageSize: 12, isAdvanced: false });
+ });
+
+ test("clamps the queue page when the result count shrinks", () => {
+ expect(resolveQueuePagination({ page: 7, pageSize: 10, isAdvanced: false }, 10, false, 3)).toEqual({
+ page: 3,
+ pageSize: 10,
+ isAdvanced: false,
+ });
+ });
+
+ test("does not create NaN chart sections for empty statistics", () => {
+ expect(toRingProgressSections([{ name: "none", value: 0 }])).toEqual([]);
+ });
+});
diff --git a/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx
index c09647138..e31f51de1 100644
--- a/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx
+++ b/packages/widgets/src/media-transcoding/panels/statistics.panel.tsx
@@ -71,8 +71,9 @@ const StatisticRingProgress = ({ items, label }: StatisticRingProgressProps) =>
);
};
-function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
+export function toRingProgressSections(segments: TdarrPieSegment[]): RingProgressProps["sections"] {
const total = segments.reduce((prev, curr) => prev + curr.value, 0);
+ if (total <= 0) return [];
return segments.map((segment, index) => ({
value: (segment.value * 100) / total,
tooltip: `${segment.name}: ${segment.value}`,
diff --git a/packages/widgets/src/minecraft/server-status/component.tsx b/packages/widgets/src/minecraft/server-status/component.tsx
index 20214e3ac..2f33ebb19 100644
--- a/packages/widgets/src/minecraft/server-status/component.tsx
+++ b/packages/widgets/src/minecraft/server-status/component.tsx
@@ -1,23 +1,31 @@
"use client";
-import { Box, Flex, Group, Text, Tooltip } from "@mantine/core";
+import { Badge, Box, Flex, Group, Progress, Stack, Text, Tooltip } from "@mantine/core";
import { IconCube, IconUsersGroup } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
-import { useScopedI18n } from "@homarr/translation/client";
+import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
-export default function MinecraftServerStatusWidget({ options }: WidgetComponentProps<"minecraftServerStatus">) {
+export default function MinecraftServerStatusWidget({
+ options,
+ width,
+ height,
+ displayMode,
+}: WidgetComponentProps<"minecraftServerStatus">) {
const { data: result } = clientApi.widget.minecraft.getServerStatus.useQuery(options);
- const tStatus = useScopedI18n("widget.minecraftServerStatus.status");
+ const t = useI18n();
if (!result) return ;
const { data } = result;
const title = options.title.trim().length > 0 ? options.title : options.domain;
+ const isAdvanced = displayMode === "advanced";
+ const iconSize = Math.max(40, Math.min(isAdvanced ? 128 : 80, width * 0.45, height * 0.45));
+ const playerPercent = data.online && data.players.max > 0 ? (data.players.online / data.players.max) * 100 : 0;
return (
-
+
-
+
{title}
+ {isAdvanced && (
+
+ {options.domain}
+ {options.isBedrockServer && (
+ {t("widget.minecraftServerStatus.option.isBedrockServer.label")}
+ )}
+
+ )}
{data.online && (
<>
{!options.isBedrockServer &&
(data.icon ? (
@@ -55,17 +77,25 @@ export default function MinecraftServerStatusWidget({ options }: WidgetComponent
justifyContent: "center",
}}
>
-
+
))}
-
-
-
- {formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
-
-
+
+
+
+
+ {formatNumber(data.players.online, 1)} / {formatNumber(data.players.max, 1)}
+
+
+ {isAdvanced && = 90 ? "orange" : "teal"} />}
+
>
)}
+ {isAdvanced && !data.online && (
+
+ {options.domain}
+
+ )}
);
}
diff --git a/packages/widgets/src/modals/widget-edit-modal.tsx b/packages/widgets/src/modals/widget-edit-modal.tsx
index 3edb6ffe5..3e745484e 100644
--- a/packages/widgets/src/modals/widget-edit-modal.tsx
+++ b/packages/widgets/src/modals/widget-edit-modal.tsx
@@ -8,6 +8,7 @@ import { z } from "zod/v4";
import { objectEntries } from "@homarr/common";
import { useSession } from "@homarr/auth/client";
+import { useOptionalBoard } from "@homarr/boards/context";
import type { WidgetKind } from "@homarr/definitions";
import { createModal, ModalFormFooter, modalSizeForm, useModalAction } from "@homarr/modals";
import type { SettingsContextProps } from "@homarr/settings/creator";
@@ -40,11 +41,13 @@ interface ModalProps {
integrationSupport: boolean;
settings: SettingsContextProps;
itemId?: string;
+ boardId?: string;
appId?: string;
}
export const WidgetEditModal = createModal>(({ actions, innerProps }) => {
const t = useI18n();
+ const board = useOptionalBoard();
const { data: session } = useSession();
const [advancedOptions, setAdvancedOptions] = useState(innerProps.value.advancedOptions);
const appEditRef = useRef(null);
@@ -154,6 +157,7 @@ export const WidgetEditModal = createModal>(({ actions, i
options={value as never}
initialOptions={innerProps.value.options}
itemId={innerProps.itemId}
+ boardId={innerProps.boardId ?? board?.id}
/>
);
})}
diff --git a/packages/widgets/src/network-controller/network-status/component.tsx b/packages/widgets/src/network-controller/network-status/component.tsx
index c4e892dba..7472d5007 100644
--- a/packages/widgets/src/network-controller/network-status/component.tsx
+++ b/packages/widgets/src/network-controller/network-status/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { useMemo } from "react";
-import { Box } from "@mantine/core";
+import { Box, Card, SimpleGrid } from "@mantine/core";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import objectSupport from "dayjs/plugin/objectSupport";
@@ -20,26 +20,33 @@ dayjs.extend(duration);
export default function NetworkControllerNetworkStatusWidget({
options,
integrationIds,
+ displayMode,
}: WidgetComponentProps<"networkControllerStatus">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
});
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
+ const isAdvanced = displayMode === "advanced";
+ const countWifiGuests = data.reduce((sum, summary) => sum + summary.wifi.guests, 0);
+ const countWifiUsers = data.reduce((sum, summary) => sum + summary.wifi.users, 0);
+ const countLanGuests = data.reduce((sum, summary) => sum + summary.lan.guests, 0);
+ const countLanUsers = data.reduce((sum, summary) => sum + summary.lan.users, 0);
return (
-
- {options.content === "wifi" ? (
- sum + summary.wifi.guests, 0)}
- countUsers={data.reduce((sum, summary) => sum + summary.wifi.users, 0)}
- />
- ) : (
- sum + summary.lan.guests, 0)}
- countUsers={data.reduce((sum, summary) => sum + summary.lan.users, 0)}
- />
- )}
+
+
+ {(isAdvanced || options.content === "wifi") && (
+
+
+
+ )}
+ {(isAdvanced || options.content === "wired") && (
+
+
+
+ )}
+
);
}
diff --git a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx
index aa6c1998b..de9be70d3 100644
--- a/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx
+++ b/packages/widgets/src/network-controller/network-status/variants/stat-row.tsx
@@ -1,12 +1,20 @@
import { Stack, Text } from "@mantine/core";
-export const StatRow = ({ label, value }: { label: string; value: string | number }) => {
+export const StatRow = ({
+ label,
+ value,
+ compact = false,
+}: {
+ label: string;
+ value: string | number;
+ compact?: boolean;
+}) => {
return (
-
+
{value}
-
+
{label}
diff --git a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx
index 85b7e2355..9560c984a 100644
--- a/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx
+++ b/packages/widgets/src/network-controller/network-status/variants/wifi-variant.tsx
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
import { StatRow } from "./stat-row";
-export const WifiVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
+export const WifiVariant = ({
+ countGuests,
+ countUsers,
+ compact = false,
+}: {
+ countUsers: number;
+ countGuests: number;
+ compact?: boolean;
+}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
<>
-
+
{t("variants.wifi.name")}
-
-
-
+
+
+
>
);
diff --git a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx
index f635dd897..46ca76a83 100644
--- a/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx
+++ b/packages/widgets/src/network-controller/network-status/variants/wired-variant.tsx
@@ -5,19 +5,27 @@ import { useScopedI18n } from "@homarr/translation/client";
import { StatRow } from "./stat-row";
-export const WiredVariant = ({ countGuests, countUsers }: { countUsers: number; countGuests: number }) => {
+export const WiredVariant = ({
+ countGuests,
+ countUsers,
+ compact = false,
+}: {
+ countUsers: number;
+ countGuests: number;
+ compact?: boolean;
+}) => {
const t = useScopedI18n("widget.networkControllerStatus.card");
return (
<>
-
+
{t("variants.wired.name")}
-
-
-
+
+
+
>
);
diff --git a/packages/widgets/src/network-controller/summary/component.tsx b/packages/widgets/src/network-controller/summary/component.tsx
index cb5dc7545..86075dc0b 100644
--- a/packages/widgets/src/network-controller/summary/component.tsx
+++ b/packages/widgets/src/network-controller/summary/component.tsx
@@ -1,7 +1,6 @@
"use client";
-import { useMemo } from "react";
-import { Box, Center, List, Text, useMantineTheme } from "@mantine/core";
+import { Badge, Card, Group, List, ScrollArea, SimpleGrid, Stack, Text, useMantineTheme } from "@mantine/core";
import { IconCircleCheckFilled, IconCircleXFilled } from "@tabler/icons-react";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
@@ -19,6 +18,8 @@ dayjs.extend(duration);
export default function NetworkControllerSummaryWidget({
integrationIds,
+ width,
+ displayMode,
}: WidgetComponentProps<"networkControllerSummary">) {
const { data: summaries = [] } = clientApi.widget.networkController.summary.useQuery({
integrationIds,
@@ -26,33 +27,57 @@ export default function NetworkControllerSummaryWidget({
const t = useI18n();
- const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
+ const isAdvanced = displayMode === "advanced";
+ const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
-
-
-
- }>WAN
- }>
-
- WWW
-
- {data[0]?.www.latency}ms
-
-
-
- }>Wi-Fi
- }>
-
- VPN
-
- {t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${data[0]?.vpn.users}` })}
-
-
-
-
-
-
+
+
+ {summaries.map(({ integration, summary }) => (
+ 1 || isAdvanced}>
+
+ {(isAdvanced || summaries.length > 1) && (
+
+
+ {integration.name}
+
+
+ {integration.kind}
+
+
+ )}
+
+ }>WAN
+ }>
+
+ WWW
+
+ {summary.www.latency}ms{isAdvanced ? ` · ${summary.www.ping}ms` : ""}
+
+
+
+ }>
+ Wi-Fi{isAdvanced ? ` · ${summary.wifi.users + summary.wifi.guests}` : ""}
+
+ {isAdvanced && (
+ }>
+ LAN · {summary.lan.users + summary.lan.guests}
+
+ )}
+ }>
+
+ VPN
+
+ {t("widget.networkControllerSummary.card.vpn.countConnected", { count: `${summary.vpn.users}` })}
+
+
+
+
+
+
+ ))}
+
+
);
}
@@ -61,5 +86,6 @@ const StatusIcon = ({ status }: { status?: "enabled" | "disabled" }) => {
if (status === "enabled") {
return ;
}
- return ;
+ if (status === "disabled") return ;
+ return ;
};
diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx
index 9fdd692fb..e9bed344e 100644
--- a/packages/widgets/src/notebook/notebook.tsx
+++ b/packages/widgets/src/notebook/notebook.tsx
@@ -1,7 +1,7 @@
"use client";
import type { MouseEvent as ReactMouseEvent } from "react";
-import { useCallback, useEffect, useRef, useState } from "react";
+import { useCallback, useEffect, useId, useRef, useState } from "react";
import {
ActionIcon,
Box,
@@ -13,7 +13,9 @@ import {
Popover,
ScrollArea,
Stack,
+ Text,
TextInput,
+ Tooltip,
useMantineColorScheme,
useMantineTheme,
} from "@mantine/core";
@@ -89,16 +91,63 @@ const controlIconProps = {
stroke: 1.5,
};
-export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: WidgetComponentProps<"notebook">) {
- const [content, setContent] = useState(options.content);
- const previousContentRef = useRef(content);
+interface NotebookPersistedState {
+ itemId: string | undefined;
+ content: string;
+ previousContent: string;
+ isEditing: boolean;
+ saveError: string | null;
+}
+
+export function Notebook({
+ options,
+ setOptions,
+ isEditMode,
+ boardId,
+ itemId,
+ displayMode,
+ height,
+ widgetStateRef,
+}: WidgetComponentProps<"notebook">) {
+ const persistedState = widgetStateRef?.current?.notebook as NotebookPersistedState | undefined;
+ const canHydratePersistedState = persistedState?.itemId === itemId;
+ const initialContent = canHydratePersistedState ? (persistedState?.content ?? options.content) : options.content;
+ const [content, setContent] = useState(initialContent);
+ const previousContentRef = useRef(
+ canHydratePersistedState ? (persistedState?.previousContent ?? initialContent) : initialContent,
+ );
+ const [saveError, setSaveError] = useState(
+ canHydratePersistedState ? (persistedState?.saveError ?? null) : null,
+ );
const board = useRequiredBoard();
const { data: session } = useSession();
const { hasChangeAccess } = constructBoardPermissions(board, session);
const canChange = !isEditMode && hasChangeAccess;
- const [isEditing, setIsEditing] = useState(false);
+ const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false);
+ const canChangeRef = useRef(canChange);
+ const allowReadOnlyCheckRef = useRef(options.allowReadOnlyCheck);
+ const readOnlyCheckEventName = `homarr:notebook-read-only-check:${useId()}`;
+
+ useEffect(() => {
+ canChangeRef.current = canChange;
+ allowReadOnlyCheckRef.current = options.allowReadOnlyCheck;
+ }, [canChange, options.allowReadOnlyCheck]);
+
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ notebook: {
+ itemId,
+ content,
+ previousContent: previousContentRef.current,
+ isEditing,
+ saveError,
+ },
+ };
+ }, [content, isEditing, itemId, saveError, widgetStateRef]);
const { primaryColor } = useMantineTheme();
const { colorScheme } = useMantineColorScheme();
@@ -108,6 +157,37 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
const tControls = useScopedI18n("widget.notebook.controls");
const t = useI18n();
+ const handleContentUpdate = useCallback(
+ async (contentUpdate: string) => {
+ setSaveError(null);
+
+ try {
+ if (boardId && itemId) {
+ await mutateAsync({ boardId, itemId, content: contentUpdate });
+ }
+ previousContentRef.current = contentUpdate;
+ if (widgetStateRef) {
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ notebook: {
+ itemId,
+ content: contentUpdate,
+ previousContent: contentUpdate,
+ isEditing,
+ saveError: null,
+ },
+ };
+ }
+ setOptions({ newOptions: { content: contentUpdate } });
+ return true;
+ } catch (error) {
+ setSaveError(error instanceof Error ? error.message : t("widget.notebook.saveFailed"));
+ return false;
+ }
+ },
+ [boardId, isEditing, itemId, mutateAsync, setOptions, t, widgetStateRef],
+ );
+
const editor = useEditor(
{
extensions: [
@@ -162,10 +242,10 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
TaskItem.configure({
nested: true,
onReadOnlyChecked: (node, checked) => {
- if (!options.allowReadOnlyCheck) return false;
- if (!canChange) return false;
+ if (!allowReadOnlyCheckRef.current) return false;
+ if (!canChangeRef.current) return false;
- const event = new CustomEvent("onReadOnlyCheck", {
+ const event = new CustomEvent(readOnlyCheckEventName, {
detail: { node, checked },
});
dispatchEvent(event);
@@ -184,60 +264,51 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setContent(editor.getHTML());
},
onCreate: ({ editor }) => {
- editor.setEditable(false);
+ editor.setEditable(isEditing);
},
},
[],
);
- const handleOnReadOnlyCheck = (event: CustomEventInit<{ node: Node; checked: boolean }>) => {
- if (!options.allowReadOnlyCheck) return;
- if (!editor) return;
+ const handleOnReadOnlyCheck = useCallback(
+ (event: Event) => {
+ if (!allowReadOnlyCheckRef.current) return;
+ if (!editor) return;
+ const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
- editor.state.doc.descendants((subnode, pos) => {
- if (!event.detail) return;
- if (!subnode.eq(event.detail.node)) return;
+ editor.state.doc.descendants((subnode, pos) => {
+ if (!detail) return;
+ if (!subnode.eq(detail.node)) return;
- const { tr } = editor.state;
- tr.setNodeMarkup(pos, undefined, {
- ...event.detail.node.attrs,
- checked: event.detail.checked,
+ const { tr } = editor.state;
+ tr.setNodeMarkup(pos, undefined, {
+ ...detail.node.attrs,
+ checked: detail.checked,
+ });
+ editor.view.dispatch(tr);
+ const nextContent = editor.getHTML();
+ setContent(nextContent);
+ void handleContentUpdate(nextContent).then((wasSaved) => {
+ if (wasSaved) return;
+ setContent(previousContentRef.current);
+ editor.commands.setContent(previousContentRef.current);
+ });
});
- editor.view.dispatch(tr);
- setContent(editor.getHTML());
- handleContentUpdate(editor.getHTML());
- });
- };
-
- addEventListener("onReadOnlyCheck", handleOnReadOnlyCheck);
-
- const handleContentUpdate = useCallback(
- (contentUpdate: string) => {
- previousContentRef.current = contentUpdate;
- setOptions({ newOptions: { content: contentUpdate } });
-
- // This is not available in preview mode
- if (boardId && itemId) {
- void mutateAsync({ boardId, itemId, content: contentUpdate });
- }
},
- [boardId, itemId, mutateAsync, setOptions],
+ [editor, handleContentUpdate],
);
- const handleEditToggleCallback = useCallback(
- (previous: boolean) => {
- const current = !previous;
- if (!editor) return current;
- editor.setEditable(current);
+ useEffect(() => {
+ addEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
+ return () => removeEventListener(readOnlyCheckEventName, handleOnReadOnlyCheck);
+ }, [handleOnReadOnlyCheck, readOnlyCheckEventName]);
- if (previous) {
- handleContentUpdate(content);
- }
-
- return current;
- },
- [content, editor, handleContentUpdate],
- );
+ useEffect(() => {
+ if (isEditing || !editor || options.content === previousContentRef.current) return;
+ previousContentRef.current = options.content;
+ setContent(options.content);
+ editor.commands.setContent(options.content);
+ }, [editor, isEditing, options.content]);
const handleEditCancelCallback = useCallback(() => {
if (!editor) return false;
@@ -264,9 +335,20 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
});
}, [setIsEditing, handleEditCancelCallback, openConfirmModal, t]);
- const handleEditToggle = useCallback(() => {
- setIsEditing(handleEditToggleCallback);
- }, [setIsEditing, handleEditToggleCallback]);
+ const handleEditToggle = useCallback(async () => {
+ if (!editor) return;
+ if (!isEditing) {
+ setSaveError(null);
+ editor.setEditable(true);
+ setIsEditing(true);
+ return;
+ }
+
+ const wasSaved = await handleContentUpdate(content);
+ if (!wasSaved) return;
+ editor.setEditable(false);
+ setIsEditing(false);
+ }, [content, editor, handleContentUpdate, isEditing]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent) => {
@@ -276,18 +358,26 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
if (event.target instanceof Element && event.target.closest("button, a")) {
return;
}
- setIsEditing(handleEditToggleCallback);
+ setSaveError(null);
+ editor?.setEditable(true);
+ setIsEditing(true);
},
- [canChange, isEditing, setIsEditing, handleEditToggleCallback],
+ [canChange, editor, isEditing],
);
+ const documentText = editor?.getText().trim() ?? "";
+ const documentStats = {
+ characters: documentText.length,
+ words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length,
+ };
+
return (
void handleEditToggle()]]) : undefined}
editor={editor}
styles={(theme) => ({
root: {
@@ -304,7 +394,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
},
content: {
backgroundColor: "transparent",
- padding: "0.5rem",
+ padding: height < 120 && displayMode !== "advanced" ? "0.25rem" : "0.5rem",
height: "100%",
},
typographyStylesProvider: {
@@ -314,7 +404,7 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
>
@@ -442,6 +532,27 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
+ {displayMode === "advanced" && (
+
+ {saveError && (
+
+
+ {t("widget.notebook.saveFailed")}
+
+
+ )}
+
+ {t("widget.notebook.documentStats", documentStats)}
+
+
+ )}
{canChange && (
<>
void handleEditToggle()}
>
{isEditing ? : }
diff --git a/packages/widgets/src/notifications/component.tsx b/packages/widgets/src/notifications/component.tsx
index 3b04393d0..421471956 100644
--- a/packages/widgets/src/notifications/component.tsx
+++ b/packages/widgets/src/notifications/component.tsx
@@ -1,78 +1,110 @@
"use client";
import { useMemo } from "react";
-import { Avatar, Card, Flex, Group, ScrollArea, Stack, Text } from "@mantine/core";
+import { Avatar, Badge, Card, Flex, Group, ScrollArea, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useTimeAgo } from "@homarr/common";
-import { useScopedI18n } from "@homarr/translation/client";
+import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
-export default function NotificationsWidget({ options, integrationIds }: WidgetComponentProps<"notifications">) {
+export default function NotificationsWidget({
+ options,
+ integrationIds,
+ width,
+ displayMode,
+}: WidgetComponentProps<"notifications">) {
const { data: notificationIntegrations = [] } = clientApi.widget.notifications.getNotifications.useQuery({
...options,
integrationIds,
});
- const t = useScopedI18n("widget.notifications");
+ const t = useI18n();
const board = useRequiredBoard();
const sortedNotifications = useMemo(
() =>
notificationIntegrations
- .flatMap((integration) => integration.data)
+ .flatMap((integration) =>
+ integration.data.map((notification) => ({
+ ...notification,
+ compositeKey: `${integration.integration.id}:${notification.id}`,
+ integrationName: integration.integration.name,
+ })),
+ )
.sort((entryA, entryB) => entryB.time.getTime() - entryA.time.getTime()),
[notificationIntegrations],
);
+ const failedIntegrations = notificationIntegrations.filter(
+ (integration): integration is typeof integration & { error: string } => Boolean(integration.error),
+ );
+ const isAdvanced = displayMode === "advanced";
+ const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
-
-
+
+
+ {failedIntegrations.length > 0 && (
+
+ {failedIntegrations.map((integration) => (
+
+
+ {integration.integration.name}: {t("common.error")}
+
+
+ ))}
+
+ )}
{sortedNotifications.length > 0 ? (
- sortedNotifications.map((notification) => (
-
-
- {!options.hideLogos && notification.source?.iconUrl && (
-
- )}
-
-
- {notification.title && (
-
- {notification.title}
-
+
+ {sortedNotifications.map((notification) => (
+
+
+ {!options.hideLogos && notification.source?.iconUrl && (
+
)}
-
- {notification.body}
-
-
+
+ {notification.title && (
+
+ {notification.title}
+
+ )}
+
+ {notification.body}
+
+
+
+
-
-
- ))
+
+ ))}
+
) : (
- {t("noItems")}
+ {t("widget.notifications.noItems")}
)}
@@ -80,15 +112,21 @@ export default function NotificationsWidget({ options, integrationIds }: WidgetC
);
}
-const InfoDisplay = ({ date }: { date: Date }) => {
+const InfoDisplay = ({ date, source }: { date: Date; source?: string }) => {
const timeAgo = useTimeAgo(date, 30000); // update every 30sec
return (
-
+
{timeAgo}
+ {source && • }
+ {source && (
+
+ {source}
+
+ )}
);
};
diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts
index 17fd13393..27273c343 100644
--- a/packages/widgets/src/notifications/index.ts
+++ b/packages/widgets/src/notifications/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("notifications", {
icon: IconMessage,
+ queryKey: [["widget", "notifications"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts
index c699e68da..85ef192b9 100644
--- a/packages/widgets/src/options.ts
+++ b/packages/widgets/src/options.ts
@@ -56,6 +56,8 @@ interface DynamicSelectInput extends CommonInput {
query: string,
integrationIds: string[],
options: Record,
+ itemId?: string,
+ boardId?: string,
) => {
isPending: boolean;
options: DynamicSelectOption[];
diff --git a/packages/widgets/src/paperless-ngx/component.tsx b/packages/widgets/src/paperless-ngx/component.tsx
index 08bcee3fb..4744c1bec 100644
--- a/packages/widgets/src/paperless-ngx/component.tsx
+++ b/packages/widgets/src/paperless-ngx/component.tsx
@@ -70,7 +70,13 @@ const heroPartVisibility = {
ring: "showInboxRing",
} as const;
-export default function PaperlessNgxWidget({ integrationIds, options, width }: WidgetComponentProps<"paperlessNgx">) {
+export default function PaperlessNgxWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"paperlessNgx">) {
const t = useScopedI18n("widget.paperlessNgx");
const { data: stats } = clientApi.widget.paperlessNgx.getStats.useQuery({
integrationId: integrationIds[0] ?? "",
@@ -95,18 +101,20 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
.map(([, statKey]) => statKey)
.filter((statKey) => !(showHero && gridHiddenWhenHeroShown.has(statKey)));
- const gridCols = getGridCols(width);
- const ringSize = getRingSize(width);
+ const advanced = displayMode === "advanced";
+ const gridCols = advanced ? Math.max(1, Math.min(5, visibleStatKeys.length)) : getGridCols(width);
+ const ringSize = advanced ? 112 : getRingSize(width);
const iconSize = getIconSize(width);
const ringLabelSize = getRingLabelSize(ringSize);
const hasContent = showHero || visibleStatKeys.length > 0;
const heroLayoutClass =
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
- const heroRingClass = heroVariantByRing[String(options.showInboxRing) as keyof typeof heroVariantByRing];
+ const showInboxRing = options.showInboxRing && (advanced || height >= 120);
+ const heroRingClass = heroVariantByRing[String(showInboxRing) as keyof typeof heroVariantByRing];
const visibleHeroParts = Object.entries(heroPartVisibility).filter(
- ([, optionKey]) => options[optionKey as keyof typeof options],
+ ([partKey, optionKey]) => options[optionKey as keyof typeof options] && (partKey !== "ring" || showInboxRing),
);
const heroPartRenderers = {
@@ -140,7 +148,11 @@ export default function PaperlessNgxWidget({ integrationIds, options, width }: W
} as const;
return (
-
+
{showHero && (
{visibleHeroParts.map(([partKey]) => (
diff --git a/packages/widgets/src/paperless-ngx/index.ts b/packages/widgets/src/paperless-ngx/index.ts
index ef6fd2e5a..af88489ab 100644
--- a/packages/widgets/src/paperless-ngx/index.ts
+++ b/packages/widgets/src/paperless-ngx/index.ts
@@ -5,9 +5,11 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
icon: IconFileText,
+ queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
showInboxRatio: factory.switch({
diff --git a/packages/widgets/src/patchmon/component.tsx b/packages/widgets/src/patchmon/component.tsx
index f1dae3e29..951412254 100644
--- a/packages/widgets/src/patchmon/component.tsx
+++ b/packages/widgets/src/patchmon/component.tsx
@@ -75,7 +75,13 @@ const getLayoutMode = (width: number, height: number): LayoutMode => {
return "comfortable";
};
-export default function PatchMonWidget({ integrationIds, options, width, height }: WidgetComponentProps<"patchmon">) {
+export default function PatchMonWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"patchmon">) {
const t = useScopedI18n("widget.patchmon");
const integrationId = integrationIds[0] ?? "";
const { data: stats } = clientApi.widget.patchmon.getStats.useQuery({ integrationId }, { staleTime: 60 * 1000 });
@@ -98,18 +104,20 @@ export default function PatchMonWidget({ integrationIds, options, width, height
.map(([, statKey]) => statKey);
const colorContext = { totalHosts: stats.totalHosts };
- const layout = getLayoutMode(width, height);
- const isLandscape = width / height > 2.5;
+ const advanced = displayMode === "advanced";
+ const layout = advanced ? "spacious" : getLayoutMode(width, height);
+ const isLandscape = !advanced && width / height > 2.5;
const isTinySquare = width < 160 && height < 160;
const isShortLandscape = width >= 160 && height < 160;
- const isCompactSurface = isTinySquare || isShortLandscape;
+ const isCompactSurface = !advanced && (isTinySquare || isShortLandscape);
const isNarrow = width < 180;
const osDisplayMode = options.osDisplayMode as "bars" | "donut";
const showOsData = options.showOsDistribution && stats.osDistribution.length > 0;
const showIcons = layout !== "mini";
const showLabels = layout !== "mini";
- const showHero = options.showComplianceHero && (!isLandscape || isCompactSurface) && width >= 90 && height >= 90;
+ const showHero =
+ options.showComplianceHero && (advanced || !isLandscape || isCompactSurface) && width >= 90 && height >= 90;
const showHeroText = showHero && shouldShowComplianceHeroText(width);
const showHeroRingOnly = showHero && !showHeroText;
const compactPrimaryContent = isCompactSurface
@@ -120,10 +128,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
})
: null;
const showStats = visibleStatKeys.length > 0 && !isCompactSurface;
- const showOsSection = showOsData && !isCompactSurface && height > 200;
+ const showOsSection = showOsData && !isCompactSurface && (advanced || height > 200);
const showCompactFooter = isCompactSurface && height >= 110;
- const showFooter = isCompactSurface ? showCompactFooter : !isLandscape && height > 220;
- const showOsLegend = osDisplayMode === "donut" && width >= 380 && height >= 300;
+ const showFooter = advanced || (isCompactSurface ? showCompactFooter : !isLandscape && height > 220);
+ const showOsLegend = osDisplayMode === "donut" && (advanced || (width >= 380 && height >= 300));
const hasContent = compactPrimaryContent !== null || showStats || showOsSection || showHero;
@@ -272,10 +280,10 @@ export default function PatchMonWidget({ integrationIds, options, width, height
);
- const needsScroll = layout === "mini" || layout === "compact";
+ const needsScroll = !advanced && (layout === "mini" || layout === "compact");
return (
-
+
{showHero && (
({
@@ -188,18 +190,42 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon"
useCustomThresholds: {
shouldHide: (options) => !options.enableThresholdColors,
},
- hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
- upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds },
+ hostsNeedingUpdatesThresholdMode: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ hostsNeedingUpdatesWarningAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ hostsNeedingUpdatesCriticalAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ securityUpdatesThresholdMode: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ securityUpdatesWarningAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ securityUpdatesCriticalAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ hostsWithSecurityUpdatesThresholdMode: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ hostsWithSecurityUpdatesWarningAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ hostsWithSecurityUpdatesCriticalAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ upToDateHostsThresholdMode: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ upToDateHostsWarningAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
+ upToDateHostsCriticalAt: {
+ shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds,
+ },
totalOutdatedPackagesThresholdMode: {
shouldHide: (options) =>
!options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages,
diff --git a/packages/widgets/src/patchmon/os-distribution-section.tsx b/packages/widgets/src/patchmon/os-distribution-section.tsx
index cb21cb203..4cb03447c 100644
--- a/packages/widgets/src/patchmon/os-distribution-section.tsx
+++ b/packages/widgets/src/patchmon/os-distribution-section.tsx
@@ -9,16 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
-const OS_COLORS = [
- "blue.6",
- "orange.6",
- "green.6",
- "grape.6",
- "cyan.6",
- "pink.6",
- "yellow.6",
- "teal.6",
-] as const;
+const OS_COLORS = ["blue.6", "orange.6", "green.6", "grape.6", "cyan.6", "pink.6", "yellow.6", "teal.6"] as const;
interface OsDistributionSectionProps {
entries: PatchMonOsDistributionEntry[];
@@ -73,13 +64,7 @@ export function OsDistributionSection({
)}
-
+
{showLegend && (
@@ -115,7 +100,12 @@ export function OsDistributionSection({
{compactEntries.map((entry, i) => (
-
+
{formatOsLabel(entry, showOsVersion)}
diff --git a/packages/widgets/src/patchmon/stat-colors.ts b/packages/widgets/src/patchmon/stat-colors.ts
index 7ecd035ac..1974f73a2 100644
--- a/packages/widgets/src/patchmon/stat-colors.ts
+++ b/packages/widgets/src/patchmon/stat-colors.ts
@@ -13,11 +13,7 @@ export type PatchMonStatKey =
export type ColorablePatchMonStatKey = Extract<
PatchMonStatKey,
- | "hostsNeedingUpdates"
- | "securityUpdates"
- | "hostsWithSecurityUpdates"
- | "upToDateHosts"
- | "totalOutdatedPackages"
+ "hostsNeedingUpdates" | "securityUpdates" | "hostsWithSecurityUpdates" | "upToDateHosts" | "totalOutdatedPackages"
>;
export type StatSeverity = "green" | "yellow" | "red" | "neutral";
diff --git a/packages/widgets/src/patchmon/test/stat-colors.spec.ts b/packages/widgets/src/patchmon/test/stat-colors.spec.ts
index 12596f499..22d296829 100644
--- a/packages/widgets/src/patchmon/test/stat-colors.spec.ts
+++ b/packages/widgets/src/patchmon/test/stat-colors.spec.ts
@@ -117,11 +117,16 @@ describe("resolveStatColor options", () => {
test("handles zero total hosts for percent-based stats", () => {
expect(resolveStatColor("upToDateHosts", 0, { totalHosts: 0 }, defaultOptions)).toBe("green");
expect(
- resolveStatColor("hostsNeedingUpdates", 1, { totalHosts: 0 }, {
- ...defaultOptions,
- useCustomThresholds: true,
- hostsNeedingUpdatesThresholdMode: "percent",
- }),
+ resolveStatColor(
+ "hostsNeedingUpdates",
+ 1,
+ { totalHosts: 0 },
+ {
+ ...defaultOptions,
+ useCustomThresholds: true,
+ hostsNeedingUpdatesThresholdMode: "percent",
+ },
+ ),
).toBe("red");
});
diff --git a/packages/widgets/src/patchmon/test/threshold-validation.spec.ts b/packages/widgets/src/patchmon/test/threshold-validation.spec.ts
index 00dafba3b..008d9746d 100644
--- a/packages/widgets/src/patchmon/test/threshold-validation.spec.ts
+++ b/packages/widgets/src/patchmon/test/threshold-validation.spec.ts
@@ -23,25 +23,27 @@ const baseOptions = {
totalOutdatedPackagesCriticalAt: 50,
};
-const optionsSchema = z.object({
- hostsNeedingUpdatesWarningAt: z.number().int().min(0),
- hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
- securityUpdatesWarningAt: z.number().int().min(0),
- securityUpdatesCriticalAt: z.number().int().min(0),
- hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
- hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
- upToDateHostsWarningAt: z.number().int().min(0),
- upToDateHostsCriticalAt: z.number().int().min(0),
- totalOutdatedPackagesWarningAt: z.number().int().min(0),
- totalOutdatedPackagesCriticalAt: z.number().int().min(0),
- enableThresholdColors: z.boolean(),
- useCustomThresholds: z.boolean(),
- hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
- securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
- hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
- upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
- totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
-}).superRefine(patchmonOptionsSuperRefine);
+const optionsSchema = z
+ .object({
+ hostsNeedingUpdatesWarningAt: z.number().int().min(0),
+ hostsNeedingUpdatesCriticalAt: z.number().int().min(0),
+ securityUpdatesWarningAt: z.number().int().min(0),
+ securityUpdatesCriticalAt: z.number().int().min(0),
+ hostsWithSecurityUpdatesWarningAt: z.number().int().min(0),
+ hostsWithSecurityUpdatesCriticalAt: z.number().int().min(0),
+ upToDateHostsWarningAt: z.number().int().min(0),
+ upToDateHostsCriticalAt: z.number().int().min(0),
+ totalOutdatedPackagesWarningAt: z.number().int().min(0),
+ totalOutdatedPackagesCriticalAt: z.number().int().min(0),
+ enableThresholdColors: z.boolean(),
+ useCustomThresholds: z.boolean(),
+ hostsNeedingUpdatesThresholdMode: z.enum(["absolute", "percent"]),
+ securityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
+ hostsWithSecurityUpdatesThresholdMode: z.enum(["absolute", "percent"]),
+ upToDateHostsThresholdMode: z.enum(["absolute", "percent"]),
+ totalOutdatedPackagesThresholdMode: z.enum(["absolute", "percent"]),
+ })
+ .superRefine(patchmonOptionsSuperRefine);
describe("patchmonOptionsSuperRefine", () => {
test("accepts percent thresholds within 0-100", () => {
diff --git a/packages/widgets/src/releases/component.tsx b/packages/widgets/src/releases/component.tsx
index ca61d24c3..c0f89c924 100644
--- a/packages/widgets/src/releases/component.tsx
+++ b/packages/widgets/src/releases/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { useCallback, useMemo, useState } from "react";
-import { Button, Divider, Group, Stack, Text, Title, Tooltip } from "@mantine/core";
+import { Button, Divider, Group, ScrollArea, SimpleGrid, Stack, Text, Title, Tooltip } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconArchive,
@@ -37,7 +37,7 @@ const formatRelativeDate = (value: string): string => {
return isMonths ? value.toUpperCase() : isOtherUnits ? value.toLowerCase() : value;
};
-export default function ReleasesWidget({ options, itemId }: WidgetComponentProps<"releases">) {
+export default function ReleasesWidget({ options, itemId, width, displayMode }: WidgetComponentProps<"releases">) {
const t = useScopedI18n("widget.releases");
const now = useNow();
const formatter = useFormatter();
@@ -48,6 +48,7 @@ export default function ReleasesWidget({ options, itemId }: WidgetComponentProps
key: "releases-viewed-versions",
defaultValue: {},
});
+ const isAdvanced = displayMode === "advanced";
const relativeDateOptions = useMemo(
() => ({
@@ -174,137 +175,151 @@ export default function ReleasesWidget({ options, itemId }: WidgetComponentProps
);
return (
-
- {repositories.map((repository: ReleasesRepositoryResponse) => {
- const isActive = expandedRepositoryId === repository.id;
- const hasError = repository.error !== undefined;
+
+
+ {repositories.map((repository: ReleasesRepositoryResponse) => {
+ const isActive = expandedRepositoryId === repository.id;
+ const hasError = repository.error !== undefined;
- return (
-
- toggleExpandedDisplay(repository)}
+ return (
+
-
-
toggleExpandedDisplay(repository)}
>
- {!options.showOnlyIcon && (
-
- {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
- {repository.name || repository.identifier}
-
- )}
+
-
-
+ {/* eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing */}
+ {repository.name || repository.identifier}
+
+ )}
+
+
- {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
-
-
-
+
+ {hasError ? t("error.label") : (repository.latestRelease ?? t("not-found"))}
+
+
+
-
-
- {repository.latestReleaseAt &&
- !hasError &&
- formatter.relativeTime(repository.latestReleaseAt, {
- now,
- style: "long",
- })}
-
- {hasError ? (
-
- ) : repository.viewed ? (
-
- ) : (
- (repository.isNewRelease || repository.isStaleRelease) && (
-
+
+ {repository.latestReleaseAt &&
+ !hasError &&
+ formatter.relativeTime(repository.latestReleaseAt, {
+ now,
+ style: "long",
+ })}
+
+ {hasError ? (
+
- )
- )}
+ ) : repository.viewed ? (
+
+ ) : (
+ (repository.isNewRelease || repository.isStaleRelease) && (
+
+ )
+ )}
+
-
- {options.showDetails && (
-
- )}
- {isActive && (
-
- )}
-
-
- );
- })}
-
+ {(options.showDetails || isAdvanced) && (
+
+ )}
+ {isActive && (
+
+ )}
+
+
+ );
+ })}
+
+
);
}
diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts
index 62fa7ebab..88ae80cda 100644
--- a/packages/widgets/src/releases/index.ts
+++ b/packages/widgets/src/releases/index.ts
@@ -14,6 +14,7 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
+ queryKey: [["widget", "releases"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx
index 626f87328..a3d9dd3a2 100644
--- a/packages/widgets/src/rssFeed/component.tsx
+++ b/packages/widgets/src/rssFeed/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { Card, Flex, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
+import { Card, Flex, Group, Image, ScrollArea, SimpleGrid, Text } from "@mantine/core";
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
@@ -17,7 +17,7 @@ const useLiveFeedEntries = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]
return feedEntries;
};
-export default function RssFeed({ options, width }: WidgetComponentProps<"rssFeed">) {
+export default function RssFeed({ options, width, displayMode }: WidgetComponentProps<"rssFeed">) {
const feedEntries = useLiveFeedEntries({
urls: options.feedUrls,
maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100,
@@ -27,60 +27,109 @@ export default function RssFeed({ options, width }: WidgetComponentProps<"rssFee
const languageDir = options.enableRtl ? "RTL" : "LTR";
- const isNarrow = width < 128 * 3;
+ const isAdvanced = displayMode === "advanced";
+ const isNarrow = !isAdvanced && width < 128 * 3;
+ const columns = isAdvanced && width >= 720 ? 2 : 1;
return (
-
-
+
+
{feedEntries.map((feedEntry) => (
{feedEntry.enclosure !== undefined && (
)}
-
+
{feedEntry.enclosure !== undefined && options.showPosterImage && !isNarrow && (
-
+
)}
-
-
+
+
{feedEntry.title}
{!options.hideDescription && feedEntry.description && (
+ lineClamp={isAdvanced ? Math.max(options.textLinesClamp, 8) : options.textLinesClamp}
+ >
+ {feedDescriptionToText(feedEntry.description)}
+
)}
- {feedEntry.published && }
+
))}
-
+
);
}
-const InfoDisplay = ({ date }: { date: string }) => (
-
-
-
- {date}
-
+export const feedDescriptionToText = (description: string): string => {
+ if (typeof DOMParser !== "undefined") {
+ return new DOMParser().parseFromString(description, "text/html").body.textContent?.trim() ?? "";
+ }
+ return description
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
+};
+
+export const getHostname = (url: string): string => {
+ try {
+ return new URL(url).hostname;
+ } catch {
+ return url;
+ }
+};
+
+export const getSafeExternalUrl = (value: unknown, baseUrl?: string): string | undefined => {
+ if (typeof value !== "string") return undefined;
+ try {
+ const url = new URL(value, baseUrl);
+ return url.protocol === "http:" || url.protocol === "https:" ? url.toString() : undefined;
+ } catch {
+ return undefined;
+ }
+};
+
+const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
+
+ {date && }
+ {date && (
+
+ {date}
+
+ )}
+ {date && source && • }
+ {source && (
+
+ {source}
+
+ )}
);
diff --git a/packages/widgets/src/rssFeed/feed-display.spec.ts b/packages/widgets/src/rssFeed/feed-display.spec.ts
new file mode 100644
index 000000000..3b0b460b9
--- /dev/null
+++ b/packages/widgets/src/rssFeed/feed-display.spec.ts
@@ -0,0 +1,23 @@
+import { describe, expect, test } from "vitest";
+
+import { feedDescriptionToText, getHostname, getSafeExternalUrl } from "./component";
+
+describe("RSS feed display helpers", () => {
+ test("renders feed markup as inert text", () => {
+ expect(feedDescriptionToText('Hello world
')).toBe(
+ "Hello world",
+ );
+ });
+
+ test("handles malformed feed URLs without crashing the widget", () => {
+ expect(getHostname("not a URL")).toBe("not a URL");
+ expect(getHostname("https://example.com/feed.xml")).toBe("example.com");
+ });
+
+ test("only exposes HTTP links from untrusted feed entries", () => {
+ expect(getSafeExternalUrl("https://example.com/story")).toBe("https://example.com/story");
+ expect(getSafeExternalUrl("/story", "https://example.com/feed.xml")).toBe("https://example.com/story");
+ expect(getSafeExternalUrl("javascript:alert(1)")).toBeUndefined();
+ expect(getSafeExternalUrl("not a URL")).toBeUndefined();
+ });
+});
diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts
index eee7028cb..ec41fd69e 100644
--- a/packages/widgets/src/rssFeed/index.ts
+++ b/packages/widgets/src/rssFeed/index.ts
@@ -12,6 +12,7 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
+ queryKey: [["widget", "rssFeed"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx
index f1d6b6e18..e0f90bd52 100644
--- a/packages/widgets/src/smart-home/entity-state/component.tsx
+++ b/packages/widgets/src/smart-home/entity-state/component.tsx
@@ -1,11 +1,13 @@
"use client";
import { useCallback } from "react";
-import { Center, Stack, Text, UnstyledButton } from "@mantine/core";
+import { Badge, Center, Group, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconBinaryTree } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
+import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
@@ -14,7 +16,10 @@ export default function SmartHomeEntityStateWidget({
integrationIds,
isEditMode,
width,
+ height,
+ displayMode,
}: WidgetComponentProps<"smartHome-entityState">) {
+ const t = useI18n();
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
@@ -23,21 +28,25 @@ export default function SmartHomeEntityStateWidget({
entityId: options.entityId,
integrationId,
};
- const { data: entityState } = clientApi.widget.smartHome.entityState.useQuery(input);
+ const { data: entity } = clientApi.widget.smartHome.entityDetails.useQuery(input);
+ const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
const utils = clientApi.useUtils();
- const { mutate } = clientApi.widget.smartHome.switchEntity.useMutation({
- onSettled: () => void utils.widget.smartHome.entityState.invalidate(input),
+ const { mutate, isPending, error } = clientApi.widget.smartHome.switchEntity.useMutation({
+ onSettled: () => void utils.widget.smartHome.entityDetails.invalidate(input),
});
- const attribute = options.entityUnit.length > 0 ? " " + options.entityUnit : "";
+ const apiUnit = entity?.attributes.unit_of_measurement;
+ const unit = options.entityUnit || (typeof apiUnit === "string" ? apiUnit : "");
+ const attribute = unit.length > 0 ? ` ${unit}` : "";
+ const isActionable = options.clickable && canInteract && !isPending;
const handleClick = useCallback(() => {
if (isEditMode) {
return;
}
- if (!options.clickable) {
+ if (!isActionable) {
return;
}
@@ -45,7 +54,7 @@ export default function SmartHomeEntityStateWidget({
entityId: options.entityId,
integrationId,
});
- }, [integrationId, isEditMode, mutate, options.clickable, options.entityId]);
+ }, [integrationId, isActionable, isEditMode, mutate, options.entityId]);
useRegisterSpotlightContextActions(
`smartHome-entityState-${options.entityId}`,
@@ -62,35 +71,70 @@ export default function SmartHomeEntityStateWidget({
},
};
},
- disabled: !options.clickable,
+ disabled: !isActionable,
},
],
- [handleClick, options.clickable, options.displayName, options.entityId],
+ [handleClick, isActionable, options.displayName, options.entityId],
);
- const isTiny = width < 128;
+ const isTiny = width < 128 || height < 96;
+ const advancedAttributes = [
+ { key: "unit" as const, value: entity?.attributes.unit_of_measurement },
+ { key: "deviceClass" as const, value: entity?.attributes.device_class },
+ { key: "icon" as const, value: entity?.attributes.icon },
+ ].filter(({ value }) => typeof value === "string" && value.length > 0);
return (
-
+
- {options.displayName}
+ {displayMode === "advanced" && typeof entity?.attributes.friendly_name === "string"
+ ? entity.attributes.friendly_name
+ : options.displayName}
- {entityState}
+ {entity?.state ?? "—"}
{attribute}
+ {displayMode === "advanced" && entity && (
+ <>
+
+ {advancedAttributes.map(({ key, value }) => (
+
+ {t(`widget.smartHome-entityState.advanced.attribute.${key}`)}: {String(value)}
+
+ ))}
+
+
+ {t("widget.smartHome-entityState.advanced.entityId", { id: entity.entity_id })}
+
+
+ {t("widget.smartHome-entityState.advanced.lastUpdated", {
+ date: new Date(entity.last_updated).toLocaleString(),
+ })}
+
+ >
+ )}
+ {error && (
+
+
+ {t("widget.smartHome-entityState.error.toggleFailed")}
+
+
+ )}
diff --git a/packages/widgets/src/smart-home/entity-state/index.ts b/packages/widgets/src/smart-home/entity-state/index.ts
index a4dd32921..649116bce 100644
--- a/packages/widgets/src/smart-home/entity-state/index.ts
+++ b/packages/widgets/src/smart-home/entity-state/index.ts
@@ -7,6 +7,8 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-entityState", {
icon: IconBinaryTree,
+ queryKey: [["widget", "smartHome"]],
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
entityId: factory.text({
diff --git a/packages/widgets/src/smart-home/execute-automation/component.tsx b/packages/widgets/src/smart-home/execute-automation/component.tsx
index f61ea1cfc..671502fb3 100644
--- a/packages/widgets/src/smart-home/execute-automation/component.tsx
+++ b/packages/widgets/src/smart-home/execute-automation/component.tsx
@@ -1,11 +1,12 @@
"use client";
import React from "react";
-import { Center, LoadingOverlay, Overlay, Stack, Text, ThemeIcon, UnstyledButton } from "@mantine/core";
+import { Center, LoadingOverlay, Overlay, Stack, Text, ThemeIcon, Tooltip, UnstyledButton } from "@mantine/core";
import { useDisclosure, useTimeout } from "@mantine/hooks";
import { IconAutomation, IconCheck } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
+import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
import { useI18n } from "@homarr/translation/client";
@@ -16,27 +17,37 @@ export default function SmartHomeTriggerAutomationWidget({
integrationIds,
isEditMode,
width,
+ height,
+ displayMode,
}: WidgetComponentProps<"smartHome-executeAutomation">) {
+ const integrationId = integrationIds[0];
+ const canInteract = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId);
+ const [lastExecutedAt, setLastExecutedAt] = React.useState(null);
const [isShowSuccess, { open: showSuccess, close: closeSuccess }] = useDisclosure();
const { start } = useTimeout(() => {
closeSuccess();
}, 1000);
- const { mutateAsync, isPending } = clientApi.widget.smartHome.executeAutomation.useMutation({
+ const { mutateAsync, isPending, error } = clientApi.widget.smartHome.executeAutomation.useMutation({
onSuccess: () => {
+ setLastExecutedAt(new Date());
showSuccess();
start();
},
});
const handleClick = React.useCallback(async () => {
- if (isEditMode) {
+ if (isEditMode || isPending || !integrationId || !canInteract) {
return;
}
- await mutateAsync({
- automationId: options.automationId,
- integrationId: integrationIds[0] ?? "",
- });
- }, [integrationIds, isEditMode, mutateAsync, options.automationId]);
+ try {
+ await mutateAsync({
+ automationId: options.automationId,
+ integrationId,
+ });
+ } catch {
+ // The mutation exposes the error below and remains retryable.
+ }
+ }, [canInteract, integrationId, isEditMode, isPending, mutateAsync, options.automationId]);
const t = useI18n();
useRegisterSpotlightContextActions(
@@ -55,17 +66,23 @@ export default function SmartHomeTriggerAutomationWidget({
},
};
},
+ disabled: !integrationId || !canInteract || isPending,
},
],
- [handleClick, options.automationId, options.displayName],
+ [canInteract, handleClick, integrationId, isPending, options.automationId, options.displayName],
);
- const isTiny = width < 128;
+ const isTiny = width < 128 || height < 96;
return (
@@ -79,12 +96,44 @@ export default function SmartHomeTriggerAutomationWidget({
)}
+ {error && displayMode !== "advanced" && (
+
+
+
+
+ {t("widget.smartHome-executeAutomation.error.executeFailed")}
+
+
+
+
+ )}
{options.displayName}
+ {displayMode === "advanced" && (
+ <>
+ {error ? (
+
+
+ {t("widget.smartHome-executeAutomation.error.executeFailed")}
+
+
+ ) : (
+
+ {lastExecutedAt
+ ? t("widget.smartHome-executeAutomation.advanced.lastExecuted", {
+ time: lastExecutedAt.toLocaleTimeString(),
+ })
+ : t("widget.smartHome-executeAutomation.advanced.automationId", {
+ id: options.automationId,
+ })}
+
+ )}
+ >
+ )}
diff --git a/packages/widgets/src/smart-home/execute-automation/index.ts b/packages/widgets/src/smart-home/execute-automation/index.ts
index ea957fab9..e4435f892 100644
--- a/packages/widgets/src/smart-home/execute-automation/index.ts
+++ b/packages/widgets/src/smart-home/execute-automation/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("smartHome-executeAutomation", {
icon: IconBinaryTree,
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from((factory) => ({
displayName: factory.text(),
diff --git a/packages/widgets/src/speedtest-tracker/averages.tsx b/packages/widgets/src/speedtest-tracker/averages.tsx
index 41b116d56..ea4404c4d 100644
--- a/packages/widgets/src/speedtest-tracker/averages.tsx
+++ b/packages/widgets/src/speedtest-tracker/averages.tsx
@@ -10,7 +10,7 @@ import { formatStatsSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
-export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
+export function AveragesSection({ stats, width = 500 }: { stats: SpeedtestTrackerStats; width?: number }) {
const t = useScopedI18n("widget.speedtestTracker");
return (
@@ -18,7 +18,7 @@ export function AveragesSection({ stats }: { stats: SpeedtestTrackerStats }) {
{t("averages")} · {t("tests", { count: stats.total_results.toString() })}
-
+
) {
+export default function SpeedtestTrackerWidget({
+ options,
+ integrationIds,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"speedtestTracker">) {
const t = useScopedI18n("widget.speedtestTracker");
const { data: dashboardData = [] } = clientApi.widget.speedtestTracker.getDashboard.useQuery({ integrationIds });
+ const [advancedRange, setAdvancedRange] = useState("24");
const combined = useMemo(
- () =>
- dashboardData.reduce(
- (acc, item) => ({
- latestResult: item.dashboard.latestResult ?? acc.latestResult,
- stats: mergeStats(acc.stats, item.dashboard.stats),
- recentResults: [...acc.recentResults, ...item.dashboard.recentResults],
- }),
- { latestResult: null, stats: null, recentResults: [] },
- ),
+ () => combineSpeedtestDashboards(dashboardData.map((item) => item.dashboard)),
[dashboardData],
);
- const twelveHoursAgo = Date.now() - 12 * 60 * 60 * 1000;
- const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > twelveHoursAgo);
+ const rangeHours = displayMode === "advanced" ? Number(advancedRange) : 12;
+ const cutoff = rangeHours > 0 ? Date.now() - rangeHours * 60 * 60 * 1000 : 0;
+ const recentFiltered = combined.recentResults.filter((result) => result.created_at.getTime() > cutoff);
const hasStatSection =
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
+ const latest = options.showLatestResult && combined.latestResult && (
+
+ );
+ const averages = options.showStats && combined.stats && (
+
+ );
+
+ if (displayMode === "advanced") {
+ return (
+
+
+ {hasStatSection && (
+ = 720 ? 2 : 1} spacing="md">
+ {latest}
+ {averages}
+
+ )}
+ {hasChart && (
+
+
+
+ )}
+
+ );
+ }
+
return (
{hasStatSection && (
- {options.showLatestResult && combined.latestResult && (
-
-
-
- )}
- {options.showStats && combined.stats && (
-
- )}
+ {latest && {latest}
}
+ {averages && height >= 170 && {averages}
}
)}
{hasChart && (
diff --git a/packages/widgets/src/speedtest-tracker/helpers.spec.ts b/packages/widgets/src/speedtest-tracker/helpers.spec.ts
new file mode 100644
index 000000000..ab2cd8564
--- /dev/null
+++ b/packages/widgets/src/speedtest-tracker/helpers.spec.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from "vitest";
+
+import type { SpeedtestTrackerDashboardData, SpeedtestTrackerStats } from "@homarr/integrations/types";
+
+import { combineSpeedtestDashboards, mergeStats } from "./helpers";
+
+const stats = (average: number, totalResults: number): SpeedtestTrackerStats => ({
+ ping: { avg: average, min: average, max: average },
+ download: { avg: average, avg_bits: average, min: average, max: average },
+ upload: { avg: average, avg_bits: average, min: average, max: average },
+ total_results: totalResults,
+});
+
+describe("speedtest dashboard aggregation", () => {
+ it("weights averages by result count", () => {
+ expect(mergeStats(stats(10, 1), stats(20, 3))?.ping.avg).toBe(17.5);
+ });
+
+ it("selects the newest latest result independent of integration order", () => {
+ const older = { id: 1, ping: 1, download_bits: 1, upload_bits: 1, healthy: true, created_at: new Date(1) };
+ const newer = { ...older, id: 2, created_at: new Date(2) };
+ const dashboards: SpeedtestTrackerDashboardData[] = [
+ { latestResult: newer, stats: null, recentResults: [] },
+ { latestResult: older, stats: null, recentResults: [] },
+ ];
+ expect(combineSpeedtestDashboards(dashboards).latestResult?.id).toBe(2);
+ });
+});
diff --git a/packages/widgets/src/speedtest-tracker/helpers.ts b/packages/widgets/src/speedtest-tracker/helpers.ts
index 075fdb90c..e438a4fa4 100644
--- a/packages/widgets/src/speedtest-tracker/helpers.ts
+++ b/packages/widgets/src/speedtest-tracker/helpers.ts
@@ -11,34 +11,58 @@ export const mergeStats = (
): SpeedtestTrackerDashboardData["stats"] => {
if (!statsB) return statsA;
if (!statsA) return statsB;
+ const totalResults = statsA.total_results + statsB.total_results;
+ const weightedAverage = (left: number, right: number) =>
+ totalResults > 0 ? (left * statsA.total_results + right * statsB.total_results) / totalResults : (left + right) / 2;
return {
ping: {
- avg: (statsA.ping.avg + statsB.ping.avg) / 2,
+ avg: weightedAverage(statsA.ping.avg, statsB.ping.avg),
min: Math.min(statsA.ping.min, statsB.ping.min),
max: Math.max(statsA.ping.max, statsB.ping.max),
},
download: {
- avg: (statsA.download.avg + statsB.download.avg) / 2,
+ avg: weightedAverage(statsA.download.avg, statsB.download.avg),
avg_bits:
statsA.download.avg_bits !== undefined && statsB.download.avg_bits !== undefined
- ? (statsA.download.avg_bits + statsB.download.avg_bits) / 2
+ ? weightedAverage(statsA.download.avg_bits, statsB.download.avg_bits)
: (statsA.download.avg_bits ?? statsB.download.avg_bits),
min: Math.min(statsA.download.min, statsB.download.min),
max: Math.max(statsA.download.max, statsB.download.max),
},
upload: {
- avg: (statsA.upload.avg + statsB.upload.avg) / 2,
+ avg: weightedAverage(statsA.upload.avg, statsB.upload.avg),
avg_bits:
statsA.upload.avg_bits !== undefined && statsB.upload.avg_bits !== undefined
- ? (statsA.upload.avg_bits + statsB.upload.avg_bits) / 2
+ ? weightedAverage(statsA.upload.avg_bits, statsB.upload.avg_bits)
: (statsA.upload.avg_bits ?? statsB.upload.avg_bits),
min: Math.min(statsA.upload.min, statsB.upload.min),
max: Math.max(statsA.upload.max, statsB.upload.max),
},
- total_results: statsA.total_results + statsB.total_results,
+ total_results: totalResults,
};
};
+export const selectLatestResult = (
+ current: SpeedtestTrackerResult | null,
+ candidate: SpeedtestTrackerResult | null,
+): SpeedtestTrackerResult | null => {
+ if (!candidate) return current;
+ if (!current) return candidate;
+ return candidate.created_at.getTime() > current.created_at.getTime() ? candidate : current;
+};
+
+export const combineSpeedtestDashboards = (
+ dashboards: SpeedtestTrackerDashboardData[],
+): SpeedtestTrackerDashboardData =>
+ dashboards.reduce(
+ (combined, dashboard) => ({
+ latestResult: selectLatestResult(combined.latestResult, dashboard.latestResult),
+ stats: mergeStats(combined.stats, dashboard.stats),
+ recentResults: [...combined.recentResults, ...dashboard.recentResults],
+ }),
+ { latestResult: null, stats: null, recentResults: [] },
+ );
+
export const formatBitsPerSec = (bps: number): string => `${formatNumber(bps, 2)}bps`;
export const formatResultSpeed = (result: SpeedtestTrackerResult, dir: "download" | "upload"): string => {
diff --git a/packages/widgets/src/speedtest-tracker/index.ts b/packages/widgets/src/speedtest-tracker/index.ts
index 14cc5d118..943ef7fdb 100644
--- a/packages/widgets/src/speedtest-tracker/index.ts
+++ b/packages/widgets/src/speedtest-tracker/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("speedtestTracker", {
icon: IconSpeedboat,
+ queryKey: [["widget", "speedtestTracker"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/speedtest-tracker/latest-result.tsx b/packages/widgets/src/speedtest-tracker/latest-result.tsx
index fcc6136cf..20b792196 100644
--- a/packages/widgets/src/speedtest-tracker/latest-result.tsx
+++ b/packages/widgets/src/speedtest-tracker/latest-result.tsx
@@ -10,7 +10,7 @@ import { formatResultSpeed } from "./helpers";
import { SectionLabel } from "./section-label";
import { SpeedStatCard } from "./speed-stat-card";
-export function LatestResultSection({ result }: { result: SpeedtestTrackerResult }) {
+export function LatestResultSection({ result, width = 500 }: { result: SpeedtestTrackerResult; width?: number }) {
const t = useScopedI18n("widget.speedtestTracker");
const locale = useCurrentLocale();
const timestamp = Intl.DateTimeFormat(locale, {
@@ -19,7 +19,8 @@ export function LatestResultSection({ result }: { result: SpeedtestTrackerResult
hour: "2-digit",
minute: "2-digit",
}).format(result.created_at);
- const cols = result.healthy !== null ? 4 : 3;
+ const metricCount = result.healthy !== null ? 4 : 3;
+ const cols = width < 230 ? 2 : metricCount;
return (
diff --git a/packages/widgets/src/stocks/component.spec.ts b/packages/widgets/src/stocks/component.spec.ts
new file mode 100644
index 000000000..56ad94954
--- /dev/null
+++ b/packages/widgets/src/stocks/component.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, it } from "vitest";
+
+import { getStockSummary } from "./component";
+
+describe("stock summary", () => {
+ it("returns no summary for an empty series", () => {
+ expect(getStockSummary([], 10)).toBeNull();
+ });
+
+ it("does not divide by a zero previous close", () => {
+ expect(getStockSummary([0, 5], 0)).toMatchObject({
+ currentPrice: 5,
+ change: 5,
+ changePercentage: null,
+ graphValues: [50, 55],
+ });
+ });
+});
diff --git a/packages/widgets/src/stocks/component.tsx b/packages/widgets/src/stocks/component.tsx
index 918851ce8..aacd9c16c 100644
--- a/packages/widgets/src/stocks/component.tsx
+++ b/packages/widgets/src/stocks/component.tsx
@@ -19,10 +19,31 @@ function calculateChange(currentPrice: number, previousClose: number) {
}
function calculateChangePercentage(currentPrice: number, previousClose: number) {
+ if (previousClose === 0) return null;
return 100 * ((currentPrice - previousClose) / previousClose);
}
-export default function StockPriceWidget({ options, width, height }: WidgetComponentProps<"stockPrice">) {
+export interface StockSummary {
+ currentPrice: number;
+ change: number;
+ changePercentage: number | null;
+ graphValues: number[];
+}
+
+export function getStockSummary(priceHistory: number[], previousClose: number): StockSummary | null {
+ const currentPrice = priceHistory.at(-1);
+ if (currentPrice === undefined) return null;
+
+ const minimum = Math.min(...priceHistory);
+ return {
+ currentPrice,
+ change: round(calculateChange(currentPrice, previousClose)),
+ changePercentage: calculateChangePercentage(currentPrice, previousClose),
+ graphValues: priceHistory.map((value) => value - minimum + 50),
+ };
+}
+
+export default function StockPriceWidget({ options, width, height, displayMode }: WidgetComponentProps<"stockPrice">) {
const t = useScopedI18n("widget.stockPrice");
const theme = useMantineTheme();
const { data: result } = clientApi.widget.stockPrice.getPriceHistory.useQuery(options);
@@ -30,13 +51,11 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
if (!result) return ;
const { data } = result;
- const stockValuesChange = round(calculateChange(data.priceHistory.at(-1) ?? 0, data.previousClose));
- const stockValuesChangePercentage = round(
- calculateChangePercentage(data.priceHistory.at(-1) ?? 0, data.previousClose),
- );
-
- const stockValuesMin = Math.min(...data.priceHistory);
- const stockGraphValues = data.priceHistory.map((value) => value - stockValuesMin + 50);
+ const summary = getStockSummary(data.priceHistory, data.previousClose);
+ if (!summary) return ;
+ const stockValuesChange = summary.change;
+ const stockValuesChangePercentage = summary.changePercentage === null ? null : round(summary.changePercentage);
+ const stockGraphValues = summary.graphValues;
const trendColor = stockValuesChange > 0 ? "green.7" : stockValuesChange < 0 ? "red.7" : "gray.6";
return (
@@ -45,7 +64,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
pos="absolute"
bottom={10}
w="100%"
- h={height > 280 ? "75%" : "50%"}
+ h={displayMode === "advanced" || height > 280 ? "75%" : "50%"}
data={stockGraphValues}
curveType="linear"
color={trendColor}
@@ -62,7 +81,7 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
)}
{data.symbol}
- {width > 280 && height > 280 && (
+ {(displayMode === "advanced" || (width > 280 && height > 280)) && (
{data.shortName}
@@ -70,37 +89,23 @@ export default function StockPriceWidget({ options, width, height }: WidgetCompo
280 ? 1 : 2} fw={700}>
- {new Intl.NumberFormat().format(round(data.priceHistory.at(-1) ?? 0))}
+ {new Intl.NumberFormat().format(round(summary.currentPrice))}
- {width > 280 && (
+ {(displayMode === "advanced" || width > 280) && (
- {new Intl.NumberFormat().format(stockValuesChange)} ({stockValuesChange > 0 ? "+" : ""}
- {new Intl.NumberFormat().format(stockValuesChangePercentage)}%)
+ {new Intl.NumberFormat().format(stockValuesChange)}
+ {stockValuesChangePercentage === null
+ ? null
+ : ` (${stockValuesChange > 0 ? "+" : ""}${new Intl.NumberFormat().format(stockValuesChangePercentage)}%)`}
)}
- {width > 280 && (
+ {(displayMode === "advanced" || width > 280) && (
{t(`option.timeRange.option.${options.timeRange}.label`)}
)}
-
-
-
- {stockValuesChange > 0 ? (
-
- ) : (
-
- )}
- {data.symbol}
-
- {width > 280 && height > 280 && (
-
- {data.shortName}
-
- )}
-
);
}
diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts
index ba44f481e..1174a9caa 100644
--- a/packages/widgets/src/stocks/index.ts
+++ b/packages/widgets/src/stocks/index.ts
@@ -13,6 +13,7 @@ const timeIntervalOptions = stockPriceTimeFrames.interval;
export const { definition, componentLoader } = createWidgetDefinition("stockPrice", {
icon: IconBuildingBank,
+ queryKey: [["widget", "stockPrice"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx
index 5f84dd0b4..73a21fdb2 100644
--- a/packages/widgets/src/system-disks/component.tsx
+++ b/packages/widgets/src/system-disks/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
-import { Box, Card, Group, Stack, Tooltip, useMantineColorScheme } from "@mantine/core";
+import { Box, Card, Group, ScrollArea, SimpleGrid, Text, Tooltip, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -10,12 +10,15 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
-import { filterStorageVolumes } from "../filter-storage-volumes";
+import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
-const getDisplayText = (item: { used: string; available: string; percentage: number }, displayMode: DisplayMode) => {
+export const getDisplayText = (
+ item: { used: string; available: string; percentage: number },
+ displayMode: DisplayMode,
+) => {
switch (displayMode) {
case "percentage":
return `${Math.round(item.percentage)}%`;
@@ -35,6 +38,16 @@ const getDisplayText = (item: { used: string; available: string; percentage: num
}
};
+export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage));
+
+const getAbsoluteText = (item: { used: string; available: string }) => {
+ const usedInBytes = Number(item.used);
+ const availableInBytes = Number(item.available);
+ if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`;
+ const { used, available } = formatBytesPair(usedInBytes, availableInBytes);
+ return `${used} / ${available}`;
+};
+
interface SystemDiskCardProps {
deviceName: string;
percentage: number;
@@ -42,6 +55,9 @@ interface SystemDiskCardProps {
temperature: number | null | undefined;
healthy: boolean;
showBackgroundBar: boolean;
+ integrationName?: string;
+ secondaryText?: string;
+ isAdvanced: boolean;
}
const SystemDiskCard = ({
@@ -51,6 +67,9 @@ const SystemDiskCard = ({
temperature,
healthy,
showBackgroundBar,
+ integrationName,
+ secondaryText,
+ isAdvanced,
}: SystemDiskCardProps) => {
const board = useRequiredBoard();
const scheme = useMantineColorScheme();
@@ -80,27 +99,39 @@ const SystemDiskCard = ({
return (
-
- {deviceName}
-
+
+ {deviceName}
+
+ {integrationName && (
+
+ {integrationName}
+
+ )}
{displayText}
{!healthy && {unhealthyLabel} }
+ {isAdvanced && secondaryText && (
+
+ {secondaryText}
+
+ )}
+
+
+ {temperature !== null && temperature !== undefined ?
{temperature}°C
: null}
- {temperature ?
{temperature}°C
: null}
) {
+export default function SystemResources({
+ integrationIds,
+ options,
+ width,
+ displayMode: surfaceMode,
+}: WidgetComponentProps<"systemDisks">) {
const queryInput = { integrationIds };
const { data = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput);
- const lastItem = data.at(-1);
+ if (data.length === 0) return ;
- if (!lastItem) return ;
+ const disks = data.flatMap((entry) => {
+ const fileSystem = filterStorageVolumes(
+ entry.healthInfo.fileSystem,
+ options.visibleStorageVolumes,
+ entry.integrationId,
+ );
+ const smart = filterStorageVolumes(entry.healthInfo.smart, options.visibleStorageVolumes, entry.integrationId);
+ return fileSystem.map((item) => ({
+ integrationId: entry.integrationId,
+ integrationName: entry.integrationName,
+ item,
+ smartItem: smart.find(
+ (candidate) => normalizeStorageDeviceName(candidate.deviceName) === normalizeStorageDeviceName(item.deviceName),
+ ),
+ }));
+ });
- const rawFileSystem = lastItem.healthInfo.fileSystem;
- if (rawFileSystem.length === 0) {
- throw new NoIntegrationDataError();
- }
+ if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError();
+ if (disks.length === 0) return ;
- const fileSystem = filterStorageVolumes(rawFileSystem, options.visibleStorageVolumes, lastItem.integrationId);
- const smart = filterStorageVolumes(lastItem.healthInfo.smart, options.visibleStorageVolumes, lastItem.integrationId);
-
- if (fileSystem.length === 0) return ;
+ const isAdvanced = surfaceMode === "advanced";
+ const columns = isAdvanced ? Math.max(1, Math.min(disks.length, Math.floor(width / 320))) : 1;
return (
-
- {fileSystem.map((item) => {
- const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
-
- return (
+
+
+ {disks.map(({ integrationId, integrationName, item, smartItem }) => (
1 ? integrationName : undefined}
+ secondaryText={getAbsoluteText(item)}
+ isAdvanced={isAdvanced}
/>
- );
- })}
-
+ ))}
+
+
);
}
diff --git a/packages/widgets/src/system-disks/display.spec.ts b/packages/widgets/src/system-disks/display.spec.ts
new file mode 100644
index 000000000..1ed6e3eac
--- /dev/null
+++ b/packages/widgets/src/system-disks/display.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, test } from "vitest";
+
+import { clampPercentage, getDisplayText } from "./component";
+
+describe("system disk display", () => {
+ test("clamps malformed utilization before sizing the background", () => {
+ expect(clampPercentage(-4)).toBe(0);
+ expect(clampPercentage(108)).toBe(100);
+ });
+
+ test("falls back to provider values when sizes are already formatted", () => {
+ expect(getDisplayText({ used: "5 GiB", available: "10 GiB", percentage: 50 }, "absolute")).toBe("5 GiB / 10 GiB");
+ });
+});
diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx
index 1d3854c89..5de42f5b3 100644
--- a/packages/widgets/src/system-resources/component.tsx
+++ b/packages/widgets/src/system-resources/component.tsx
@@ -1,8 +1,7 @@
"use client";
-import { useEffect, useRef, useState } from "react";
-import { Box, Group, Stack } from "@mantine/core";
-import { useElementSize } from "@mantine/hooks";
+import { useEffect, useMemo, useRef, useState } from "react";
+import { Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
@@ -13,115 +12,226 @@ import { SystemResourceGPUChart } from "./chart/gpu-chart";
import { SystemResourceMemoryChart } from "./chart/memory-chart";
import { NetworkTrafficChart } from "./chart/network-traffic";
-const MAX_QUEUE_SIZE = 15;
+const COMPACT_HISTORY_SIZE = 15;
+const ADVANCED_HISTORY_SIZE = 60;
-const toChartItem = (healthInfo: {
+type ChartItem = {
+ cpu: number;
+ memory: number;
+ gpu: number;
+ network: { up: number; down: number } | null;
+};
+
+export const toChartItem = (healthInfo: {
cpuUtilization: number;
memUsedInBytes: number;
gpu: { processorUtilization: number }[];
network: { up: number; down: number } | null;
-}) => ({
+}): ChartItem => ({
cpu: healthInfo.cpuUtilization,
memory: healthInfo.memUsedInBytes,
gpu:
healthInfo.gpu.length > 0
- ? healthInfo.gpu.reduce((acc, g) => acc + g.processorUtilization, 0) / healthInfo.gpu.length
+ ? healthInfo.gpu.reduce((acc, gpu) => acc + gpu.processorUtilization, 0) / healthInfo.gpu.length
: 0,
network: healthInfo.network,
});
-export default function SystemResources({ integrationIds, options }: WidgetComponentProps<"systemResources">) {
- const { ref, width } = useElementSize();
+export const appendBoundedHistory = (history: ChartItem[], item: ChartItem, limit: number): ChartItem[] =>
+ [...history, item].slice(-limit);
+export const boundHistoryByIntegration = (
+ historyByIntegration: Record,
+ limit: number,
+): Record =>
+ Object.fromEntries(
+ Object.entries(historyByIntegration).map(([integrationId, history]) => [integrationId, history.slice(-limit)]),
+ );
+
+export default function SystemResources({
+ integrationIds,
+ options,
+ width,
+ displayMode,
+ widgetStateRef,
+}: WidgetComponentProps<"systemResources">) {
const { data = [], dataUpdatedAt } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
- const memoryCapacityInBytes =
- (data[0]?.healthInfo.memAvailableInBytes ?? 0) + (data[0]?.healthInfo.memUsedInBytes ?? 0);
+ const isAdvanced = displayMode === "advanced";
+ const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]);
+ const persistedState = widgetStateRef?.current?.systemResources as
+ | { integrationKey?: string; historyByIntegration?: Record }
+ | undefined;
+ const [historyByIntegration, setHistoryByIntegration] = useState>(() =>
+ persistedState?.integrationKey === integrationKey && persistedState.historyByIntegration
+ ? boundHistoryByIntegration(persistedState.historyByIntegration, ADVANCED_HISTORY_SIZE)
+ : {},
+ );
+ const previousIntegrationKey = useRef(integrationKey);
- const [items, setItems] = useState<
- { cpu: number; memory: number; gpu: number; network: { up: number; down: number } | null }[]
- >(() => (data[0] ? [toChartItem(data[0].healthInfo)] : []));
-
- const prevIntegrationIds = useRef(integrationIds);
useEffect(() => {
- if (prevIntegrationIds.current === integrationIds) return;
- prevIntegrationIds.current = integrationIds;
- setItems([]);
- }, [integrationIds]);
+ if (previousIntegrationKey.current === integrationKey) return;
+ previousIntegrationKey.current = integrationKey;
+ setHistoryByIntegration({});
+ }, [integrationKey]);
const lastUpdatedAt = useRef(dataUpdatedAt);
useEffect(() => {
if (dataUpdatedAt === lastUpdatedAt.current) return;
lastUpdatedAt.current = dataUpdatedAt;
- const firstItem = data[0];
- if (!firstItem) return;
- setItems((prev) => [...prev, toChartItem(firstItem.healthInfo)].slice(-MAX_QUEUE_SIZE));
+ setHistoryByIntegration((previous) =>
+ Object.fromEntries(
+ data.map((entry) => [
+ entry.integrationId,
+ appendBoundedHistory(
+ previous[entry.integrationId] ?? [],
+ toChartItem(entry.healthInfo),
+ ADVANCED_HISTORY_SIZE,
+ ),
+ ]),
+ ),
+ );
}, [dataUpdatedAt, data]);
- const showNetwork =
- items.length === 0 || (items.every((item) => item.network !== null) && options.visibleCharts.includes("network"));
- const rowHeight = `calc((100% - ${(options.visibleCharts.length - 1) * 8}px) / ${options.visibleCharts.length})`;
+ useEffect(() => {
+ if (!widgetStateRef) return;
+ widgetStateRef.current = {
+ ...widgetStateRef.current,
+ systemResources: {
+ integrationKey,
+ historyByIntegration: boundHistoryByIntegration(historyByIntegration, ADVANCED_HISTORY_SIZE),
+ },
+ };
+ }, [historyByIntegration, integrationKey, widgetStateRef]);
+
+ const panelColumns = isAdvanced && width >= 960 && data.length > 1 ? 2 : 1;
+ const panelWidth = width / panelColumns;
+ const renderCharts = (entry: (typeof data)[number], availableWidth: number) => {
+ const currentItem = toChartItem(entry.healthInfo);
+ const fullHistory = historyByIntegration[entry.integrationId] ?? [currentItem];
+ const history = isAdvanced ? fullHistory : fullHistory.slice(-COMPACT_HISTORY_SIZE);
+
+ return (
+ 1}
+ />
+ );
+ };
+
+ if (!isAdvanced && data.length === 1 && data[0]) {
+ return (
+
+ {renderCharts(data[0], width)}
+
+ );
+ }
return (
-
- {options.visibleCharts.includes("cpu") && (
-
- item.cpu)}
- hasShadow={options.hasShadow}
- labelDisplayMode={options.labelDisplayMode}
- />
-
- )}
- {options.visibleCharts.includes("memory") && (
-
- item.memory)}
- totalCapacityInBytes={memoryCapacityInBytes}
- hasShadow={options.hasShadow}
- labelDisplayMode={options.labelDisplayMode}
- />
-
- )}
- {options.visibleCharts.includes("gpu") && (
-
- item.gpu)}
- hasShadow={options.hasShadow}
- labelDisplayMode={options.labelDisplayMode}
- />
-
- )}
- {showNetwork &&
- (width > 256 ? (
-
- item.network!.down)}
- isUp={false}
- hasShadow={options.hasShadow}
- labelDisplayMode={options.labelDisplayMode}
- />
+
+
+ {data.map((entry) => renderCharts(entry, panelWidth))}
+
+
+ );
+}
- item.network!.up)}
- isUp
- hasShadow={options.hasShadow}
- labelDisplayMode={options.labelDisplayMode}
- />
-
- ) : (
-
- item.network!)}
+interface SystemChartsProps {
+ integrationName: string;
+ items: ChartItem[];
+ memoryCapacityInBytes: number;
+ options: WidgetComponentProps<"systemResources">["options"];
+ width: number;
+ isAdvanced: boolean;
+ showTitle: boolean;
+}
+
+const SystemCharts = ({
+ integrationName,
+ items,
+ memoryCapacityInBytes,
+ options,
+ width,
+ isAdvanced,
+ showTitle,
+}: SystemChartsProps) => {
+ const showNetwork =
+ items.length > 0 && items.every((item) => item.network !== null) && options.visibleCharts.includes("network");
+ const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork);
+ const chartColumns = isAdvanced && width >= 560 ? 2 : 1;
+ const chartHeight = isAdvanced
+ ? 180
+ : `calc((100% - ${Math.max(0, chartCount - 1) * 8}px) / ${Math.max(1, chartCount)})`;
+
+ return (
+
+ {showTitle && (
+
+ {integrationName}
+
+ )}
+
+ {options.visibleCharts.includes("cpu") && (
+
+ item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={options.labelDisplayMode}
/>
- ))}
+ )}
+ {options.visibleCharts.includes("memory") && (
+
+ item.memory)}
+ totalCapacityInBytes={memoryCapacityInBytes}
+ hasShadow={options.hasShadow}
+ labelDisplayMode={options.labelDisplayMode}
+ />
+
+ )}
+ {options.visibleCharts.includes("gpu") && (
+
+ item.gpu)}
+ hasShadow={options.hasShadow}
+ labelDisplayMode={options.labelDisplayMode}
+ />
+
+ )}
+ {showNetwork &&
+ (width >= 300 ? (
+
+ item.network?.down ?? 0)}
+ isUp={false}
+ hasShadow={options.hasShadow}
+ labelDisplayMode={options.labelDisplayMode}
+ />
+ item.network?.up ?? 0)}
+ isUp
+ hasShadow={options.hasShadow}
+ labelDisplayMode={options.labelDisplayMode}
+ />
+
+ ) : (
+
+ item.network ?? { up: 0, down: 0 })}
+ hasShadow={options.hasShadow}
+ labelDisplayMode={options.labelDisplayMode}
+ />
+
+ ))}
+
);
-}
+};
diff --git a/packages/widgets/src/system-resources/history.spec.ts b/packages/widgets/src/system-resources/history.spec.ts
new file mode 100644
index 000000000..539bd2296
--- /dev/null
+++ b/packages/widgets/src/system-resources/history.spec.ts
@@ -0,0 +1,29 @@
+import { describe, expect, test } from "vitest";
+
+import { appendBoundedHistory, boundHistoryByIntegration, toChartItem } from "./component";
+
+describe("system resource history", () => {
+ test("averages all GPU values", () => {
+ expect(
+ toChartItem({
+ cpuUtilization: 10,
+ memUsedInBytes: 20,
+ gpu: [{ processorUtilization: 20 }, { processorUtilization: 60 }],
+ network: null,
+ }).gpu,
+ ).toBe(40);
+ });
+
+ test("keeps a bounded rolling history", () => {
+ const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
+ expect(appendBoundedHistory([item, item], item, 2)).toHaveLength(2);
+ });
+
+ test("bounds restored history for every integration", () => {
+ const item = toChartItem({ cpuUtilization: 1, memUsedInBytes: 2, gpu: [], network: null });
+ expect(boundHistoryByIntegration({ first: [item, item, item], second: [item, item] }, 2)).toEqual({
+ first: [item, item],
+ second: [item, item],
+ });
+ });
+});
diff --git a/packages/widgets/src/timetable/component.tsx b/packages/widgets/src/timetable/component.tsx
index 47751a751..957b044d6 100644
--- a/packages/widgets/src/timetable/component.tsx
+++ b/packages/widgets/src/timetable/component.tsx
@@ -1,4 +1,4 @@
-import { Badge, Center, Group, Stack, Text } from "@mantine/core";
+import { Badge, Center, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import dayjs from "dayjs";
import { clientApi } from "@homarr/api/client";
@@ -7,66 +7,109 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { DynamicSelectOption } from "../_inputs/widget-dynamic-select-input";
import type { WidgetComponentProps } from "../definition";
-export default function TimetableWidget({ options }: WidgetComponentProps<"timetable">) {
+export default function TimetableWidget({
+ options,
+ displayMode = "compact",
+ width,
+ itemId,
+}: WidgetComponentProps<"timetable">) {
const t = useScopedI18n("widget.timetable");
if (!options.station) {
return {t("noStation")} ;
}
- return ;
+ return (
+
+ );
}
interface TimetableWidgetInnerProps {
station: DynamicSelectOption;
baseUrl: string;
+ itemId?: string;
+ displayMode: "compact" | "advanced";
+ width: number;
}
-const TimetableWidgetInner = ({ station, baseUrl }: TimetableWidgetInnerProps) => {
+const TimetableWidgetInner = ({ station, baseUrl, itemId, displayMode, width }: TimetableWidgetInnerProps) => {
const { data: timetable } = clientApi.widget.timetable.getTimetable.useQuery({
baseUrl,
+ itemId,
stationId: station.value,
- limit: 10,
+ limit: displayMode === "advanced" ? 50 : 10,
});
const t = useScopedI18n("widget.timetable");
+ const entries = timetable?.entries ?? [];
+
return (
-
+
{t("title", { station: station.label })}
- {(timetable?.entries ?? []).map((entry) => (
-
-
- {entry.line && (
-
- {entry.line.name}
-
- )}
-
- {dayjs(entry.timestamp).format("HH:mm")}{" "}
- {entry.delay >= 1 && (
-
- +{entry.delay}'
-
- )}
-
-
- {entry.location}
-
-
- {entry.platform && (
-
- {entry.platform.name}
-
- )}
-
- ))}
+
+ = 760 ? 2 : 1} spacing="xs">
+ {entries.map((entry) => (
+
+ ))}
+
+
+ {displayMode === "advanced" && timetable && (
+
+ {dayjs(timetable.timestamp).format("HH:mm:ss")}
+
+ )}
);
};
+
+interface TimetableEntryView {
+ timestamp: Date;
+ delay: number;
+ line: { name: string; color: string | null } | null;
+ location: string;
+ platform: { name: string; hasChanged: boolean } | null;
+}
+
+function DepartureRow({ entry }: { entry: TimetableEntryView }) {
+ return (
+
+
+ {entry.line && (
+
+ {entry.line.name}
+
+ )}
+
+ {dayjs(entry.timestamp).format("HH:mm")}{" "}
+ {entry.delay >= 1 && (
+
+ +{entry.delay}'
+
+ )}
+
+
+ {entry.location}
+
+
+
+ {entry.platform && (
+
+ {entry.platform.name}
+
+ )}
+
+ );
+}
diff --git a/packages/widgets/src/timetable/index.ts b/packages/widgets/src/timetable/index.ts
index cee44323c..634ad20c4 100644
--- a/packages/widgets/src/timetable/index.ts
+++ b/packages/widgets/src/timetable/index.ts
@@ -15,9 +15,11 @@ export const { componentLoader, definition } = createWidgetDefinition("timetable
validate: z.string().url(),
}),
station: factory.dynamicSelect({
- useOptions(query, _integrationIds, options) {
+ useOptions(query, _integrationIds, options, itemId, boardId) {
const { data: stations, isPending } = clientApi.widget.timetable.searchStations.useQuery({
baseUrl: typeof options.baseUrl === "string" ? options.baseUrl : "https://search.ch",
+ itemId,
+ boardId,
query,
});
diff --git a/packages/widgets/src/tracearr/bitrate.spec.ts b/packages/widgets/src/tracearr/bitrate.spec.ts
new file mode 100644
index 000000000..cbac63553
--- /dev/null
+++ b/packages/widgets/src/tracearr/bitrate.spec.ts
@@ -0,0 +1,18 @@
+import { describe, expect, test } from "vitest";
+
+import { formatTotalTracearrBitrate, formatTracearrBitrate } from "./bitrate";
+
+describe("Tracearr bitrate formatting", () => {
+ test("sums raw Kbps before choosing the display unit", () => {
+ expect(formatTotalTracearrBitrate([{ bitrate: 800 }, { bitrate: 1200 }])).toBe("2 Mbps");
+ });
+
+ test("formats Gbps after aggregation", () => {
+ expect(formatTotalTracearrBitrate([{ bitrate: 1_500_000 }, { bitrate: 500_000 }])).toBe("2 Gbps");
+ });
+
+ test("ignores missing and invalid bitrate values", () => {
+ expect(formatTotalTracearrBitrate([{ bitrate: null }, { bitrate: Number.NaN }, { bitrate: 500 }])).toBe("500 kbps");
+ expect(formatTracearrBitrate(0)).toBe("—");
+ });
+});
diff --git a/packages/widgets/src/tracearr/bitrate.ts b/packages/widgets/src/tracearr/bitrate.ts
new file mode 100644
index 000000000..203dcdbdc
--- /dev/null
+++ b/packages/widgets/src/tracearr/bitrate.ts
@@ -0,0 +1,20 @@
+import type { TracearrStream } from "@homarr/integrations/types";
+
+export function formatTracearrBitrate(kbps: number): string {
+ if (!Number.isFinite(kbps) || kbps <= 0) return "—";
+ if (kbps >= 1_000_000) return `${formatScaledBitrate(kbps / 1_000_000)} Gbps`;
+ if (kbps >= 1000) return `${formatScaledBitrate(kbps / 1000)} Mbps`;
+ return `${kbps} kbps`;
+}
+
+export function formatTotalTracearrBitrate(streams: readonly Pick[]): string {
+ const totalKbps = streams.reduce((sum, { bitrate }) => {
+ if (bitrate === null || !Number.isFinite(bitrate) || bitrate <= 0) return sum;
+ return sum + bitrate;
+ }, 0);
+ return formatTracearrBitrate(totalKbps);
+}
+
+function formatScaledBitrate(value: number): string {
+ return value % 1 === 0 ? value.toFixed(0) : value.toFixed(1);
+}
diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx
index f3b70192a..f026b5710 100644
--- a/packages/widgets/src/tracearr/component.tsx
+++ b/packages/widgets/src/tracearr/component.tsx
@@ -1,6 +1,6 @@
"use client";
-import { ScrollArea, Stack, Text } from "@mantine/core";
+import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { TracearrDashboardData } from "@homarr/integrations/types";
@@ -8,31 +8,48 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
+import { formatTotalTracearrBitrate } from "./bitrate";
import { RecentActivityList } from "./recent-activity-section";
import { StatsBar } from "./stats-section";
import { StreamsList } from "./streams-section";
import { ViolationsList } from "./violations-section";
-export default function TracearrWidget({ options, integrationIds, width }: WidgetComponentProps<"tracearr">) {
+export default function TracearrWidget({
+ options,
+ integrationIds,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"tracearr">) {
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
- return ;
+ return (
+
+ );
}
interface TracearrContentProps {
integrationIds: string[];
options: WidgetComponentProps<"tracearr">["options"];
width: number;
+ height: number;
+ displayMode: "compact" | "advanced";
}
-function TracearrContent({ integrationIds, options, width }: TracearrContentProps) {
+function TracearrContent({ integrationIds, options, width, height, displayMode }: TracearrContentProps) {
const t = useScopedI18n("widget.tracearr");
const { data: dashboardData = [] } = clientApi.widget.tracearr.getDashboard.useQuery({ integrationIds });
// Merge data from all integrations
- const combined = dashboardData.reduce(
+ const combinedData = dashboardData.reduce(
(acc, item) => {
const { stats, streams, violations, recentActivity } = item.dashboard;
const vData = violations ?? { data: [], meta: { total: 0, page: 1, pageSize: 5 } };
@@ -53,7 +70,7 @@ function TracearrContent({ integrationIds, options, width }: TracearrContentProp
transcodes: acc.streams.summary.transcodes + streams.summary.transcodes,
directStreams: acc.streams.summary.directStreams + streams.summary.directStreams,
directPlays: acc.streams.summary.directPlays + streams.summary.directPlays,
- totalBitrate: streams.summary.totalBitrate,
+ totalBitrate: "—",
byServer: [...acc.streams.summary.byServer, ...streams.summary.byServer],
},
},
@@ -85,17 +102,51 @@ function TracearrContent({ integrationIds, options, width }: TracearrContentProp
recentActivity: null,
},
);
+ const combined: TracearrDashboardData = {
+ ...combinedData,
+ streams: {
+ ...combinedData.streams,
+ summary: {
+ ...combinedData.streams.summary,
+ totalBitrate: formatTotalTracearrBitrate(combinedData.streams.data),
+ },
+ },
+ };
const noSectionsEnabled =
!options.showStats && !options.showStreams && !options.showRecentActivity && !options.showViolations;
+ const compactLimit = height < 220 ? 2 : height < 360 ? 4 : 8;
+ const streams = displayMode === "advanced" ? combined.streams.data : combined.streams.data.slice(0, compactLimit);
+ const violations = combined.violations?.data ?? [];
+ const recentActivity = combined.recentActivity?.data ?? [];
+
+ if (displayMode === "advanced") {
+ return (
+
+ = 800 ? 2 : 1} spacing="md" p="md">
+
+ {options.showStats && (
+
+ )}
+ {options.showStreams && }
+
+
+ {options.showViolations && }
+ {options.showRecentActivity && }
+
+
+
+ );
+ }
+
return (
{options.showStats && }
- {options.showStreams && }
- {options.showViolations && }
- {options.showRecentActivity && }
+ {options.showStreams && }
+ {options.showViolations && }
+ {options.showRecentActivity && }
{noSectionsEnabled && (
{t("noSectionsEnabled")}
diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts
index 9c34eb548..8567197bb 100644
--- a/packages/widgets/src/tracearr/index.ts
+++ b/packages/widgets/src/tracearr/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
+ queryKey: [["widget", "tracearr"]],
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx
index 69ec3e811..e73de954c 100644
--- a/packages/widgets/src/traefik/component.tsx
+++ b/packages/widgets/src/traefik/component.tsx
@@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
-import { Badge, Group, ScrollArea, Text } from "@mantine/core";
+import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import {
IconAlertTriangle,
IconCircleCheck,
@@ -36,6 +36,8 @@ const emptyProtocol: TraefikProtocolSummary = {
const emptyDashboard: TraefikDashboardData = {
version: null,
entryPoints: [],
+ resources: [],
+ failedEndpoints: [],
http: emptyProtocol,
tcp: emptyProtocol,
udp: {
@@ -47,11 +49,21 @@ const emptyDashboard: TraefikDashboardData = {
type ProtocolKey = "http" | "tcp" | "udp";
type ResourceKey = "routers" | "services" | "middlewares";
-export default function TraefikWidget({ integrationIds, options, width }: WidgetComponentProps<"traefik">) {
- if (integrationIds.length === 0) {
+export default function TraefikWidget(props: WidgetComponentProps<"traefik">) {
+ if (props.integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
+ return ;
+}
+
+function TraefikWidgetContent({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"traefik">) {
const t = useScopedI18n("widget.traefik");
const { data } = clientApi.widget.traefik.getDashboard.useQuery({ integrationIds });
@@ -61,6 +73,8 @@ export default function TraefikWidget({ integrationIds, options, width }: Widget
(acc, item) => ({
version: acc.version ?? item.dashboard.version,
entryPoints: [...acc.entryPoints, ...item.dashboard.entryPoints],
+ resources: [...acc.resources, ...item.dashboard.resources],
+ failedEndpoints: [...acc.failedEndpoints, ...item.dashboard.failedEndpoints],
http: combineProtocol(acc.http, item.dashboard.http),
tcp: combineProtocol(acc.tcp, item.dashboard.tcp),
udp: {
@@ -72,59 +86,114 @@ export default function TraefikWidget({ integrationIds, options, width }: Widget
);
const totalRouters = combined.http.routers.total + combined.tcp.routers.total + combined.udp.routers.total;
- const totalErrors = getProtocolErrors(combined.http) + getProtocolErrors(combined.tcp) + getUdpErrors(combined.udp);
+ const totalErrors =
+ getProtocolErrors(combined.http) +
+ getProtocolErrors(combined.tcp) +
+ getUdpErrors(combined.udp) +
+ combined.failedEndpoints.length;
const totalWarnings =
getProtocolWarnings(combined.http) + getProtocolWarnings(combined.tcp) + getUdpWarnings(combined.udp);
const protocolKeys: ProtocolKey[] = ["http"];
- if (options.showTcp) protocolKeys.push("tcp");
- if (options.showUdp) protocolKeys.push("udp");
+ if (options.showTcp && (displayMode === "advanced" || height >= 220)) protocolKeys.push("tcp");
+ if (options.showUdp && (displayMode === "advanced" || height >= 300)) protocolKeys.push("udp");
- return (
-
-
-
-
- {data.length === 1 ? data[0]?.integrationName : t("instances")}
- {combined.version ? `v${combined.version}` : t("versionUnknown")}
-
-
-
-
-
-
}
- label={t("summary.routers")}
- value={totalRouters}
- />
-
}
- label={t("summary.entryPoints")}
- value={dedupe(combined.entryPoints).length}
- />
+ const summary = (
+
+
+
+ {data.length === 1 ? data[0]?.integrationName : t("instances")}
+ {combined.version ? `v${combined.version}` : t("versionUnknown")}
+
+
-
- {protocolKeys.map((protocol) => (
-
- ))}
-
-
- {options.showEntryPoints && combined.entryPoints.length > 0 && (
-
- {dedupe(combined.entryPoints)
- .slice(0, getEntryPointLimit(width))
- .map((entryPoint) => (
-
- {entryPoint}
-
- ))}
-
- )}
+
+ }
+ label={t("summary.routers")}
+ value={totalRouters}
+ />
+ }
+ label={t("summary.entryPoints")}
+ value={dedupe(combined.entryPoints).length}
+ />
-
+
+
+ {protocolKeys.map((protocol) => (
+
+ ))}
+
+
+ {options.showEntryPoints && combined.entryPoints.length > 0 && (
+
+ {dedupe(combined.entryPoints)
+ .slice(0, getEntryPointLimit(width))
+ .map((entryPoint) => (
+
+ {entryPoint}
+
+ ))}
+
+ )}
+
+ );
+
+ if (displayMode === "compact") return
{summary} ;
+
+ const resources = [...combined.resources].toSorted(
+ (left, right) =>
+ resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
+ );
+ return (
+
= 900 ? 2 : 1} spacing="md" h="100%" p="md">
+ {summary}
+
+
+ {combined.failedEndpoints.map((endpoint) => (
+
+ {t("failedEndpoint", { endpoint })}
+
+ ))}
+ {resources.map((resource, index) => (
+
+
+
+ {resource.name}
+
+
+ {resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
+
+
+
+ {t(`resourceStatus.${resource.status}`)}
+
+
+ ))}
+
+
+
);
}
+const resourceStatusOrder = { error: 0, warning: 1, disabled: 2, unknown: 3, enabled: 4 } as const;
+
function ProtocolCard({ protocol, data }: { protocol: ProtocolKey; data: TraefikDashboardData[ProtocolKey] }) {
const t = useScopedI18n("widget.traefik");
const rows: { key: ResourceKey; icon: typeof IconRoute; summary: TraefikResourceSummary }[] = [
diff --git a/packages/widgets/src/traefik/index.ts b/packages/widgets/src/traefik/index.ts
index 854075121..a45686578 100644
--- a/packages/widgets/src/traefik/index.ts
+++ b/packages/widgets/src/traefik/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("traefik", {
icon: IconRoute,
+ queryKey: [["widget", "traefik"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showTcp: factory.switch({
diff --git a/packages/widgets/src/umami/component.tsx b/packages/widgets/src/umami/component.tsx
index 2aec3b46e..73ccaf679 100644
--- a/packages/widgets/src/umami/component.tsx
+++ b/packages/widgets/src/umami/component.tsx
@@ -7,7 +7,12 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { UmamiContent } from "./umami-content";
-export default function UmamiWidget({ options, integrationIds }: WidgetComponentProps<"umami">) {
+export default function UmamiWidget({
+ options,
+ integrationIds,
+ width,
+ displayMode = "compact",
+}: WidgetComponentProps<"umami">) {
if (!options.websiteId || options.websiteId.trim() === "") {
return
;
}
@@ -23,6 +28,8 @@ export default function UmamiWidget({ options, integrationIds }: WidgetComponent
chartType={options.chartType}
viewMode={options.viewMode}
topCount={options.topCount}
+ width={width}
+ displayMode={displayMode}
/>
);
}
diff --git a/packages/widgets/src/umami/index.ts b/packages/widgets/src/umami/index.ts
index badbef369..90861eab1 100644
--- a/packages/widgets/src/umami/index.ts
+++ b/packages/widgets/src/umami/index.ts
@@ -8,8 +8,11 @@ export const timeFrameValues = ["today", "24h", "7d", "30d", "month", "lastMonth
export const { definition, componentLoader } = createWidgetDefinition("umami", {
icon: IconChartBar,
+ queryKey: [["widget", "umami"]],
refetchInterval: null,
supportedIntegrations: ["umami"],
+ integrationsRequired: true,
+ maxIntegrations: 1,
createOptions() {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx
index 2a187c3d5..17422a3a1 100644
--- a/packages/widgets/src/umami/umami-content.tsx
+++ b/packages/widgets/src/umami/umami-content.tsx
@@ -1,7 +1,7 @@
"use client";
import { BarChart, LineChart } from "@mantine/charts";
-import { Box, Group, Stack, Text, useMantineColorScheme } from "@mantine/core";
+import { Box, Group, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
@@ -23,6 +23,8 @@ interface UmamiContentProps {
chartType: string;
viewMode: string;
topCount: number;
+ width: number;
+ displayMode: "compact" | "advanced";
}
export function UmamiContent({
@@ -35,6 +37,8 @@ export function UmamiContent({
chartType,
viewMode,
topCount,
+ width,
+ displayMode,
}: UmamiContentProps) {
const t = useScopedI18n("widget.umami");
const { colorScheme } = useMantineColorScheme();
@@ -88,6 +92,63 @@ export function UmamiContent({
const isOverlay = chartStyle === "overlay" && hasEventSeries;
const overlayBarSize = 16;
+ const selectedView =
+ viewMode === "events" ? (
+
+ ) : viewMode === "topPages" ? (
+
+ ) : viewMode === "topReferrers" ? (
+
+ ) : chartType === "sparkline" ? (
+
+ ) : (
+
+ );
+
return (
@@ -140,74 +201,38 @@ export function UmamiContent({
{visitorStats.totalVisits.toLocaleString()} {t("visits")}
-
- {visitorStats.bounceRate}% {t("bounceRate")}
-
-
- {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
-
+ {(displayMode === "advanced" || width >= 260) && (
+ <>
+
+ {visitorStats.bounceRate}% {t("bounceRate")}
+
+
+ {formatDuration(visitorStats.avgDuration * 1000)} {t("avgDuration")}
+
+ >
+ )}
- {viewMode === "events" ? (
-
- ) : viewMode === "topPages" ? (
-
- ) : viewMode === "topReferrers" ? (
-
- ) : chartType === "sparkline" ? (
-
+ {displayMode === "advanced" ? (
+ = 900 ? 2 : 1} spacing="md" h="100%">
+ {selectedView}
+
+
+
+
+
) : (
-
+ selectedView
)}
diff --git a/packages/widgets/src/ups/component.tsx b/packages/widgets/src/ups/component.tsx
index 2b3d93bd9..a7d140ce7 100644
--- a/packages/widgets/src/ups/component.tsx
+++ b/packages/widgets/src/ups/component.tsx
@@ -1,6 +1,18 @@
"use client";
-import { Badge, Box, Card, Group, Progress, RingProgress, ScrollArea, Stack, Text } from "@mantine/core";
+import {
+ Badge,
+ Box,
+ Card,
+ Group,
+ Progress,
+ RingProgress,
+ ScrollArea,
+ SimpleGrid,
+ Stack,
+ Text,
+ VisuallyHidden,
+} from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { formatDuration } from "@homarr/common";
@@ -22,29 +34,47 @@ const statusColors: Record
= {
type UpsLayout = "mini" | "compact" | "full";
-export default function UpsWidget({ options, integrationIds, width }: WidgetComponentProps<"ups">) {
+export default function UpsWidget({
+ options,
+ integrationIds,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"ups">) {
if (integrationIds.length === 0) {
throw new NoIntegrationSelectedError();
}
- return ;
+ return (
+
+ );
}
interface UpsContentProps {
integrationIds: string[];
options: WidgetComponentProps<"ups">["options"];
width: number;
+ height: number;
+ displayMode: "compact" | "advanced";
}
-function UpsContent({ integrationIds, options, width }: UpsContentProps) {
+function UpsContent({ integrationIds, options, width, height, displayMode }: UpsContentProps) {
const t = useScopedI18n("widget.ups");
const { data } = clientApi.widget.ups.getSummaries.useQuery({ integrationIds });
if (!data) return ;
- const devices = data.flatMap((instance) =>
- instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
- );
+ const devices = data
+ .flatMap((instance) =>
+ instance.summaries.map((summary) => ({ key: `${instance.integrationId}:${summary.id}`, summary })),
+ )
+ .toSorted((left, right) => statusPriority[left.summary.status] - statusPriority[right.summary.status]);
if (devices.length === 0) {
return ;
@@ -55,27 +85,52 @@ function UpsContent({ integrationIds, options, width }: UpsContentProps) {
// - full: ring beside a column of stats (runtime, load, voltage)
// - compact (~2 columns): ring beside the name and a status badge
// - mini (~1 column): a text badge no longer fits, so status becomes a colour dot next to the name
- const layout: UpsLayout = width < 150 ? "mini" : width < 256 ? "compact" : "full";
+ const layout: UpsLayout =
+ displayMode === "advanced" ? "full" : width < 150 || height < 110 ? "mini" : width < 256 ? "compact" : "full";
+
+ const cards = devices.map(({ key, summary }) => (
+
+ ));
return (
-
- {devices.map(({ key, summary }) => (
-
- ))}
-
+ {displayMode === "advanced" ? (
+ = 760 ? 2 : 1} spacing="md" p="md">
+ {cards}
+
+ ) : (
+
+ {cards}
+
+ )}
);
}
+const statusPriority: Record = {
+ lowBattery: 0,
+ onBattery: 1,
+ charging: 2,
+ unknown: 3,
+ online: 4,
+};
+
interface UpsDeviceCardProps {
summary: UpsSummary;
options: WidgetComponentProps<"ups">["options"];
layout: UpsLayout;
+ advanced: boolean;
t: ScopedTranslationFunction<"widget.ups">;
}
-function UpsDeviceCard({ summary, options, layout, t }: UpsDeviceCardProps) {
+function UpsDeviceCard({ summary, options, layout, advanced, t }: UpsDeviceCardProps) {
const showRing = options.showBattery && summary.batteryCharge !== null;
const statusBadge = (
+ {t(`status.${summary.status}`)}
{summary.name}
@@ -205,6 +262,20 @@ function UpsDeviceCard({ summary, options, layout, t }: UpsDeviceCardProps) {
)}
+ {advanced && (
+
+ {summary.batteryVoltage !== null && (
+
+ )}
+ {summary.power !== null && }
+ {summary.temperature !== null && (
+
+ )}
+ {summary.manufacturer && }
+ {summary.model && }
+ {summary.serial && }
+
+ )}
);
}
diff --git a/packages/widgets/src/ups/index.ts b/packages/widgets/src/ups/index.ts
index 681daf417..c9d535dd0 100644
--- a/packages/widgets/src/ups/index.ts
+++ b/packages/widgets/src/ups/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("ups", {
icon: IconBatteryCharging,
+ queryKey: [["widget", "ups"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showBattery: factory.switch({
diff --git a/packages/widgets/src/uptime-kuma/aggregate.spec.ts b/packages/widgets/src/uptime-kuma/aggregate.spec.ts
new file mode 100644
index 000000000..c4aaf9f18
--- /dev/null
+++ b/packages/widgets/src/uptime-kuma/aggregate.spec.ts
@@ -0,0 +1,31 @@
+import { describe, expect, it } from "vitest";
+
+import { aggregateUptimeKumaDashboards } from "./aggregate";
+
+describe("aggregateUptimeKumaDashboards", () => {
+ it("weights uptime by monitors instead of integrations", () => {
+ const combined = aggregateUptimeKumaDashboards([
+ {
+ totalMonitors: 1,
+ upCount: 1,
+ downCount: 0,
+ pausedCount: 0,
+ averageUptimePercent: 100,
+ monitors: [{ id: 1, name: "one", status: "up", uptimePercent24h: 100 }],
+ },
+ {
+ totalMonitors: 3,
+ upCount: 0,
+ downCount: 3,
+ pausedCount: 0,
+ averageUptimePercent: 0,
+ monitors: [
+ { id: 2, name: "two", status: "down", uptimePercent24h: 0 },
+ { id: 3, name: "three", status: "down", uptimePercent24h: 0 },
+ { id: 4, name: "four", status: "down", uptimePercent24h: 0 },
+ ],
+ },
+ ]);
+ expect(combined.averageUptimePercent).toBe(25);
+ });
+});
diff --git a/packages/widgets/src/uptime-kuma/aggregate.ts b/packages/widgets/src/uptime-kuma/aggregate.ts
new file mode 100644
index 000000000..f7e6650be
--- /dev/null
+++ b/packages/widgets/src/uptime-kuma/aggregate.ts
@@ -0,0 +1,30 @@
+import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
+
+const emptyDashboard: UptimeKumaDashboardData = {
+ totalMonitors: 0,
+ upCount: 0,
+ downCount: 0,
+ pausedCount: 0,
+ averageUptimePercent: 0,
+ monitors: [],
+};
+
+export function aggregateUptimeKumaDashboards(dashboards: UptimeKumaDashboardData[]): UptimeKumaDashboardData {
+ const combined = dashboards.reduce(
+ (acc, dashboard) => ({
+ totalMonitors: acc.totalMonitors + dashboard.totalMonitors,
+ upCount: acc.upCount + dashboard.upCount,
+ downCount: acc.downCount + dashboard.downCount,
+ pausedCount: acc.pausedCount + dashboard.pausedCount,
+ averageUptimePercent: 0,
+ monitors: [...acc.monitors, ...dashboard.monitors],
+ }),
+ emptyDashboard,
+ );
+ const uptimeValues = combined.monitors
+ .map((monitor) => monitor.uptimePercent24h)
+ .filter((value): value is number => value !== null);
+ combined.averageUptimePercent =
+ uptimeValues.reduce((total, value) => total + value, 0) / Math.max(uptimeValues.length, 1);
+ return combined;
+}
diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx
index 5719f2f13..b77d66afb 100644
--- a/packages/widgets/src/uptime-kuma/component.tsx
+++ b/packages/widgets/src/uptime-kuma/component.tsx
@@ -1,27 +1,18 @@
"use client";
import type { CSSProperties } from "react";
-import { RingProgress, Text } from "@mantine/core";
+import { Badge, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { formatNumber } from "@homarr/common";
-import type { UptimeKumaDashboardData } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
-
-const emptyDashboard: UptimeKumaDashboardData = {
- totalMonitors: 0,
- upCount: 0,
- downCount: 0,
- pausedCount: 0,
- averageUptimePercent: 0,
- monitors: [],
-};
+import { aggregateUptimeKumaDashboards } from "./aggregate";
const statVisibilityByOption = {
showTotalMonitors: "totalMonitors",
@@ -85,41 +76,51 @@ const heroVariantByRing = {
false: classes.heroTextOnly,
} as const;
-export default function UptimeKumaWidget({ integrationIds, options, width }: WidgetComponentProps<"uptimeKuma">) {
+export default function UptimeKumaWidget({
+ integrationIds,
+ options,
+ width,
+ height,
+ displayMode = "compact",
+}: WidgetComponentProps<"uptimeKuma">) {
if (integrationIds.length === 0) {
throw new NoIntegrationDataError();
}
- return ;
+ return (
+
+ );
}
interface UptimeKumaContentProps {
integrationIds: string[];
options: WidgetComponentProps<"uptimeKuma">["options"];
width: number;
+ height: number;
+ displayMode: "compact" | "advanced";
}
-function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContentProps) {
+function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) {
const t = useScopedI18n("widget.uptimeKuma");
const { data: dashboardData } = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds });
if (!dashboardData) return ;
- const combined = dashboardData.reduce(
- (acc, item) => ({
- totalMonitors: acc.totalMonitors + item.dashboard.totalMonitors,
- upCount: acc.upCount + item.dashboard.upCount,
- downCount: acc.downCount + item.dashboard.downCount,
- pausedCount: acc.pausedCount + item.dashboard.pausedCount,
- averageUptimePercent: acc.averageUptimePercent + item.dashboard.averageUptimePercent,
- monitors: [...acc.monitors, ...item.dashboard.monitors],
- }),
- emptyDashboard,
+ const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard));
+ const monitors = dashboardData.flatMap((result) =>
+ result.dashboard.monitors.map((monitor) => ({
+ ...monitor,
+ key: `${result.integrationId}:${monitor.id}`,
+ integrationName: result.integrationName,
+ })),
);
-
- const instanceCount = dashboardData.length;
- const averageUptime = combined.averageUptimePercent / Math.max(instanceCount, 1);
- const uptimeValue = clampPercent(averageUptime);
+ const uptimeValue = clampPercent(combined.averageUptimePercent);
const uptimeColor = getUptimeColor(uptimeValue);
const ringSize = getRingSize(width);
const iconSize = getIconSize(width);
@@ -136,7 +137,7 @@ function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContent
heroLayoutBySecondaryStats[String(visibleStatKeys.length > 0) as keyof typeof heroLayoutBySecondaryStats];
const heroRingClass = heroVariantByRing[String(options.showUptimeRing) as keyof typeof heroVariantByRing];
- return (
+ const summaryContent = (
{showHero && (
@@ -189,10 +190,61 @@ function UptimeKumaContent({ integrationIds, options, width }: UptimeKumaContent
})}
)}
+ {displayMode === "compact" && height >= 240 && combined.downCount > 0 && (
+
+ {monitors
+ .filter((monitor) => monitor.status === "down")
+ .slice(0, 3)
+ .map((monitor) => (
+
+
+ {monitor.name}
+
+
+ {t("status.down")} · {formatNumber(monitor.uptimePercent24h ?? 0, 1)}%
+
+
+ ))}
+
+ )}
);
+
+ if (displayMode === "compact") return summaryContent;
+
+ return (
+ = 760 ? 2 : 1} spacing="lg" h="100%" p="md">
+ {summaryContent}
+
+
+ {monitors
+ .toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
+ .map((monitor) => (
+
+
+
+ {monitor.name}
+
+ {dashboardData.length > 1 && (
+
+ {monitor.integrationName}
+
+ )}
+
+
+ {t(`status.${monitor.status}`)} ·{" "}
+ {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
+
+
+ ))}
+
+
+
+ );
}
+const statusOrder = { down: 0, paused: 1, up: 2 } as const;
+
function clampPercent(value: number): number {
return Math.min(100, Math.max(0, value));
}
diff --git a/packages/widgets/src/uptime-kuma/index.ts b/packages/widgets/src/uptime-kuma/index.ts
index b9eebcd8c..cb0d4b0ea 100644
--- a/packages/widgets/src/uptime-kuma/index.ts
+++ b/packages/widgets/src/uptime-kuma/index.ts
@@ -5,6 +5,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("uptimeKuma", {
icon: IconHeartbeat,
+ queryKey: [["widget", "uptimeKuma"]],
createOptions() {
return optionsBuilder.from((factory) => ({
showAverageUptime: factory.switch({
diff --git a/packages/widgets/src/video/component.tsx b/packages/widgets/src/video/component.tsx
index 12b6bbde0..954065e01 100644
--- a/packages/widgets/src/video/component.tsx
+++ b/packages/widgets/src/video/component.tsx
@@ -1,7 +1,7 @@
"use client";
import { useEffect, useRef } from "react";
-import { Anchor, Box, Center, Group, Stack, Title } from "@mantine/core";
+import { Anchor, Badge, Box, Center, Group, Stack, Title } from "@mantine/core";
import { IconBrandYoutube, IconDeviceCctvOff } from "@tabler/icons-react";
import videojs from "video.js";
@@ -16,18 +16,27 @@ import type Player from "video.js/dist/types/player";
import { createDocumentationLink } from "@homarr/definitions";
-export default function VideoWidget({ options }: WidgetComponentProps<"video">) {
+export default function VideoWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"video">) {
if (options.feedUrl.trim() === "") {
return ;
}
- if (options.feedUrl.trim().startsWith("https://www.youtube.com/watch")) {
+ if (isYouTubeUrl(options.feedUrl)) {
return ;
}
- return ;
+ return ;
}
+export const isYouTubeUrl = (value: string): boolean => {
+ try {
+ const url = new URL(value);
+ return url.hostname === "youtu.be" || url.hostname === "youtube.com" || url.hostname.endsWith(".youtube.com");
+ } catch {
+ return false;
+ }
+};
+
const NoUrl = () => {
const t = useI18n();
@@ -55,7 +64,11 @@ const ForYoutubeUseIframe = () => {
);
};
-const Feed = ({ options }: Pick, "options">) => {
+const Feed = ({
+ options,
+ isEditMode,
+ isAdvanced,
+}: Pick, "options"> & { isEditMode: boolean; isAdvanced: boolean }) => {
const videoRef = useRef(null);
const playerRef = useRef(null);
@@ -98,8 +111,8 @@ const Feed = ({ options }: Pick, "options">) => {
useEffect(() => {
if (!playerRef.current) return;
- playerRef.current.controls(options.hasControls);
- }, [options.hasControls]);
+ playerRef.current.controls(!isEditMode && (options.hasControls || isAdvanced));
+ }, [isAdvanced, isEditMode, options.hasControls]);
useEffect(() => {
const player = playerRef.current;
@@ -113,8 +126,21 @@ const Feed = ({ options }: Pick, "options">) => {
}, [playerRef]);
return (
-
+
+ {isAdvanced && (
+
+ {getVideoHost(options.feedUrl)}
+
+ )}
);
};
+
+const getVideoHost = (value: string) => {
+ try {
+ return new URL(value).host;
+ } catch {
+ return value;
+ }
+};
diff --git a/packages/widgets/src/vpn/component.tsx b/packages/widgets/src/vpn/component.tsx
index 79ed5d695..4d43bc302 100644
--- a/packages/widgets/src/vpn/component.tsx
+++ b/packages/widgets/src/vpn/component.tsx
@@ -1,35 +1,63 @@
"use client";
-import { Flex, ScrollArea, Stack } from "@mantine/core";
+import { Flex, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { WidgetComponentProps } from "../definition";
import { VpnIntegrationCard } from "./vpn-card";
-export default function VpnWidget({ options, integrationIds }: WidgetComponentProps<"vpn">) {
+export default function VpnWidget({
+ options,
+ integrationIds,
+ width,
+ displayMode = "compact",
+}: WidgetComponentProps<"vpn">) {
const { data: integrations = [] } = clientApi.widget.vpn.getSummaries.useQuery({
...options,
integrationIds,
});
const [vpn] = integrations;
- if (integrations.length === 1 && vpn) {
+ if (displayMode === "compact" && integrations.length === 1 && vpn) {
return (
-
+
);
}
+ if (displayMode === "advanced") {
+ return (
+
+ = 760 ? 2 : 1} spacing="md">
+ {integrations.map((result) => (
+
+
+ {!result.error && new Date(result.integration.updatedAt).getTime() > 0 && (
+
+ {new Date(result.integration.updatedAt).toLocaleString()}
+
+ )}
+
+ ))}
+
+
+ );
+ }
+
return (
- {integrations.map((vpn) => (
+ {integrations.map((result) => (
))}
diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts
index b3cbe8c73..e63b55455 100644
--- a/packages/widgets/src/vpn/index.ts
+++ b/packages/widgets/src/vpn/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("vpn", {
icon: IconShieldLock,
+ queryKey: [["widget", "vpn"]],
refetchInterval: null,
createOptions() {
return optionsBuilder.from(() => ({}));
diff --git a/packages/widgets/src/weather/animated-icon.tsx b/packages/widgets/src/weather/animated-icon.tsx
index 70145b4b1..e95fa2f47 100644
--- a/packages/widgets/src/weather/animated-icon.tsx
+++ b/packages/widgets/src/weather/animated-icon.tsx
@@ -7,7 +7,6 @@ interface AnimatedWeatherIconProps {
size?: string | number;
}
-
const getAnimationClass = (code: number): string => {
if (code === 0) return "weather-anim-sun";
if ([1, 2, 3].includes(code)) return "weather-anim-cloud";
@@ -18,7 +17,6 @@ const getAnimationClass = (code: number): string => {
return "";
};
-
export const AnimatedWeatherIcon = ({ code, size = 26 }: AnimatedWeatherIconProps) => {
return (
diff --git a/packages/widgets/src/weather/component.spec.ts b/packages/widgets/src/weather/component.spec.ts
new file mode 100644
index 000000000..54f8f6a56
--- /dev/null
+++ b/packages/widgets/src/weather/component.spec.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vitest";
+
+import { getPreferredUnit, getVisibleForecastDays } from "./component";
+
+describe("weather formatting", () => {
+ it("preserves valid zero temperatures", () => {
+ expect(getPreferredUnit(0)).toBe("0.0°C");
+ expect(getPreferredUnit(0, true, true)).toBe("32°F");
+ });
+
+ it("limits compact forecasts by both axes", () => {
+ expect(getVisibleForecastDays(7, 120, 100, false)).toBe(2);
+ expect(getVisibleForecastDays(7, 600, 100, false)).toBe(3);
+ expect(getVisibleForecastDays(7, 120, 100, true)).toBe(7);
+ });
+});
diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx
index 0329597b1..d2346e6ae 100644
--- a/packages/widgets/src/weather/component.tsx
+++ b/packages/widgets/src/weather/component.tsx
@@ -15,7 +15,13 @@ import type { WidgetComponentProps } from "../definition";
import { AnimatedWeatherIcon } from "./animated-icon";
import { WeatherDescription } from "./icon";
-export default function WeatherWidget({ isEditMode, options }: WidgetComponentProps<"weather">) {
+export default function WeatherWidget({
+ isEditMode,
+ options,
+ width,
+ height,
+ displayMode,
+}: WidgetComponentProps<"weather">) {
const input = {
latitude: options.location.latitude,
longitude: options.location.longitude,
@@ -34,9 +40,14 @@ export default function WeatherWidget({ isEditMode, options }: WidgetComponentPr
style={{ pointerEvents: isEditMode ? "none" : undefined }}
>
{options.hasForecast ? (
-
+
) : (
-
+
)}
);
@@ -46,7 +57,7 @@ interface WeatherProps extends Pick, "options">
weather: RouterOutputs["widget"]["weather"]["atLocation"];
}
-const DailyWeather = ({ options, weather }: WeatherProps) => {
+const DailyWeather = ({ options, weather, showDetails = false }: WeatherProps & { showDetails?: boolean }) => {
const t = useScopedI18n("widget.weather");
const tCommon = useScopedI18n("common");
@@ -127,11 +138,39 @@ const DailyWeather = ({ options, weather }: WeatherProps) => {
>
)}
+ {showDetails && weather.daily[0] && (
+
+ )}
>
);
};
-const WeeklyForecast = ({ options, weather }: WeatherProps) => {
+const WeeklyForecast = ({
+ options,
+ weather,
+ maxDays,
+ showDetails,
+}: WeatherProps & { maxDays: number; showDetails: boolean }) => {
return (
<>
@@ -163,16 +202,21 @@ const WeeklyForecast = ({ options, weather }: WeatherProps) => {
-
+
>
);
};
-function Forecast({ weather, options }: WeatherProps) {
+function Forecast({
+ weather,
+ options,
+ maxDays,
+ showDetails,
+}: WeatherProps & { maxDays: number; showDetails: boolean }) {
const dateFormat = options.dateFormat;
return (
- {weather.daily.slice(0, options.forecastDayCount).map((dayWeather, index) => (
+ {weather.daily.slice(0, maxDays).map((dayWeather, index) => (
{getPreferredUnit(dayWeather.maxTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)}
+ {showDetails && (
+
+ {getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, true)}
+
+ )}
@@ -220,9 +269,21 @@ function Forecast({ weather, options }: WeatherProps) {
);
}
-const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
- value
+export const getPreferredUnit = (value?: number, isFahrenheit = false, disableTemperatureDecimals = false): string =>
+ value !== undefined
? isFahrenheit
? `${(value * (9 / 5) + 32).toFixed(disableTemperatureDecimals ? 0 : 1)}°F`
: `${value.toFixed(disableTemperatureDecimals ? 0 : 1)}°C`
: "?";
+
+export const getVisibleForecastDays = (
+ configuredDays: number,
+ width: number,
+ height: number,
+ isAdvanced: boolean,
+): number => {
+ if (isAdvanced) return configuredDays;
+ const widthBudget = Math.max(1, Math.floor(width / 58));
+ const heightBudget = height < 120 ? 3 : configuredDays;
+ return Math.min(configuredDays, widthBudget, heightBudget);
+};
diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts
index 6c30b6df8..f775d15b7 100644
--- a/packages/widgets/src/weather/index.ts
+++ b/packages/widgets/src/weather/index.ts
@@ -7,6 +7,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("weather", {
icon: IconCloud,
+ queryKey: [["widget", "weather"]],
createOptions() {
return optionsBuilder.from(
(factory) => ({