diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..5bf336fa7 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,36 @@ +# Product + +## Register + +product + +## Users + +Homarr is for experienced self-hosters, homelab operators, and administrators who run several services and want one dashboard for monitoring and operating them. They understand technical data, value density, and spend time tuning dashboards to match their own workflows. + +## Product Purpose + +Homarr brings status, data, and safe day-to-day controls from self-hosted services into one customizable dashboard. Success means users can inspect problems, act on services, and reshape their workspace without repeatedly leaving the board. + +## Brand Personality + +Capable, precise, configurable. Homarr should feel calm under load, direct about system state, and rewarding to experts who refine details. + +## Anti-references + +- Consumer dashboards that hide useful detail behind simplified scores. +- Decorative glass, oversized metric cards, and motion without state meaning. +- Rigid layouts that force one workflow or require leaving the dashboard for routine actions. +- Clever custom controls where familiar platform or Mantine interactions already work. + +## Design Principles + +- Preserve expert depth while prioritizing the most urgent information at small sizes. +- Make routine inspection, filtering, configuration, and safe actions available from the board. +- Reveal dense workspaces progressively through a consistent advanced focus mode. +- Treat personalization as a core workflow, not a settings afterthought. +- Show loading, stale, partial, empty, and failed states truthfully without discarding valid data. + +## Accessibility & Inclusion + +Target WCAG AA. Every pointer interaction needs a keyboard and coarse-pointer path. Focus must be visible and restored after overlays close. Motion must respect reduced-motion preferences, and status must never rely on color alone. diff --git a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx index 8dfe0f285..5d7f01cde 100644 --- a/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx +++ b/apps/docs/docs/advanced/keyboard-shortcuts/index.mdx @@ -6,16 +6,23 @@ tags: Homarr has the following keyboard shortcuts: -| Shortcut | Description | -| -------- | ----------- | -| `MOD` + `K` | Opens the search bar | -| `MOD` + `J` | Toggles Dark Mode | -| `MOD` + `E` | Toggles Edit Mode | +| Shortcut | Description | +| ------------------------------------------ | ---------------------------------------------------------- | +| `MOD` + `K` | Opens the search bar | +| `MOD` + `J` | Toggles Dark Mode | +| `MOD` + `E` | Toggles Edit Mode | +| Hold `Shift` while hovering a widget | Opens its temporary advanced view | +| `Shift` + `Enter` on a focused widget | Opens and pins its advanced view | +| `Esc` | Closes an open advanced view or board menu | +| `Shift` + `F10` on a focused board section | Opens the precise add-item menu at the next available cell | :::tip Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. +Advanced view and precise placement are available outside edit mode. Precise placement still requires permission to +change the board and the right-click interaction preference to be enabled. + ::: :::note diff --git a/apps/docs/docs/getting-started/after-the-installation.mdx b/apps/docs/docs/getting-started/after-the-installation.mdx index 7ac904bf4..f8fe31ed2 100644 --- a/apps/docs/docs/getting-started/after-the-installation.mdx +++ b/apps/docs/docs/getting-started/after-the-installation.mdx @@ -54,11 +54,7 @@ You must choose a username and password (both can later be changed). We enforce strict password requirements when you enter your password to protect your instance if you accidentally expose it to the internet. -{"Screenshot +{"Screenshot If you selected external providers (such as LDAP or OIDC), you'll be prompted to create an admin group. This group will be used to synchronize users from your provider to Homarr. @@ -116,22 +112,14 @@ The onboarding can no longer be started again. From here you can click on one of the provided links to continue your journey. We recommend you to continue with the section [creating your first board on this page](#creating-your-first-board). -{"Screenshot +{"Screenshot ### Importing a ZIP from version before 1.0.0 This is recommended when you used Homarr before and want to re-import your data. It is important that you first migrate correctly using [the list of breaking changes for 1.0](/blog/2024/09/23/version-1.0). -Export from old Homarr +Export from old Homarr **How to export and import data** @@ -197,38 +185,29 @@ Here you have the following options in the header: When entering the edit mode, you'll get some additional buttons to work with: -{"Screenshot +{"Screenshot - **Add item**: Click this button to add new elements to your page. -- **Exit edit mode**: Click this btuton to save your changes and exit edit mode. +- **Exit edit mode**: Click this button to save your changes and exit edit mode. + +Outside edit mode, right-click a widget for its live status, refresh, settings, quick options, and supported actions. +Hold `Shift` while hovering a widget for a temporary advanced view; use its pin control to keep it open. You can also +focus the widget and press `Shift` + `Enter` to open it pinned. If you can change the board, right-click empty grid +space to add an item at that position. +On a touch device, touch and hold empty grid space for the same precise-placement menu. To continue, click the option "New Item": -{"Screenshot +{"Screenshot Next, you can choose what item you'd like to add. We will discuss the other options in the following sections - for now choose "App" to continue. -{"Screenshot +{"Screenshot As soon as you chose the app, the modal will close and you'll get the following item on your board: -Screenshot of the No app element on the board +Screenshot of the No app element on the board This is expected, because you haven't told Homarr what app it should show in this item. To configure this, click on the small 3 dots at the top of the item in the edit mode and choose "Edit item" in the menu. diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index f1eb3c572..75c8c6e6f 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -9,11 +9,11 @@ tags: - Layout --- -import iconColorImage from './img/icon-color.png' -import itemRadiusImage from './img/item-radius.png' -import layoutLargeImage from './img/layout-large.png' -import layoutMediumImage from './img/layout-medium.png' -import layoutSmallImage from './img/layout-small.png' +import iconColorImage from "./img/icon-color.png"; +import itemRadiusImage from "./img/item-radius.png"; +import layoutLargeImage from "./img/layout-large.png"; +import layoutMediumImage from "./img/layout-medium.png"; +import layoutSmallImage from "./img/layout-small.png"; Each dashboard is called a "board". You can create as many boards as you want. @@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home) Names of boards must be unique because this will be used in the URL to load the desired board. ## Create a new board + When creating a board, the availability of the board name will be checked for you. ![](./img/create-board-modal.png) ### Name + The name must not include any special characters and must be at least one character long. Underscores and hyphen-minus are allowed. ### Column count + Homarr has a complex drag and drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public + This will enable public access for everyone - regardless of whether they are logged in or not. This can be really useful if you want to have public boards in your company or for family and fiends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings + You navigate to the board settings by clicking on these three dots: ![](./img/navigate-to-settings.png) @@ -51,7 +56,9 @@ Alternatively, you can also navigate from the board page itself using the cog ic ![](./img/navigate-to-settings-2.png) ### General + #### Page title + This is the title that will be visible at the top left in the navigation bar: ![](./img/page-title.png) @@ -59,6 +66,7 @@ This is the title that will be visible at the top left in the navigation bar: This field is optional and Homarr will fall back to "Homarr" if nothing is set. #### Meta title + This is the secondary title of the page that is not directly visible in the page itself. Browser will use it to set the title of tabs: @@ -67,10 +75,12 @@ Browser will use it to set the title of tabs: Homarr will fall back to the board name and a suffix if nothing is set. #### Logo image URL + This is the URL that is used for the image at the top left. Homarr will fall back to the Homarr logo if nothing is set. #### Favicon image URL + This is the URL that is used for the favicon in the tab title. Homarr will fall back to the Homarr logo if nothing is set. @@ -80,9 +90,10 @@ In this section you can configure the different responsive layouts. Depending on ![](./img/layout-settings.png) -On each element the position and size of items can be changed seperately. +On each element the position and size of items can be changed separately. When adding a new layout the position and sizes will automatically be generated depending on the column count. -When adding a new item / dynamic section that will always be 1x1 on each layout and placed on the first available position. +New widgets use their default size and the first available position on each layout. Adding from an empty grid cell anchors +the widget near that cell on the active layout; Homarr still chooses safe free positions for the other responsive layouts. #### Name @@ -94,51 +105,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row #### Breakpoint -Minimum width of the screen where the layout will be used. +Minimum width of the screen where the layout will be used. Once the minmum of the next layout is reached the layout will change. #### Preview - - - + + + ### Background + #### Background image URL + Homarr can display a background image behind apps and widgets. This URL can be used to define what image should be shown. #### Background image attachment + This defines whether the background should move when the user scrolls. #### Background image size + This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page. #### Background image repeat + This defines whether the background should be repeated if it cannot cover the entire page. This option can be useful if you have patterns that can be repeated easily (e.g. patterns): ![](./img/background-repeat.png) ### Appearance + #### Primary color + Color that can be chosen freely to give accents to active elements. + #### Secondary color + Color that can be chosen freely to give accents. + #### Opacity + Opacity of the colors. + #### Icon color + Change the color of your bookmark / app icons. - + #### Item radius + Change the radius of your items / categories. - - + ### Custom CSS + This is the field where you can enter custom CSS. The styling will only be applied on the respective board page. @@ -147,14 +172,29 @@ The styling will only be applied on the respective board page. ### Behaviour #### Disable app status + This will disable the status check for apps. +#### Work directly from the board + +Widgets expose their routine controls without requiring edit mode: + +- Hold `Shift` while hovering a widget for a temporary advanced view, then use its pin control to keep it open. Focus + the widget and press `Shift` + `Enter` to open it pinned, or use the maximize control on touch devices. +- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run + supported actions. Available controls follow your board and integration permissions. +- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board + section and press `Shift` + `F10` for the keyboard equivalent. + +Widget and empty-space context menus follow the **Enable right click on widgets** preference in your user settings. + ### Access control + With the access control, you can permit a single user or a group to your board. The users and groups can have one of these three levels of access: | **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) | -|----------------------|------------|-----------|----------|--------------------------| +| -------------------- | ---------- | --------- | -------- | ------------------------ | | View board | X | | | | | Modify board | X | X | X | | | Full access | X | X | X | X | @@ -173,18 +213,23 @@ After editing permissions, the save button below the table must be pressed. Otherwise, all changes will be discarded. ### Danger zone + #### Rename + Renaming a board will break all existing links to it permanently with no redirect. #### Board visibility + By default, boards will be private. This means only authenticated users that are authorized have access. Making a board public will expose it to everyone - including unauthenticated users. #### Delete + This will permanently delete the board. This action is irreversible and cannot be undone. ## Delete boards + The deletion of boards is permanent and cannot be undone. There are two ways to delete a board: @@ -196,7 +241,7 @@ Both actions will execute the same deletion process. ## Duplicate boards :::note -Duplicating a board will not copy the users and groups that have access to the board +Duplicating a board will not copy the users and groups that have access to the board nor the integrations the user does not have access to. ::: @@ -206,47 +251,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an ![](./img/duplicate-board-modal.png) ## Setting boards as your home board + The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL. All the following paths will lead to the home board: -- ``my-homarr.com`` -- ``my-homarr.com/boards`` + +- `my-homarr.com` +- `my-homarr.com/boards` Your home board will have a special small badge at the top right in the list of boards: ![](./img/home-board-flag.png) ### For yourself + To set the home board yourself, the easiest option is to set it directly in the list of boards: ![](./img/set-as-home-board-menu.png) Alternatively, you can also define it via your user profile: + 1. Click on your user profile at the top right -2. Click on ``Your preferences`` -3. Change the home board in the ``home board`` dropdown and click on ``Save``. +2. Click on `Your preferences` +3. Change the home board in the `home board` dropdown and click on `Save`. ### For other users -1. Navigate to the users: ``Management`` > ``Users`` > ``Manage`` +1. Navigate to the users: `Management` > `Users` > `Manage` 2. Click on the username of the user that you want to adjust -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-other-user.png) +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-other-user.png) ### For user groups -1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage`` -2. Go to tab ``Settings`` -3. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-user-group.png) +1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage` +2. Go to tab `Settings` +3. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-user-group.png) ### For whole server You can also set a global home board that will be used for all users that have not set a home board themselves. It will also be used for anonymous users. -1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards`` -2. Select the desired home board via the dropdown and click on ``Save`` \ -![](./img/set-home-board-global.png) +1. Navigate to the settings: `Management` > `Settings` > `Boards` +2. Select the desired home board via the dropdown and click on `Save` \ + ![](./img/set-home-board-global.png) :::note For the global home board you can only select boards that are public. @@ -255,4 +304,4 @@ For the global home board you can only select boards that are public. ### Mobile home board Same as the home board, you can also set a mobile home board. -It will be used for devices that are detected as mobile devices. (Using User-Agent header) \ No newline at end of file +It will be used for devices that are detected as mobile devices. (Using User-Agent header) diff --git a/apps/docs/docs/widgets/anchor-note/index.mdx b/apps/docs/docs/widgets/anchor-note/index.mdx index 453a5b51c..bfc520c6a 100644 --- a/apps/docs/docs/widgets/anchor-note/index.mdx +++ b/apps/docs/docs/widgets/anchor-note/index.mdx @@ -1,35 +1,37 @@ --- -title: 'Anchor Note' -description: 'Display and edit a selected note from Anchor' +title: "Anchor Note" +description: "Display and edit a selected note from Anchor" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { anchorNoteWidget } from '.'; -import { anchorIntegration } from '@site/docs/integrations/anchor'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { anchorNoteWidget } from "."; +import { anchorIntegration } from "@site/docs/integrations/anchor"; - + The Anchor Note widget shows a selected note from your Anchor integration. If your note permissions allow it, you can also edit the note title and content directly in Homarr. ### Screenshots -import anchorNoteWidgetScreenshot from './img/widget.png'; +import anchorNoteWidgetScreenshot from "./img/widget.png"; anchor-note-widget ### Supported Integrations - + ### Interactions @@ -37,6 +39,8 @@ import anchorNoteWidgetScreenshot from './img/widget.png'; - Click **Save** to persist changes to Anchor. - Click **Cancel** to discard unsaved changes. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/app/index.mdx b/apps/docs/docs/widgets/app/index.mdx index ec62d862e..4fdc3ff5f 100644 --- a/apps/docs/docs/widgets/app/index.mdx +++ b/apps/docs/docs/widgets/app/index.mdx @@ -1,37 +1,38 @@ --- -title: 'App' -description: 'Embeds an app into the board.' +title: "App" +description: "Embeds an app into the board." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { appWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { appWidget } from "."; - + The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board. It also supports a status check to indicate whether Homarr can reach the application or website. ### Screenshots -import githubApp from './img/github.png'; -import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; -import piHoleApp from './img/pi-hole-ping-icon.png'; +import githubApp from "./img/github.png"; +import jellyseerrApp from "./img/jellyseerr-description-tooltip.png"; +import piHoleApp from "./img/pi-hole-ping-icon.png"; -
+
app github with-status-check app pi-hole with-status-check status-check-icon app jellyseerr with-description-tooltip
+ + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.mdx b/apps/docs/docs/widgets/archive-team-warrior/index.mdx new file mode 100644 index 000000000..5eceed2c2 --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.mdx @@ -0,0 +1,38 @@ +--- +title: "ArchiveTeam Warrior" +description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs." +hide_title: true +--- + +import { archiveTeamWarriorIntegration } from "@site/docs/integrations/archiveteam-warrior"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { archiveTeamWarriorWidget } from "."; + + + +Use this widget to see what your ArchiveTeam Warrior is preserving without leaving Homarr. The compact view keeps the current project, runner state, job counts, bandwidth, and broadcast message visible. Advanced view adds the complete active-job list and exact update time. + +### Supported integration + + + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/archive-team-warrior/index.ts b/apps/docs/docs/widgets/archive-team-warrior/index.ts new file mode 100644 index 000000000..5a66b9fbb --- /dev/null +++ b/apps/docs/docs/widgets/archive-team-warrior/index.ts @@ -0,0 +1,19 @@ +import type { WidgetDefinition } from "@site/src/types"; +import { IconArchive } from "@tabler/icons-react"; + +export const archiveTeamWarriorWidget: WidgetDefinition = { + icon: IconArchive, + name: "ArchiveTeam Warrior", + description: "Monitor ArchiveTeam Warrior status, projects, bandwidth, and active archival jobs.", + path: "../../widgets/archive-team-warrior", + configuration: { + items: [ + { + name: "Show broadcast message", + description: "Show the message broadcast by the selected Warrior project when space allows.", + values: { type: "boolean" }, + defaultValue: "yes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/audio-stats/index.mdx b/apps/docs/docs/widgets/audio-stats/index.mdx index 88fa87f79..f79a588c3 100644 --- a/apps/docs/docs/widgets/audio-stats/index.mdx +++ b/apps/docs/docs/widgets/audio-stats/index.mdx @@ -4,6 +4,8 @@ description: "Displays media and listening statistics from Navidrome or Audioboo hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -36,6 +38,8 @@ Only options relevant to your linked integration are shown in the configuration ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/bazarr/index.mdx b/apps/docs/docs/widgets/bazarr/index.mdx index e262cfa75..5cd0c1532 100644 --- a/apps/docs/docs/widgets/bazarr/index.mdx +++ b/apps/docs/docs/widgets/bazarr/index.mdx @@ -1,26 +1,25 @@ --- -title: 'Bazarr' -description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.' +title: "Bazarr" +description: "Displays missing subtitle counts and health indicators from your Bazarr instance." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { bazarrWidget } from '.'; -import { bazarrIntegration } from '@site/docs/integrations/bazarr'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { bazarrWidget } from "."; +import { bazarrIntegration } from "@site/docs/integrations/bazarr"; + + This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings. ### Screenshots -import widgetOverview from './img/widget-overview.png'; +import widgetOverview from "./img/widget-overview.png";
bazarr-widget-overview @@ -28,14 +27,21 @@ import widgetOverview from './img/widget-overview.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/beszel-alerts/index.mdx b/apps/docs/docs/widgets/beszel-alerts/index.mdx index 3a4899d17..d1abe2d2e 100644 --- a/apps/docs/docs/widgets/beszel-alerts/index.mdx +++ b/apps/docs/docs/widgets/beszel-alerts/index.mdx @@ -1,22 +1,21 @@ --- -title: 'Beszel Alerts' -description: 'View Beszel alert configurations and history with triggered/ok status indicators.' +title: "Beszel Alerts" +description: "View Beszel alert configurations and history with triggered/ok status indicators." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelAlertsWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; -import beszelAlerts from './img/beszel-alerts.png'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelAlertsWidget } from "."; +import { beszelIntegration } from "@site/docs/integrations/beszel"; - +import beszelAlerts from "./img/beszel-alerts.png"; + + This widget displays your Beszel alert configurations separated by status (triggered and ok). It also shows alert history with timestamps. Alerts are updated in real time via WebSocket subscriptions. @@ -26,14 +25,21 @@ This widget displays your Beszel alert configurations separated by status (trigg ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/beszel-system-grid/index.mdx b/apps/docs/docs/widgets/beszel-system-grid/index.mdx index e3b17623b..fd4232524 100644 --- a/apps/docs/docs/widgets/beszel-system-grid/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-grid/index.mdx @@ -1,22 +1,21 @@ --- -title: 'Beszel Systems (Grid)' -description: 'Card grid view of all Beszel-monitored systems with real-time metrics.' +title: "Beszel Systems (Grid)" +description: "Card grid view of all Beszel-monitored systems with real-time metrics." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelSystemGridWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; -import beszelSystemGrid from './img/beszel-system-grid.png'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelSystemGridWidget } from "."; +import { beszelIntegration } from "@site/docs/integrations/beszel"; - +import beszelSystemGrid from "./img/beszel-system-grid.png"; + + This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount. @@ -28,14 +27,21 @@ Select a system card to open the complete statistics view, including system, fil ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/beszel-system-stats/index.mdx b/apps/docs/docs/widgets/beszel-system-stats/index.mdx index e29d083ba..4bc00990e 100644 --- a/apps/docs/docs/widgets/beszel-system-stats/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-stats/index.mdx @@ -4,6 +4,8 @@ description: "Time-series charts for CPU, memory, disk, network, and Docker cont hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -32,6 +34,8 @@ This widget displays time-series charts for a selected Beszel system, including ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/beszel-system-table/index.mdx b/apps/docs/docs/widgets/beszel-system-table/index.mdx index df68fcc1b..781a6abad 100644 --- a/apps/docs/docs/widgets/beszel-system-table/index.mdx +++ b/apps/docs/docs/widgets/beszel-system-table/index.mdx @@ -1,21 +1,19 @@ --- -title: 'Beszel Systems (Table)' -description: 'Table view of all Beszel-monitored systems with sortable columns and status indicators.' +title: "Beszel Systems (Table)" +description: "Table view of all Beszel-monitored systems with sortable columns and status indicators." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { beszelSystemTableWidget } from '.'; -import { beszelIntegration } from '@site/docs/integrations/beszel'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { beszelSystemTableWidget } from "."; +import { beszelIntegration } from "@site/docs/integrations/beszel"; - + This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. @@ -23,14 +21,21 @@ Select a system row to open the complete statistics view, including system, file ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index c3104ad5b..5ef772034 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -1,29 +1,27 @@ --- -title: 'Bookmarks' -description: 'Displays multiple app links' +title: "Bookmarks" +description: "Displays multiple app links" hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { bookmarksWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { bookmarksWidget } from "."; - + The bookmarks widget allows you to display multiple app links in a single widget. You can add links to your favorite applications, websites, or any other resources you want quick access to. ### Screenshots -import gridIconsOnly from './img/grid-icons-only.png'; -import gridWithTitle from './img/grid-with-title.png'; -import verticalListAll from './img/vertical-list-all.png'; -import vericalListSimple from './img/vertical-list-simple.png'; +import gridIconsOnly from "./img/grid-icons-only.png"; +import gridWithTitle from "./img/grid-with-title.png"; +import verticalListAll from "./img/vertical-list-all.png"; +import vericalListSimple from "./img/vertical-list-simple.png";
@@ -33,12 +31,15 @@ import vericalListSimple from './img/vertical-list-simple.png'; bookmarks grid with-title bookmarks vertical-list all-elements +
+ ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx index 5db4e9b30..3e1e3f180 100644 --- a/apps/docs/docs/widgets/calendar/index.mdx +++ b/apps/docs/docs/widgets/calendar/index.mdx @@ -1,65 +1,73 @@ --- -title: 'Calendar' -description: 'Display events from your integrations in a calendar view within a certain relative time period' +title: "Calendar" +description: "Display events from your integrations in a calendar view within a certain relative time period" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { calendarWidget } from '.'; -import { lidarrIntegration } from '@site/docs/integrations/lidarr'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; -import { radarrIntegration } from '@site/docs/integrations/radarr'; -import { readarrIntegration } from '@site/docs/integrations/readarr'; -import { sonarrIntegration } from '@site/docs/integrations/sonarr'; -import { icalIntegration } from '@site/docs/integrations/ical'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { calendarWidget } from "."; +import { lidarrIntegration } from "@site/docs/integrations/lidarr"; +import { nextcloudIntegration } from "@site/docs/integrations/nextcloud"; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; +import { radarrIntegration } from "@site/docs/integrations/radarr"; +import { readarrIntegration } from "@site/docs/integrations/readarr"; +import { sonarrIntegration } from "@site/docs/integrations/sonarr"; +import { icalIntegration } from "@site/docs/integrations/ical"; - - + The calendar widget shows a month view of events from different integrations. It allows you to view calendar events or upcoming releases from your media managers. ### Screenshots -import popover from './img/popover.png'; -import withIntegrations from './img/with-integrations.png'; -import withoutIntegrations from './img/without-integrations.png'; +import popover from "./img/popover.png"; +import withIntegrations from "./img/with-integrations.png"; +import withoutIntegrations from "./img/without-integrations.png"; -
- calendar with-integrations big-size -
- calendar popover-details the-mandalorian - calendar without-integrations small-size -
+
+ calendar with-integrations big-size +
+ calendar popover-details the-mandalorian + calendar without-integrations small-size +
### Supported Integrations - + ### Interactions @@ -67,9 +75,12 @@ import withoutIntegrations from './img/without-integrations.png'; - Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away). - Click the date again or click outside to close the pinned popover. + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/clock/index.mdx b/apps/docs/docs/widgets/clock/index.mdx index efdab31d4..157e79d0b 100644 --- a/apps/docs/docs/widgets/clock/index.mdx +++ b/apps/docs/docs/widgets/clock/index.mdx @@ -1,38 +1,38 @@ --- -title: 'Date and time' -description: 'Displays the current date and time.' +title: "Date and time" +description: "Displays the current date and time." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { clockWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { clockWidget } from "."; - + The clock widget displays the current date and time in a customizable format. ### Screenshots -import localClock from './img/local.png'; -import londonClock from './img/london-with-seconds.png'; -import tokyoClock from './img/tokyo-12-hour.png'; +import localClock from "./img/local.png"; +import londonClock from "./img/london-with-seconds.png"; +import tokyoClock from "./img/tokyo-12-hour.png"; -
- clock format-24-hours timezone-local - clock format-24-hours with-seconds timezone-london - clock format-12-hours timezone-tokyo +
+ clock format-24-hours timezone-local + clock format-24-hours with-seconds timezone-london + clock format-12-hours timezone-tokyo
+ ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/coolify/index.mdx b/apps/docs/docs/widgets/coolify/index.mdx index 23eb6ba03..ff26c0a8d 100644 --- a/apps/docs/docs/widgets/coolify/index.mdx +++ b/apps/docs/docs/widgets/coolify/index.mdx @@ -1,28 +1,33 @@ --- -title: 'Coolify' -description: 'Overview of your Coolify instance with servers, applications, and services.' +title: "Coolify" +description: "Overview of your Coolify instance with servers, applications, and services." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { coolifyWidget } from '.'; -import { coolifyIntegration } from '@site/docs/integrations/coolify'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { coolifyWidget } from "."; +import { coolifyIntegration } from "@site/docs/integrations/coolify"; + + The Coolify widget displays a compact overview of your Coolify instance. It can show servers, applications, and services with status indicators, project and environment context, direct links to Coolify, and the installed Coolify version. ### Supported Integrations - + + + ### Adding the widget @@ -34,11 +39,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can ### Sections -| Section | Description | -|---|---| -| **Servers** | Lists Coolify servers, reachability status, and application/service counts per server. | +| Section | Description | +| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- | +| **Servers** | Lists Coolify servers, reachability status, and application/service counts per server. | | **Applications** | Lists applications with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | -| **Services** | Lists services with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | +| **Services** | Lists services with runtime status, project and environment context, public URL links, Coolify dashboard links, and log links. | ### Notes diff --git a/apps/docs/docs/widgets/custom-api/index.mdx b/apps/docs/docs/widgets/custom-api/index.mdx index 78d0bcbba..94eb62116 100644 --- a/apps/docs/docs/widgets/custom-api/index.mdx +++ b/apps/docs/docs/widgets/custom-api/index.mdx @@ -1,18 +1,16 @@ --- -title: 'Custom API' -description: 'Displays data from a custom API endpoint using configurable display types and optional JSX templates.' +title: "Custom API" +description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { customApiWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { customApiWidget } from "."; - + This widget fetches data from a custom API endpoint defined in [Custom Widgets management](/docs/management/custom-widgets) and renders it using configurable display types. @@ -20,6 +18,8 @@ Custom API widgets are created and configured through the admin management page. See the [Custom Widgets documentation](/docs/management/custom-widgets) for full details on creating and configuring custom widgets. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index cea517545..e6fd43e15 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -1,57 +1,64 @@ --- -title: 'DNS Hole Controls' +title: "DNS Hole Controls" description: "Control PiHole or AdGuard from your dashboard." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dnsHoleControlsWidget } from '.'; -import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; -import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; -import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dnsHoleControlsWidget } from "."; +import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home"; +import { piHoleIntegration } from "@site/docs/integrations/pi-hole"; +import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns"; + - - -This widget allows you to enable or disable your DNS Hole. +This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Advanced view exposes every selected source, while compact layouts reduce controls according to both widget width and height. ### Screenshots -import bigSize from './img/big.png'; -import smallSize from './img/small.png'; - +import bigSize from "./img/big.png"; +import smallSize from "./img/small.png"; dns-hole-controls big-size with-toggle-all-buttons dns-hole-controls small-size no-toggle-all-buttons ### Supported Integrations - + ### Interactions -- Enable / Disable one dns-hole by clicking on the badge. -- Disable one dns-hole in x seconds by clicking on the clock icon. -- Do all of the above for all dns-holes by using the top button row. +- Enable or disable one DNS service by clicking its status badge. +- Temporarily disable one service with the clock action. +- Apply an action to all available services from the top action row. Homarr keeps successful results visible and reports how many sources failed. +- Interaction controls only appear when the current user has permission for that integration. + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/dns-hole-summary/index.mdx b/apps/docs/docs/widgets/dns-hole-summary/index.mdx index 1dcb123da..f4d7ca418 100644 --- a/apps/docs/docs/widgets/dns-hole-summary/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-summary/index.mdx @@ -1,38 +1,36 @@ --- -title: 'DNS Hole Summary' +title: "DNS Hole Summary" description: "Displays the summary of your DNS Hole." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dnsHoleSummaryWidget } from '.'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dnsHoleSummaryWidget } from "."; + - - -This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more. +This widget combines statistics from every selected DNS service, including total queries, blocked queries, and blocklist size. Its data is cached under the same DNS query family as DNS Hole Controls, so placing both widgets on a board does not create a second integration request. Advanced view keeps the aggregate metrics visible and adds a named status chip for each source. ### Screenshots -import gridLayout from './img/grid.png'; -import verticalLayout from './img/vertical.png'; -import horizontalLayout from './img/horizontal.png'; -import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; -import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; -import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; +import gridLayout from "./img/grid.png"; +import verticalLayout from "./img/vertical.png"; +import horizontalLayout from "./img/horizontal.png"; +import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home"; +import { piHoleIntegration } from "@site/docs/integrations/pi-hole"; +import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns"; -
+
grid-layout pi-hole-colors -
+
vertical-layout no-colors horizontal-layout pi-hole-colors
@@ -40,17 +38,26 @@ import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/docker-containers/index.mdx b/apps/docs/docs/widgets/docker-containers/index.mdx index fe509f86a..bbd2e104f 100644 --- a/apps/docs/docs/widgets/docker-containers/index.mdx +++ b/apps/docs/docs/widgets/docker-containers/index.mdx @@ -1,24 +1,22 @@ --- -title: 'Docker stats' -description: 'Stats of your containers' +title: "Docker stats" +description: "Stats of your containers" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { dockerContainersWidget } from '.'; -import { dockerIntegration } from '@site/docs/integrations/docker'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { dockerContainersWidget } from "."; +import { dockerIntegration } from "@site/docs/integrations/docker"; - - + This widget displays the resource usage of your Docker containers and allows you to manage them. @@ -28,24 +26,31 @@ This widget can only be used with administrator privileges. ### Screenshots -import dockerTable from './img/table.png'; +import dockerTable from "./img/table.png"; docker-stats table docker-containers ### Supported Integrations - + ### Interactions Each container row has an action menu (accessible via the three-dot icon) with the following options: + - **View logs** - opens a dedicated terminal page streaming the container's logs - **Start** / **Stop** / **Restart** - manage the container's state - **Remove** - remove the container - **Add to Homarr** - create a bookmark app from the container + + ### Adding the widget diff --git a/apps/docs/docs/widgets/downloads/index.mdx b/apps/docs/docs/widgets/downloads/index.mdx index 000fd066b..1db269895 100644 --- a/apps/docs/docs/widgets/downloads/index.mdx +++ b/apps/docs/docs/widgets/downloads/index.mdx @@ -1,43 +1,41 @@ --- -title: 'Download Client' -description: 'Allows you to view and manage your Downloads from both Torrent and Usenet clients.' +title: "Download Client" +description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients." hide_title: true --- -import { aria2Integration } from '@site/docs/integrations/aria2'; -import { delugeIntegration } from '@site/docs/integrations/deluge'; -import { nzbgetIntegration } from '@site/docs/integrations/nzbget'; -import { qBittorentIntegration } from '@site/docs/integrations/q-bittorent'; -import { sabnzbdIntegration } from '@site/docs/integrations/sabnzbd'; -import { transmissionIntegration } from '@site/docs/integrations/transmission'; -import { slskdIntegration } from '@site/docs/integrations/slskd'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { downloadsWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { aria2Integration } from "@site/docs/integrations/aria2"; +import { delugeIntegration } from "@site/docs/integrations/deluge"; +import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; +import { qBittorentIntegration } from "@site/docs/integrations/q-bittorent"; +import { sabnzbdIntegration } from "@site/docs/integrations/sabnzbd"; +import { transmissionIntegration } from "@site/docs/integrations/transmission"; +import { slskdIntegration } from "@site/docs/integrations/slskd"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { downloadsWidget } from "."; - + This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface. ### Screenshots -import downloadsCard from './img/screenshot.png'; -import contextMenuImg from './img/context-menu.png'; +import downloadsCard from "./img/screenshot.png"; +import contextMenuImg from "./img/context-menu.png";
- Downloads widget showing torrent and usenet items + Downloads widget showing torrent and usenet items
#### Right-click context menu
- Downloads widget context menu with pause, delete, and copy actions + Downloads widget context menu with pause, delete, and copy actions
### Features @@ -56,28 +54,38 @@ import contextMenuImg from './img/context-menu.png'; ### Supported Integrations - + ### Interactions @@ -89,9 +97,12 @@ import contextMenuImg from './img/context-menu.png'; - **Filter** by integration or status using the filter button in the footer - **Sort** by clicking column headers (with persistent sort direction) + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/firewall/index.mdx b/apps/docs/docs/widgets/firewall/index.mdx index 95aaa56d6..aec2d89d9 100644 --- a/apps/docs/docs/widgets/firewall/index.mdx +++ b/apps/docs/docs/widgets/firewall/index.mdx @@ -1,22 +1,20 @@ --- -title: 'Firewall Monitoring' -description: 'Displays a summary of firewalls.' +title: "Firewall Monitoring" +description: "Displays a summary of firewalls." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { firewallWidget } from '.'; -import { opnsenseIntegration } from '../../integrations/opnsense'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { firewallWidget } from "."; +import { opnsenseIntegration } from "../../integrations/opnsense"; - + ### Screenshots @@ -24,9 +22,15 @@ import { opnsenseIntegration } from '../../integrations/opnsense'; ### Supported Integrations - + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index 603207451..bc50cf2c7 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -4,6 +4,8 @@ description: "Displays information showing the health and status of your system( hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -62,6 +64,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 5be26f3b2..7d983342a 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -1,19 +1,17 @@ --- -title: 'iFrame' -description: 'Embed any content from the internet. Some websites may restrict access.' +title: "iFrame" +description: "Embed any content from the internet. Some websites may restrict access." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { iframeWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { iframeWidget } from "."; - + If your application is not supported natively by Homarr, you can integrate it using the iframe widget. It uses the [iframe element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe), @@ -22,14 +20,16 @@ This means, no traffic is routed through Homarr (and the URL must be reachable f ### Screenshots -import wikipediaHomepage from './img/wikipedia.png'; -import youtubeStream from './img/nasa-iss-stream.png'; +import wikipediaHomepage from "./img/wikipedia.png"; +import youtubeStream from "./img/nasa-iss-stream.png";
iframe wikipedia-homepage iframe youtube-iss-stream
+ + ### Adding the widget @@ -50,12 +50,14 @@ the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) For example, to allow your site to be embedded in iframes from specific domains: **Apache (.htaccess or httpd.conf):** + ```apache Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com" Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com" ``` **Nginx:** + ```nginx add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"; add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; @@ -67,6 +69,7 @@ add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni ### Configuration + ### Transparent iframes @@ -75,15 +78,16 @@ To create a transparent iframe like below you can use the following html code an ```html - + - + ``` ![transparent iframe](./img/transparent-iframe.png) ### Third party content + :::warning The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository. @@ -92,11 +96,12 @@ We cannot guarantee the functionality nor security of these tools. ::: ### homarr-iframes from Diogo Valentim + The project [homarr-iframes](https://github.com/diogovalentte/homarr-iframes) has iframes designed to be used with Homarr, these iframes offer a minimalist view of other systems: - [Vikunja](https://github.com/go-vikunja/vikunja): overview of your tasks with links to them. - [Linkwarden](https://github.com/linkwarden/linkwarden): overview of your bookmarks with links to them. -- [Cinemark Brazil](https://www.cinemark.com.br): overview of the movies in Cinemark theaters in your city (*only in Brazil*). +- [Cinemark Brazil](https://www.cinemark.com.br): overview of the movies in Cinemark theaters in your city (_only in Brazil_). ![Image of the Vikunja (left), Linkwarden (middle), and Cinemark Brazil (right) iframes on a Homarr dashboard](./img/homarr-iframes-example.webp "Image of the Vikunja (left), Linkwarden (middle), and Cinemark Brazil (right) iframes on a Homarr dashboard") diff --git a/apps/docs/docs/widgets/immich-album-carousel/index.mdx b/apps/docs/docs/widgets/immich-album-carousel/index.mdx index 703bd5a57..2975d53fe 100644 --- a/apps/docs/docs/widgets/immich-album-carousel/index.mdx +++ b/apps/docs/docs/widgets/immich-album-carousel/index.mdx @@ -1,39 +1,42 @@ --- -title: 'Immich Album Carousel' -description: 'Shows a slideshow of pictures from an Immich album or your full library' +title: "Immich Album Carousel" +description: "Shows a slideshow of pictures from an Immich album or your full library" hide_title: true --- -import { immichIntegration } from '../../integrations/immich';import { - AddingWidget -} from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { immichAlbumCarouselWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { immichIntegration } from "../../integrations/immich"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { immichAlbumCarouselWidget } from "."; - + ### Screenshots -import screen from './img/screen.png'; +import screen from "./img/screen.png"; immich album size-big ### Supported Integrations - + + + ### Adding the widget - ### Configuration + diff --git a/apps/docs/docs/widgets/immich-server-stats/index.mdx b/apps/docs/docs/widgets/immich-server-stats/index.mdx index 73d278a91..64f4d8d02 100644 --- a/apps/docs/docs/widgets/immich-server-stats/index.mdx +++ b/apps/docs/docs/widgets/immich-server-stats/index.mdx @@ -1,40 +1,42 @@ --- -title: 'Immich Server Stats' -description: 'Information about your Immich instance' +title: "Immich Server Stats" +description: "Information about your Immich instance" hide_title: true --- -import { immichIntegration } from '../../integrations/immich'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { immichServerStatsWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { immichIntegration } from "../../integrations/immich"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { immichServerStatsWidget } from "."; - + ### Screenshots -import screen from './img/screen.png'; - +import screen from "./img/screen.png"; immich server stats size-big ### Supported Integrations - + + + ### Adding the widget - ### Configuration - + diff --git a/apps/docs/docs/widgets/indexer-manager/index.mdx b/apps/docs/docs/widgets/indexer-manager/index.mdx index fad8c341b..3b81c0023 100644 --- a/apps/docs/docs/widgets/indexer-manager/index.mdx +++ b/apps/docs/docs/widgets/indexer-manager/index.mdx @@ -1,47 +1,50 @@ --- -title: 'Indexer manager status' -description: 'View the status of your indexers.' +title: "Indexer manager status" +description: "View the status of your indexers." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { indexerManagerWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { indexerManagerWidget } from "."; - + ### Screenshots -import bigItem from './img/big.png'; -import smallItem from './img/small.png'; -import { prowlarrIntegration } from '@site/docs/integrations/prowlarr'; +import bigItem from "./img/big.png"; +import smallItem from "./img/small.png"; +import { prowlarrIntegration } from "@site/docs/integrations/prowlarr"; -
+
indexer-manager size-big indexer-manager size-small
### Supported Integrations - + ### Interactions - Test all indexers by clicking on the test button. + + ### Adding the widget - ### Configuration - + diff --git a/apps/docs/docs/widgets/media-missing/index.mdx b/apps/docs/docs/widgets/media-missing/index.mdx index 648bd4190..643da6a3c 100644 --- a/apps/docs/docs/widgets/media-missing/index.mdx +++ b/apps/docs/docs/widgets/media-missing/index.mdx @@ -1,29 +1,28 @@ --- -title: 'Media Missing & Queue' -description: 'Track missing media and active downloads from Radarr and Sonarr' +title: "Media Missing & Queue" +description: "Track missing media and active downloads from Radarr and Sonarr" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { mediaMissingWidget } from '.'; -import { radarrIntegration } from '@site/docs/integrations/radarr'; -import { sonarrIntegration } from '@site/docs/integrations/sonarr'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { mediaMissingWidget } from "."; +import { radarrIntegration } from "@site/docs/integrations/radarr"; +import { sonarrIntegration } from "@site/docs/integrations/sonarr"; + + Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs. ### Screenshots -import widgetMissing from './img/missing.png'; -import widgetQueued from './img/queued.png'; -import widgetCompact from './img/compact.png'; +import widgetMissing from "./img/missing.png"; +import widgetQueued from "./img/queued.png"; +import widgetCompact from "./img/compact.png";
media-missing widget showing missing movies and episodes in a grid @@ -33,18 +32,23 @@ import widgetCompact from './img/compact.png'; ### Supported Integrations - + ### Tabs -| Tab | Description | -|---|---| +| Tab | Description | +| ----------- | ----------------------------------------------------------------------------------------------------- | | **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. | -| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. | +| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. | ### Sizing @@ -56,6 +60,8 @@ The widget adapts to its container size, so it looks good at any grid size: Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx index fece199eb..7142ccfc5 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -1,30 +1,28 @@ --- -title: 'Media releases' -description: 'Display newly added medias or upcoming releases from different integrations' +title: "Media releases" +description: "Display newly added medias or upcoming releases from different integrations" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { mediaReleasesWidget } from '.'; -import { embyIntegration } from '@site/docs/integrations/emby'; -import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; -import { plexIntegration } from '@site/docs/integrations/plex'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { mediaReleasesWidget } from "."; +import { embyIntegration } from "@site/docs/integrations/emby"; +import { jellyfinIntegration } from "@site/docs/integrations/jellyfin"; +import { plexIntegration } from "@site/docs/integrations/plex"; - + Provides a list of newly added media or upcoming releases from your media server. ### Screenshots -import backdrop from './img/backdrop.png'; -import poster from './img/poster.png'; +import backdrop from "./img/backdrop.png"; +import poster from "./img/poster.png";
media-releases layout-backdrop @@ -33,17 +31,26 @@ import poster from './img/poster.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 1c491a051..68f3a27ab 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -45,6 +47,8 @@ import mediaRequestList from "./img/list.png"; - Approve / decline a request by clicking on the thumbs up or down icon. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index a7b40f624..b381f177b 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -1,39 +1,42 @@ --- -title: 'Media Requests Stats' -description: 'Statistics about your media requests' +title: "Media Requests Stats" +description: "Statistics about your media requests" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaRequestStatsWidget } from '.'; -import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; -import { overseerrIntegration } from '@site/docs/integrations/overseerr'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaRequestStatsWidget } from "."; +import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr"; +import { overseerrIntegration } from "@site/docs/integrations/overseerr"; - - + Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections. ### Supported Integrations - + ### Screenshots -import mediaRequestStats from './img/stats.png'; +import mediaRequestStats from "./img/stats.png"; media-request-stats @@ -41,6 +44,8 @@ import mediaRequestStats from './img/stats.png'; - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index 8fb0c857c..f76ce050b 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -1,56 +1,62 @@ --- -title: 'Media server streams' -description: 'Show the current streams on your media servers' +title: "Media server streams" +description: "Show the current streams on your media servers" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaServerWidget } from '.'; -import { embyIntegration } from '@site/docs/integrations/emby'; -import { jellyfinIntegration } from '@site/docs/integrations/jellyfin'; -import { navidromeIntegration } from '@site/docs/integrations/navidrome'; -import { plexIntegration } from '@site/docs/integrations/plex'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaServerWidget } from "."; +import { embyIntegration } from "@site/docs/integrations/emby"; +import { jellyfinIntegration } from "@site/docs/integrations/jellyfin"; +import { navidromeIntegration } from "@site/docs/integrations/navidrome"; +import { plexIntegration } from "@site/docs/integrations/plex"; + - - -The media server widget enables you to watch and view current active sessions over all your media servers. +The media server widget enables you to watch and view current active sessions over all your media servers. It also displays useful information, that you can use when providing support to users or debugging a problem. - - ### Screenshots -import mediaTable from './img/table.png'; +import mediaTable from "./img/table.png"; media-server-streams ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/media-transcoding/index.mdx b/apps/docs/docs/widgets/media-transcoding/index.mdx index 0fc280021..256b8edd6 100644 --- a/apps/docs/docs/widgets/media-transcoding/index.mdx +++ b/apps/docs/docs/widgets/media-transcoding/index.mdx @@ -1,35 +1,31 @@ --- -title: 'Media transcoding' -description: 'Statistics, current queue and worker status of your media transcoding' +title: "Media transcoding" +description: "Statistics, current queue and worker status of your media transcoding" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { mediaTranscodingWidget } from '.'; -import { tdarrIntegration } from '@site/docs/integrations/tdarr'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { mediaTranscodingWidget } from "."; +import { tdarrIntegration } from "@site/docs/integrations/tdarr"; + - - -The media transcoding widget provides you with an overview of your media transcoding status. +The media transcoding widget provides you with an overview of your media transcoding status. It shows you the current queue, worker status, and statistics about your transcoding jobs. - - ### Screenshots -import queueCard from './img/queue.png'; -import workersCard from './img/workers.png'; -import statisticsCard from './img/statistics.png'; +import queueCard from "./img/queue.png"; +import workersCard from "./img/workers.png"; +import statisticsCard from "./img/statistics.png";
media-transcoding workers-tab @@ -39,13 +35,20 @@ import statisticsCard from './img/statistics.png'; ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index 51101246b..8a99cf8cf 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -1,44 +1,45 @@ --- -title: 'Minecraft Server Status' -description: 'Displays the status of a Minecraft server.' +title: "Minecraft Server Status" +description: "Displays the status of a Minecraft server." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { minecraftServerStatusWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { minecraftServerStatusWidget } from "."; - - + Shows the status and amount of players on a Minecraft server. It supports both Java and Bedrock editions, however only public servers are supported which can be reached through the used api of [api.mcsrvstat.us](https://api.mcsrvstat.us). ### Screenshots -import javaItem from './img/java.png'; -import bedrockItem from './img/bedrock.png'; +import javaItem from "./img/java.png"; +import bedrockItem from "./img/bedrock.png"; -
+
minecraft-server-status java-edition minecraft-server-status bedrock-editon
+ + ### Adding the widget ### Configuration + ### Data source We use the Minecraft Server Status API to fetch the server status. -If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us` diff --git a/apps/docs/docs/widgets/network-controller-status/index.mdx b/apps/docs/docs/widgets/network-controller-status/index.mdx index a01496234..ad0feaf9d 100644 --- a/apps/docs/docs/widgets/network-controller-status/index.mdx +++ b/apps/docs/docs/widgets/network-controller-status/index.mdx @@ -1,44 +1,49 @@ --- -title: 'Network Status' -description: 'Display connected devices on a network' +title: "Network Status" +description: "Display connected devices on a network" hide_title: true --- -import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { networkControllerStatusWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { networkControllerStatusWidget } from "."; - - + Shows connected users and connected guest devices on wired or wireless networks. ### Screenshots -import wifiCard from './img/wifi.png'; -import wiredCard from './img/wired.png'; +import wifiCard from "./img/wifi.png"; +import wiredCard from "./img/wired.png"; network-controller-status wifi network-controller-status wired ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/network-controller-summary/index.mdx b/apps/docs/docs/widgets/network-controller-summary/index.mdx index fe4cd3357..1386fc069 100644 --- a/apps/docs/docs/widgets/network-controller-summary/index.mdx +++ b/apps/docs/docs/widgets/network-controller-summary/index.mdx @@ -1,38 +1,42 @@ --- -title: 'Network Controller Summary' -description: 'Displays the summary of a Network Controller' +title: "Network Controller Summary" +description: "Displays the summary of a Network Controller" hide_title: true --- -import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import TabItem from '@theme/TabItem'; -import Tabs from '@theme/Tabs'; -import { networkControllerSummaryWidget } from '.'; +import TabItem from "@theme/TabItem"; +import Tabs from "@theme/Tabs"; +import { networkControllerSummaryWidget } from "."; - - + Shows information whether your WAN is connected, Wi-Fi is active and several other information. ### Screenshots -import summaryCard from './img/summary.png'; +import summaryCard from "./img/summary.png"; network-controller-summary ### Supported Integrations - + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/notebook/index.mdx b/apps/docs/docs/widgets/notebook/index.mdx index 393137ed2..a245600aa 100644 --- a/apps/docs/docs/widgets/notebook/index.mdx +++ b/apps/docs/docs/widgets/notebook/index.mdx @@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -24,6 +26,8 @@ import editOn from "./img/edit.png"; notebook edit-on
+ + ### Adding the widget diff --git a/apps/docs/docs/widgets/notifications/index.mdx b/apps/docs/docs/widgets/notifications/index.mdx index 0a715ab3c..41638ac33 100644 --- a/apps/docs/docs/widgets/notifications/index.mdx +++ b/apps/docs/docs/widgets/notifications/index.mdx @@ -1,44 +1,41 @@ --- -title: 'Notifications' -description: 'Display notification history from an integration' +title: "Notifications" +description: "Display notification history from an integration" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { notificationsWidget } from '.'; -import { ntfyIntegration } from '@site/docs/integrations/ntfy'; -import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; -import { gotifyIntegration } from '@site/docs/integrations/gotify'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { IconAd } from "@tabler/icons-react"; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { notificationsWidget } from "."; +import { ntfyIntegration } from "@site/docs/integrations/ntfy"; +import { nextcloudIntegration } from "@site/docs/integrations/nextcloud"; +import { gotifyIntegration } from "@site/docs/integrations/gotify"; + + The notifications widget allows you to display notifications from various integrations in a unified view. ### Screenshots -import notificationsScreenshot from './img/notifications.png'; +import notificationsScreenshot from "./img/notifications.png"; notifications ### Supported Integrations + + ### Adding the widget diff --git a/apps/docs/docs/widgets/paperless-ngx/index.mdx b/apps/docs/docs/widgets/paperless-ngx/index.mdx index 3dd684efd..4479165e2 100644 --- a/apps/docs/docs/widgets/paperless-ngx/index.mdx +++ b/apps/docs/docs/widgets/paperless-ngx/index.mdx @@ -1,34 +1,39 @@ --- -title: 'Paperless-ngx' -description: 'Displays document management statistics including inbox ratio, document counts, and metadata.' +title: "Paperless-ngx" +description: "Displays document management statistics including inbox ratio, document counts, and metadata." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { paperlessNgxWidget } from '.'; -import { paperlessNgxIntegration } from '@site/docs/integrations/paperless-ngx'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { paperlessNgxWidget } from "."; +import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx"; - + This widget displays statistics from your Paperless-ngx instance, including an inbox ratio ring showing how many documents are in your inbox versus total, along with counts for correspondents, tags, and document types. ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/patchmon/index.mdx b/apps/docs/docs/widgets/patchmon/index.mdx index d16ab8d68..ccbcd7dab 100644 --- a/apps/docs/docs/widgets/patchmon/index.mdx +++ b/apps/docs/docs/widgets/patchmon/index.mdx @@ -1,21 +1,19 @@ --- -title: 'PatchMon' -description: 'Displays host patch statistics from PatchMon including update and security counts.' +title: "PatchMon" +description: "Displays host patch statistics from PatchMon including update and security counts." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { patchmonWidget } from '.'; -import { patchmonIntegration } from '@site/docs/integrations/patchmon'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { patchmonWidget } from "."; +import { patchmonIntegration } from "@site/docs/integrations/patchmon"; - + This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning. @@ -39,13 +37,13 @@ In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a r When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity: -| Stat | Green | Yellow | Red | -|------|-------|--------|-----| -| Needs Updates | 0 | 1–4 | 5+ | -| Security Updates | 0 | 1–9 | 10+ | -| Hosts with Security Updates | 0 | — | 1+ | -| Up-to-Date Hosts | 100% of total | 90–99% | below 90% | -| Outdated Packages | 0 | 1–49 | 50+ | +| Stat | Green | Yellow | Red | +| --------------------------- | ------------- | ------ | --------- | +| Needs Updates | 0 | 1–4 | 5+ | +| Security Updates | 0 | 1–9 | 10+ | +| Hosts with Security Updates | 0 | — | 1+ | +| Up-to-Date Hosts | 100% of total | 90–99% | below 90% | +| Outdated Packages | 0 | 1–49 | 50+ | Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts. @@ -62,13 +60,13 @@ The OS distribution limit defaults to **All**. A widget size of **2×3** or larg The widget adapts its layout based on the available space: -| Widget size | Layout | -|-------------|--------| -| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats | +| Widget size | Layout | +| ----------------- | ------------------------------------------------------------------------------------------------------------------- | +| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats | | **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero | -| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards | -| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer | -| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend | +| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards | +| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer | +| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend | ### Last updated @@ -76,14 +74,21 @@ When the widget has enough space, a "last updated" timestamp appears at the bott ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/releases/index.mdx b/apps/docs/docs/widgets/releases/index.mdx index faf386a13..017ea97ca 100644 --- a/apps/docs/docs/widgets/releases/index.mdx +++ b/apps/docs/docs/widgets/releases/index.mdx @@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -33,6 +35,8 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted. + + ### Adding the widget diff --git a/apps/docs/docs/widgets/rss-feed/index.mdx b/apps/docs/docs/widgets/rss-feed/index.mdx index 68bbb1071..d663554d6 100644 --- a/apps/docs/docs/widgets/rss-feed/index.mdx +++ b/apps/docs/docs/widgets/rss-feed/index.mdx @@ -1,37 +1,40 @@ --- -title: 'RSS Feed' -description: 'Monitor and display one or more generic RSS, ATOM or JSON feeds.' +title: "RSS Feed" +description: "Monitor and display one or more generic RSS, ATOM or JSON feeds." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { rssFeedWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { rssFeedWidget } from "."; - + Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. ### Screenshots -import screenshot from './img/screenshot.png'; +import screenshot from "./img/screenshot.png"; rss-feed noted-lol + + ### Adding the widget ### Configuration + ### Examples + Here's a small collection of examples, that you can use for testing or actual use (in no particular order): + - https://noted.lol/rss/ - https://blog.google/products/chrome/rss/ - https://www.w3.org/blog/feed/ @@ -45,4 +48,4 @@ Here's a small collection of examples, that you can use for testing or actual us - http://feeds.foxnews.com/foxnews/latest There is a good list of feeds here: -https://github.com/greysonp/rss-feeds/blob/master/feeds.json \ No newline at end of file +https://github.com/greysonp/rss-feeds/blob/master/feeds.json diff --git a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx index d101d6376..28c83d3aa 100644 --- a/apps/docs/docs/widgets/smart-home-entity-state/index.mdx +++ b/apps/docs/docs/widgets/smart-home-entity-state/index.mdx @@ -1,34 +1,32 @@ --- -title: 'Entity State' -description: 'Display the state of an entity and toggle it optionally' +title: "Entity State" +description: "Display the state of an entity and toggle it optionally" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { smartHomeEntityStateWidget } from '.'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { smartHomeEntityStateWidget } from "."; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; - - + The entity state enables you to display the current state of an entity. -This works with most entities and enables you display almost any data. +This works with most entities and enables you display almost any data. It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited. ### Screenshots -import sun from './img/sun.png'; -import lightSwitch from './img/light-switch.png'; -import temperature from './img/temperature.png'; +import sun from "./img/sun.png"; +import lightSwitch from "./img/light-switch.png"; +import temperature from "./img/temperature.png"; smart-home-entity-state sun smart-home-entity-state light-switch @@ -36,32 +34,40 @@ import temperature from './img/temperature.png'; ### Supported Integrations - + ### Interactions - Toggle entity state by clicking on the entity (only when clickable enabled). + + ### Adding the widget ### Configuration + ### How to find the entity ID To find the entity ID in Home Assistant, follow these steps: + 1. Open Home Assistant and go to the **Developer Tools** section (usually found in the sidebar). 2. Click on the **States** tab. 3. In the list of entities, find the entity you want to use. 4. The entity ID will be displayed in the format `domain.object_id` (e.g., `light.living_room_light`). 5. Copy the entity ID and paste it into the widget configuration in Homarr. It will then show the State that is visible in Home Assistant. -import retrieveEntityId from './img/retrieve-entity-id.png'; +import retrieveEntityId from "./img/retrieve-entity-id.png";
homeassistant retrieve-entity-id -
\ No newline at end of file +
diff --git a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx index 522c113ca..f0118799a 100644 --- a/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx +++ b/apps/docs/docs/widgets/smart-home-execute-automation/index.mdx @@ -1,50 +1,60 @@ --- -title: 'Execute Automation' -description: 'Trigger an automation with one click' +title: "Execute Automation" +description: "Trigger an automation with one click" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { smartHomeExecuteAutomationWidget } from '.'; -import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { smartHomeExecuteAutomationWidget } from "."; +import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant"; - - + The execute automation widget allows you to trigger an automation with one click. ### Screenshots -import empty from './img/empty.png'; -import withDisplayname from './img/with-displayname.png'; +import empty from "./img/empty.png"; +import withDisplayname from "./img/with-displayname.png";
smart-home-execute-automation empty - smart-home-execute-automation with-displayname + smart-home-execute-automation with-displayname
### Supported Integrations - + ### Interactions - Trigger the automation by clicking on the widget. + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/speedtest-tracker/index.mdx b/apps/docs/docs/widgets/speedtest-tracker/index.mdx index 94be18dd7..c6c0bd992 100644 --- a/apps/docs/docs/widgets/speedtest-tracker/index.mdx +++ b/apps/docs/docs/widgets/speedtest-tracker/index.mdx @@ -1,39 +1,45 @@ --- -title: 'Speedtest Tracker' -description: 'Displays speed test results from your Speedtest Tracker instance.' +title: "Speedtest Tracker" +description: "Displays speed test results from your Speedtest Tracker instance." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { speedtestTrackerWidget } from '.'; -import { speedtestTrackerIntegration } from '@site/docs/integrations/speedtest-tracker'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { speedtestTrackerWidget } from "."; +import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-tracker"; + + This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, including the latest result, historical averages, and a live chart of the last 12 hours. ### Screenshots -import screenshot from './img/screenshot.png'; +import screenshot from "./img/screenshot.png"; speedtest-tracker image ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/stock-price/index.mdx b/apps/docs/docs/widgets/stock-price/index.mdx index 5d9a24c64..2a6119240 100644 --- a/apps/docs/docs/widgets/stock-price/index.mdx +++ b/apps/docs/docs/widgets/stock-price/index.mdx @@ -1,28 +1,26 @@ --- -title: 'Stock Price' -description: 'Displays the current stock price of a company.' +title: "Stock Price" +description: "Displays the current stock price of a company." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { stockPriceWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { stockPriceWidget } from "."; - + Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. ### Screenshots -import apple from './img/apple.png'; -import google from './img/google.png'; -import ethereum from './img/ethereum.png'; -import nvidia from './img/nvidia.png'; +import apple from "./img/apple.png"; +import google from "./img/google.png"; +import ethereum from "./img/ethereum.png"; +import nvidia from "./img/nvidia.png";
@@ -34,17 +32,21 @@ import nvidia from './img/nvidia.png';
stock-price nvidia
- + + ### Adding the widget ### Configuration + ### Examples + Here's a small collection of examples, that you can use for testing or actual use (in no particular order): + - AAPL - GOOGL - AMZN @@ -59,4 +61,4 @@ Here's a small collection of examples, that you can use for testing or actual us ### Data source We use the Yahoo Finance API to fetch the stock prices. -If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com` diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx index b3be87e1d..5015ac725 100644 --- a/apps/docs/docs/widgets/system-disks/index.mdx +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -4,6 +4,8 @@ description: "Disk usage of your system" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -42,6 +44,8 @@ This widget displays filesystem and SMART disk usage for supported server and NA ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/system-disks/index.ts b/apps/docs/docs/widgets/system-disks/index.ts index 8dd68f057..ade53efc0 100644 --- a/apps/docs/docs/widgets/system-disks/index.ts +++ b/apps/docs/docs/widgets/system-disks/index.ts @@ -19,7 +19,11 @@ export const systemDisksWidget: WidgetDefinition = { description: "How disk usage values are shown", values: { type: "select", - options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"], + options: [ + "Percentage (e.g., 76%)", + "Absolute values (e.g., 800GB / 1TB)", + "Free space percentage (e.g., 24% free)", + ], }, defaultValue: "Percentage (e.g., 76%)", }, diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx index 16ad19750..cdc258f4c 100644 --- a/apps/docs/docs/widgets/system-resources/index.mdx +++ b/apps/docs/docs/widgets/system-resources/index.mdx @@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time" hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology"; ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/timetable/index.mdx b/apps/docs/docs/widgets/timetable/index.mdx index e9bdfd326..8fb3b7ccd 100644 --- a/apps/docs/docs/widgets/timetable/index.mdx +++ b/apps/docs/docs/widgets/timetable/index.mdx @@ -4,6 +4,8 @@ description: "Displays departure times for a station." hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetHeader } from "@site/src/components/widgets/header"; @@ -19,18 +21,12 @@ import trainDepartures from "./img/train-departures.png"; import streetDepartures from "./img/street-departures.png";
- timetable-widget-train-departures - timetable-widget-bus-and-tram-departures + timetable-widget-train-departures + timetable-widget-bus-and-tram-departures
+ + ### Adding the widget diff --git a/apps/docs/docs/widgets/tracearr/index.mdx b/apps/docs/docs/widgets/tracearr/index.mdx index a579d8af3..ff02699ca 100644 --- a/apps/docs/docs/widgets/tracearr/index.mdx +++ b/apps/docs/docs/widgets/tracearr/index.mdx @@ -1,52 +1,57 @@ --- -title: 'Tracearr' -description: 'Monitor media server streams, user activity, and policy violations.' +title: "Tracearr" +description: "Monitor media server streams, user activity, and policy violations." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { tracearrWidget } from '.'; -import { tracearrIntegration } from '@site/docs/integrations/tracearr'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { tracearrWidget } from "."; +import { tracearrIntegration } from "@site/docs/integrations/tracearr"; - + The Tracearr widget provides a real-time monitoring dashboard for your media server activity. It displays active streams, user statistics, recent watching history, and policy violations. ### Sections -| Section | Description | -|---|---| -| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. | -| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). | -| **Recent Activity** | A paginated list of watching history with user avatars and watched status. | -| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. | +| Section | Description | +| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. | +| **Active Streams** | Shows currently playing media with poster art, user info, progress bar, resolution, and transcoding status (Direct Play or Transcode). | +| **Recent Activity** | A paginated list of watching history with user avatars and watched status. | +| **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. | ### Screenshots -import widgetOverview from './img/widget-overview.png'; -import streamsAndStats from './img/streams-and-stats.png'; +import widgetOverview from "./img/widget-overview.png"; +import streamsAndStats from "./img/streams-and-stats.png";
tracearr-widget-overview tracearr-streams-and-stats
+ + ### Adding the widget ### Supported Integrations - + ### Configuration + diff --git a/apps/docs/docs/widgets/traefik/index.mdx b/apps/docs/docs/widgets/traefik/index.mdx index dc9f6a6c4..dfc2f9247 100644 --- a/apps/docs/docs/widgets/traefik/index.mdx +++ b/apps/docs/docs/widgets/traefik/index.mdx @@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun hide_title: true --- +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; + import { WidgetHeader } from "@site/src/components/widgets/header"; import { AddingWidget } from "@site/src/components/widgets/adding"; import { WidgetConfig } from "@site/src/components/widgets/configuration"; @@ -26,6 +28,8 @@ This widget displays a compact overview of your Traefik instance, including tota ]} /> + + ### Adding the widget diff --git a/apps/docs/docs/widgets/umami/index.mdx b/apps/docs/docs/widgets/umami/index.mdx index 0d0006054..6f2c2bb7f 100644 --- a/apps/docs/docs/widgets/umami/index.mdx +++ b/apps/docs/docs/widgets/umami/index.mdx @@ -1,28 +1,33 @@ --- -title: 'Umami Analytics' -description: 'Display visitor stats from your Umami analytics instance' +title: "Umami Analytics" +description: "Display visitor stats from your Umami analytics instance" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { umamiWidget } from '.'; -import { umamiIntegration } from '@site/docs/integrations/umami'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { umamiWidget } from "."; +import { umamiIntegration } from "@site/docs/integrations/umami"; + + The Umami Analytics widget displays key visitor metrics from a website tracked by your [Umami](https://umami.is) instance - pageviews, unique visitors, visits, bounce rate, avg. visit duration, and real-time active visitor count. The widget body can be switched between a visitor/event chart, a multi-event chart, and ranked top pages or referrers lists. ### Supported Integrations - + + + ### Adding the widget @@ -34,12 +39,12 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b ### View modes -| View | Description | -|---|---| -| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. | -| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. | -| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. | -| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". | +| View | Description | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Chart** | Bar chart or sparkline of unique visitors over the selected time frame. Optionally overlay a single named event as a second series. | +| **Events chart** | Multi-select one or more named events and chart each as its own colored series - no visitor baseline. Supports bar and sparkline. | +| **Top pages** | Ranked list of URLs by pageview count for the selected time frame. | +| **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". | ### Notes diff --git a/apps/docs/docs/widgets/ups/index.mdx b/apps/docs/docs/widgets/ups/index.mdx index bf512990d..8a3d7037e 100644 --- a/apps/docs/docs/widgets/ups/index.mdx +++ b/apps/docs/docs/widgets/ups/index.mdx @@ -1,20 +1,19 @@ --- -title: 'UPS' -description: 'Monitor the status of your UPS devices through a NUT server.' +title: "UPS" +description: "Monitor the status of your UPS devices through a NUT server." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { upsWidget } from '.'; -import { peaNutIntegration } from '@site/docs/integrations/peanut'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { upsWidget } from "."; +import { peaNutIntegration } from "@site/docs/integrations/peanut"; + + This widget displays the status of the UPS devices reported by a Network UPS Tools (NUT) server. Each device shows its status (online, on battery, charging or low battery), battery charge and estimated runtime, output load, and input and @@ -22,27 +21,34 @@ output voltage. ### Layouts -import screenshotFull from './img/screenshot.png'; -import screenshotCompact from './img/screenshot-compact.png'; -import screenshotMini from './img/screenshot-mini.png'; +import screenshotFull from "./img/screenshot.png"; +import screenshotCompact from "./img/screenshot-compact.png"; +import screenshotMini from "./img/screenshot-mini.png"; The widget adapts to the size of the tile, so it stays readable from a full dashboard panel down to a single cell. Multiple UPS devices scroll within the widget at any height. -| Layout | Size | Preview | -| --- | --- | --- | -| Full | 3+ columns wide | UPS widget full layout | -| Compact | ~2 columns | UPS widget compact layout | -| Mini | 1 column | UPS widget mini layout | +| Layout | Size | Preview | +| ------- | --------------- | --------------------------------------------------------------------------- | +| Full | 3+ columns wide | UPS widget full layout | +| Compact | ~2 columns | UPS widget compact layout | +| Mini | 1 column | UPS widget mini layout | ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/uptime-kuma/index.mdx b/apps/docs/docs/widgets/uptime-kuma/index.mdx index 3a6592b2e..6c5eefa16 100644 --- a/apps/docs/docs/widgets/uptime-kuma/index.mdx +++ b/apps/docs/docs/widgets/uptime-kuma/index.mdx @@ -1,34 +1,39 @@ --- -title: 'Uptime Kuma' -description: 'Displays monitor uptime statistics, average uptime percentage, and service status counts.' +title: "Uptime Kuma" +description: "Displays monitor uptime statistics, average uptime percentage, and service status counts." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { uptimeKumaWidget } from '.'; -import { uptimeKumaIntegration } from '@site/docs/integrations/uptime-kuma'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { uptimeKumaWidget } from "."; +import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma"; - + This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. ### Supported Integrations - + + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/video/index.mdx b/apps/docs/docs/widgets/video/index.mdx index 3ccf3760a..27fef9fb9 100644 --- a/apps/docs/docs/widgets/video/index.mdx +++ b/apps/docs/docs/widgets/video/index.mdx @@ -1,19 +1,17 @@ --- -title: 'Video Stream' -description: 'Embed a video stream or video from a camera or a website.' +title: "Video Stream" +description: "Embed a video stream or video from a camera or a website." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { videoWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { videoWidget } from "."; - + Do you run surveilance cameras? Or maybe you have a favourite video? The video widget enables you to integrate both seamlessly into your dashboard. @@ -22,7 +20,6 @@ This widget uses [video.js](https://videojs.com/) to play HLS or DASH video nati DRM content is not yet supported. More info here: https://videojs.com/guides/faqs/#q-what-is-videojs - You can use the following URL to test: http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 @@ -30,9 +27,12 @@ http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp ![video big-buck-bunny](./img/bunny.png) + + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/video/index.tsx b/apps/docs/docs/widgets/video/index.tsx index e0df87ce0..7d57a27ad 100644 --- a/apps/docs/docs/widgets/video/index.tsx +++ b/apps/docs/docs/widgets/video/index.tsx @@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = { icon: IconDeviceCctv, name: "Video Stream", description: "Embed a video stream or video from a camera or a website.", - path: "../../widgets/stock-price", + path: "../../widgets/video", configuration: { items: [ { diff --git a/apps/docs/docs/widgets/vpn/index.mdx b/apps/docs/docs/widgets/vpn/index.mdx index 0aa6a1a16..fb6223cbe 100644 --- a/apps/docs/docs/widgets/vpn/index.mdx +++ b/apps/docs/docs/widgets/vpn/index.mdx @@ -1,19 +1,18 @@ --- -title: 'VPN' -description: 'Monitor the status of your VPN integrations.' +title: "VPN" +description: "Monitor the status of your VPN integrations." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { vpnWidget } from '.'; -import { gluetunIntegration } from '@site/docs/integrations/gluetun'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; - +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { vpnWidget } from "."; +import { gluetunIntegration } from "@site/docs/integrations/gluetun"; + + The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS services are up, the currently exposed public IP address, and the VPN provider and protocol being used. @@ -23,7 +22,7 @@ current status at a glance. ### Screenshots -import singleIntegrationImage from './img/single.png'; +import singleIntegrationImage from "./img/single.png";
VPN widget showing a single integration @@ -31,9 +30,15 @@ import singleIntegrationImage from './img/single.png'; ### Supported Integrations - + + + ### Adding the widget diff --git a/apps/docs/docs/widgets/weather/index.mdx b/apps/docs/docs/widgets/weather/index.mdx index 0ff4081d7..3ca81acaa 100644 --- a/apps/docs/docs/widgets/weather/index.mdx +++ b/apps/docs/docs/widgets/weather/index.mdx @@ -1,40 +1,41 @@ --- -title: 'Weather' -description: 'Displays the current weather information of a set location.' +title: "Weather" +description: "Displays the current weather information of a set location." hide_title: true --- -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { weatherWidget } from '.'; +import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { weatherWidget } from "."; - + The Weather Widget uses your location to display the current, highest and lowest temperature. ### Screenshots -import forecastWeather from './img/forecast.png'; -import currentWeather from './img/current.png'; +import forecastWeather from "./img/forecast.png"; +import currentWeather from "./img/current.png";
weather forecast-5-days weather current
+ + ### Adding the widget ### Configuration + ### Data source We use the Open Meteo API to fetch the weather and locations. -If you need to whitelist the api, please use the following URL: `https://open-meteo.com` \ No newline at end of file +If you need to whitelist the api, please use the following URL: `https://open-meteo.com` diff --git a/apps/docs/src/components/widgets/interactions.tsx b/apps/docs/src/components/widgets/interactions.tsx new file mode 100644 index 000000000..80ba47b46 --- /dev/null +++ b/apps/docs/src/components/widgets/interactions.tsx @@ -0,0 +1,47 @@ +import { IconArrowsMaximize, IconMouse2, IconPlus } from "@tabler/icons-react"; + +const interactions = [ + { + icon: IconArrowsMaximize, + title: "Advanced view", + description: + "Hold Shift while hovering (then pin it), press Shift + Enter, or choose Open advanced view from the widget menu.", + }, + { + icon: IconMouse2, + title: "Widget actions", + description: + "Right-click for live query status, refresh, settings, quick options, and actions supported by the widget.", + }, + { + icon: IconPlus, + title: "Place it precisely", + description: + "With board changes allowed, right-click or touch and hold empty grid space to add there. Keyboard users can press Shift + F10.", + }, +] as const; + +export const WidgetInteractionGuide = () => ( +
+ + Power-user controls + Show shortcuts + + Hide shortcuts + + +
+ {interactions.map(({ icon: Icon, title, description }) => ( +
+ +
{title}
+

{description}

+
+ ))} +
+

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

+
+); diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 3e167dd8c..9f2c2b3d8 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -2,7 +2,6 @@ import type { PropsWithChildren } from "react"; import { useState } from "react"; -import type { QueryKey } from "@tanstack/react-query"; import { QueryClient } from "@tanstack/react-query"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryStreamedHydration } from "@tanstack/react-query-next-experimental"; @@ -34,6 +33,7 @@ import { import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; +import type { WidgetDefinition } from "@homarr/widgets"; import { widgetImports } from "@homarr/widgets"; import { createWidgetQueryPersister } from "./query-cache-persister"; @@ -93,11 +93,11 @@ export function TRPCReactProvider(props: PropsWithChildren) { }); client.setQueryDefaults([["widget"]], { refetchInterval: queryCacheDefaultRefetchIntervalMs }); for (const { definition } of Object.values(widgetImports)) { - const def = definition as { refetchInterval?: number | null; queryKey?: QueryKey; kind: string }; + const def = definition as WidgetDefinition & { kind: string }; if (def.refetchInterval === undefined) continue; - const key = def.queryKey ?? [["widget", def.kind]]; const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - client.setQueryDefaults(key, { refetchInterval: interval }); + const primaryQueryKey = def.queryKey ?? [["widget", def.kind]]; + client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval }); } return client; }); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx index 0ba502a9a..292a2ed05 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_client.tsx @@ -4,6 +4,7 @@ import { Box, Stack } from "@mantine/core"; import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context"; import { BoardCategorySection } from "~/components/board/sections/category-section"; import { BoardEmptySection } from "~/components/board/sections/empty-section"; import { BoardBackgroundVideo } from "~/components/layout/background"; @@ -17,17 +18,19 @@ export const ClientBoard = () => { .sort((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset); return ( - - - - {fullWidthSortedSections.map((section) => - section.kind === "empty" ? ( - - ) : ( - - ), - )} - - + + + + + {fullWidthSortedSections.map((section) => + section.kind === "empty" ? ( + + ) : ( + + ), + )} + + + ); }; diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx index 30c91de40..5e12a259b 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_header-actions.tsx @@ -70,6 +70,7 @@ export const BoardContentHeaderActions = ({ demoReadOnly }: { demoReadOnly: bool }; const AddMenu = () => { + const board = useRequiredBoard(); const { data: session } = useSession(); const { openModal: openCategoryEditModal } = useModalAction(CategoryEditModal); const { openModal: openItemSelectModal } = useModalAction(ItemSelectModal); @@ -101,8 +102,8 @@ const AddMenu = () => { ); const handleSelectItem = useCallback(() => { - openItemSelectModal(); - }, [openItemSelectModal]); + openItemSelectModal({ board, persistImmediately: false }); + }, [board, openItemSelectModal]); const handleSelectApp = useCallback(() => { openAppSelectModal({ diff --git a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx index 25a53ffd2..383d74795 100644 --- a/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx +++ b/apps/nextjs/src/app/[locale]/manage/custom-widgets/_custom-widget-preview.tsx @@ -123,7 +123,7 @@ export function CustomWidgetPreview({ type: "application/json", }); const blobUrl = URL.createObjectURL(blob); - window.open(blobUrl); + window.open(blobUrl, "_blank", "noopener,noreferrer"); setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000); }} > @@ -167,7 +167,7 @@ function PreviewDisplay({ data }: { data: unknown }) { const Component = dataType ? displayComponents[dataType] : undefined; if (Component) { - return ; + return ; } return ( diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css new file mode 100644 index 000000000..d1b045802 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css @@ -0,0 +1,132 @@ +.focusScope { + height: 100%; +} + +.backdrop { + position: fixed; + inset: 0; + z-index: 198; + background: rgba(0, 0, 0, 0.64); + backdrop-filter: blur(6px) saturate(0.75); + touch-action: none; + animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.surface { + position: fixed; + z-index: 199; + pointer-events: auto; + overflow: hidden; + background: var(--mantine-color-body); + box-shadow: var(--mantine-shadow-lg); + overscroll-behavior: contain; + animation: focus-surface-in 220ms cubic-bezier(0.16, 1, 0.3, 1); + transform-origin: top left; +} + +.backdropClosing { + animation: focus-backdrop-out 180ms ease-in forwards; +} + +.surfaceClosing { + animation: focus-surface-out 180ms ease-in forwards; +} + +.sourcePlaceholder { + pointer-events: none; +} + +.surfaceHost { + width: 100%; + height: 100%; +} + +.surfaceHostAdvanced { + position: fixed; + inset: 0; + z-index: 199; + width: auto; + height: auto; + pointer-events: none; +} + +.surfaceControls { + position: absolute; + inset: 0; + z-index: 2; + pointer-events: none; +} + +.closeButton { + position: absolute; + top: 8px; + right: 8px; + z-index: 2; + pointer-events: auto; +} + +.pinButton { + position: absolute; + top: 8px; + right: 52px; + z-index: 2; + pointer-events: auto; +} + +.touchButton { + position: absolute; + right: 8px; + bottom: 8px; + z-index: 2; + display: none; + pointer-events: auto; +} + +@media (pointer: coarse) { + .touchButton { + display: inline-flex; + } +} + +@keyframes focus-backdrop-in { + from { + opacity: 0; + } +} + +@keyframes focus-surface-in { + from { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0.72; + } +} + +@keyframes focus-backdrop-out { + to { + opacity: 0; + } +} + +@keyframes focus-surface-out { + to { + transform: translate(var(--focus-translate-x), var(--focus-translate-y)) + scale(var(--focus-scale-x), var(--focus-scale-y)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + .backdrop, + .surface { + animation-duration: 1ms; + } +} + +@media (forced-colors: active) { + .backdrop { + backdrop-filter: none; + background: Canvas; + opacity: 0.8; + } +} diff --git a/apps/nextjs/src/components/board/advanced-focus/context.tsx b/apps/nextjs/src/components/board/advanced-focus/context.tsx new file mode 100644 index 000000000..79e4ff50d --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/context.tsx @@ -0,0 +1,213 @@ +"use client"; + +import type { PropsWithChildren } from "react"; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; +import { Box, Portal, RemoveScroll } from "@mantine/core"; + +import { useEditMode } from "@homarr/boards/edit-mode"; + +import classes from "./advanced-focus.module.css"; +import { isEscapeOwnedByNestedOverlay } from "./escape"; +import type { FocusRect } from "./geometry"; + +interface ActiveFocus { + itemId: string; + source: HTMLElement; + sourceRect: FocusRect; + pinned: boolean; + autofocusClose: boolean; + phase: "visible" | "closing"; +} + +interface AdvancedFocusContextValue { + active: ActiveFocus | null; + open: (itemId: string, source: HTMLElement, pinned?: boolean) => void; + close: (restoreFocus?: boolean) => void; + dismiss: (itemId: string) => void; + pin: (autofocusClose?: boolean) => void; + hover: (itemId: string, source: HTMLElement) => void; + leave: (itemId: string) => void; +} + +const AdvancedFocusContext = createContext(null); +const HOVER_DELAY_MS = 150; +const CLOSE_DURATION_MS = 180; + +const isEditableTarget = (target: EventTarget | null) => + target instanceof HTMLElement && + (target.matches("input, textarea, select, [contenteditable='true']") || target.isContentEditable); + +export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => { + const [isEditMode] = useEditMode(); + const [active, setActive] = useState(null); + const activeRef = useRef(null); + const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null); + const shiftHeldRef = useRef(false); + const hoverTimerRef = useRef | null>(null); + const closeTimerRef = useRef | null>(null); + + const cancelHoverTimer = useCallback(() => { + if (hoverTimerRef.current === null) return; + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + }, []); + + const updateActive = useCallback((next: ActiveFocus | null) => { + activeRef.current = next; + setActive(next); + }, []); + + const cancelCloseTimer = useCallback(() => { + if (closeTimerRef.current === null) return; + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + }, []); + + const open = useCallback( + (itemId: string, source: HTMLElement, pinned = false) => { + if (isEditMode) return; + cancelHoverTimer(); + cancelCloseTimer(); + const rect = source.getBoundingClientRect(); + updateActive({ + itemId, + source, + sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height }, + pinned, + autofocusClose: pinned, + phase: "visible", + }); + }, + [cancelCloseTimer, cancelHoverTimer, isEditMode, updateActive], + ); + + const close = useCallback( + (restoreFocus = true) => { + cancelHoverTimer(); + const current = activeRef.current; + if (!current || current.phase === "closing") return; + updateActive({ ...current, phase: "closing" }); + const closeDuration = window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : CLOSE_DURATION_MS; + closeTimerRef.current = setTimeout(() => { + closeTimerRef.current = null; + updateActive(null); + if (restoreFocus && current.source.isConnected) requestAnimationFrame(() => current.source.focus()); + }, closeDuration); + }, + [cancelHoverTimer, updateActive], + ); + + const startHoverTimer = useCallback(() => { + cancelHoverTimer(); + const hovered = hoveredRef.current; + if (!hovered || !shiftHeldRef.current || activeRef.current) return; + hoverTimerRef.current = setTimeout(() => open(hovered.itemId, hovered.source), HOVER_DELAY_MS); + }, [cancelHoverTimer, open]); + + const hover = useCallback( + (itemId: string, source: HTMLElement) => { + hoveredRef.current = { itemId, source }; + startHoverTimer(); + }, + [startHoverTimer], + ); + + const leave = useCallback( + (itemId: string) => { + if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null; + cancelHoverTimer(); + }, + [cancelHoverTimer], + ); + + const dismiss = useCallback( + (itemId: string) => { + if (activeRef.current?.itemId !== itemId) return; + cancelHoverTimer(); + cancelCloseTimer(); + updateActive(null); + }, + [cancelCloseTimer, cancelHoverTimer, updateActive], + ); + + const pin = useCallback( + (autofocusClose = false) => { + const current = activeRef.current; + if (!current || current.pinned || current.phase === "closing") return; + updateActive({ ...current, pinned: true, autofocusClose }); + }, + [updateActive], + ); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape" && activeRef.current) { + const advancedSurface = document.querySelector(`.${classes.surface}`); + if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface)) + return; + event.preventDefault(); + close(); + return; + } + if (isEditMode) return; + if (event.key !== "Shift" || event.repeat || isEditableTarget(event.target)) return; + if (activeRef.current?.pinned) return; + shiftHeldRef.current = true; + startHoverTimer(); + }; + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key !== "Shift") return; + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current && !activeRef.current.pinned) close(false); + }; + const handleBlur = () => { + shiftHeldRef.current = false; + cancelHoverTimer(); + if (activeRef.current && !activeRef.current.pinned) close(false); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + window.addEventListener("blur", handleBlur); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + window.removeEventListener("blur", handleBlur); + cancelHoverTimer(); + cancelCloseTimer(); + }; + }, [cancelCloseTimer, cancelHoverTimer, close, isEditMode, startHoverTimer]); + + useEffect(() => { + if (isEditMode && activeRef.current) close(false); + }, [close, isEditMode]); + + const value = useMemo( + () => ({ active, open, close, dismiss, pin, hover, leave }), + [active, close, dismiss, hover, leave, open, pin], + ); + + return ( + + + {children} + + {active && ( + + close()} + aria-hidden + /> + + )} + + ); +}; + +export const useAdvancedFocus = () => { + const context = useContext(AdvancedFocusContext); + if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider"); + return context; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts new file mode 100644 index 000000000..dc123eeff --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.spec.ts @@ -0,0 +1,54 @@ +import { describe, expect, test } from "vitest"; + +import { isEscapeOwnedByNestedOverlay } from "./escape"; + +describe("isEscapeOwnedByNestedOverlay", () => { + test("allows Escape from the advanced surface itself", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const button = document.createElement("button"); + surface.append(button); + + expect(isEscapeOwnedByNestedOverlay(button, surface)).toBe(false); + }); + + test("leaves Escape to a nested Mantine modal", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const modal = document.createElement("section"); + modal.setAttribute("role", "dialog"); + const input = document.createElement("input"); + modal.append(input); + + expect(isEscapeOwnedByNestedOverlay(input, surface)).toBe(true); + }); + + test.each(["menu", "listbox"])("leaves Escape to a nested %s overlay", (role) => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const overlay = document.createElement("div"); + overlay.setAttribute("role", role); + const target = document.createElement("button"); + overlay.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("honors Mantine's stop-propagation marker for menus and comboboxes", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const target = document.createElement("button"); + target.setAttribute("data-mantine-stop-propagation", "true"); + surface.append(target); + + expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true); + }); + + test("does not mistake the compact widget source for a nested overlay", () => { + const surface = document.createElement("section"); + surface.setAttribute("role", "dialog"); + const source = document.createElement("button"); + + expect(isEscapeOwnedByNestedOverlay(source, surface)).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/escape.ts b/apps/nextjs/src/components/board/advanced-focus/escape.ts new file mode 100644 index 000000000..47bd86dd1 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/escape.ts @@ -0,0 +1,13 @@ +const escapeOwnerSelector = [ + '[data-mantine-stop-propagation="true"]', + '[role="dialog"]', + '[role="menu"]', + '[role="listbox"]', +].join(", "); + +export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => { + if (!(target instanceof Element)) return false; + + const escapeOwner = target.closest(escapeOwnerSelector); + return escapeOwner !== null && escapeOwner !== advancedSurface; +}; diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts new file mode 100644 index 000000000..4b5ee0b89 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.spec.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; + +import { getAdvancedFocusRect } from "./geometry"; + +describe("getAdvancedFocusRect", () => { + it("expands a widget and keeps it inside the viewport", () => { + expect(getAdvancedFocusRect({ left: 10, top: 10, width: 300, height: 200 }, { width: 1200, height: 800 })).toEqual({ + left: 240, + top: 190, + width: 720, + height: 480, + }); + }); + + it("uses the available viewport on small screens", () => { + expect(getAdvancedFocusRect({ left: 0, top: 0, width: 400, height: 500 }, { width: 390, height: 700 })).toEqual({ + left: 8, + top: 68, + width: 374, + height: 624, + }); + }); + + it("keeps controls below the fixed board header on short screens", () => { + const rect = getAdvancedFocusRect({ left: 0, top: 0, width: 500, height: 500 }, { width: 1366, height: 768 }); + + expect(rect.top).toBeGreaterThanOrEqual(84); + expect(rect.top + rect.height).toBeLessThanOrEqual(744); + }); +}); diff --git a/apps/nextjs/src/components/board/advanced-focus/geometry.ts b/apps/nextjs/src/components/board/advanced-focus/geometry.ts new file mode 100644 index 000000000..78bdda016 --- /dev/null +++ b/apps/nextjs/src/components/board/advanced-focus/geometry.ts @@ -0,0 +1,26 @@ +import { appShellHeaderHeight } from "../../layout/constants"; + +export interface FocusRect { + left: number; + top: number; + width: number; + height: number; +} + +export const getAdvancedFocusRect = (source: FocusRect, viewport: { width: number; height: number }): FocusRect => { + const margin = viewport.width < 640 ? 8 : 24; + const availableWidth = Math.max(0, viewport.width - margin * 2); + const availableHeight = Math.max(0, viewport.height - appShellHeaderHeight - margin * 2); + const width = Math.min(availableWidth, Math.max(source.width, Math.min(960, source.width * 2.4))); + const height = Math.min(availableHeight, Math.max(source.height, Math.min(720, source.height * 2.4))); + + return { + left: Math.max(margin, (viewport.width - width) / 2), + top: Math.max( + appShellHeaderHeight + margin, + appShellHeaderHeight + (viewport.height - appShellHeaderHeight - height) / 2, + ), + width, + height, + }; +}; diff --git a/apps/nextjs/src/components/board/items/actions/create-item.ts b/apps/nextjs/src/components/board/items/actions/create-item.ts index d4b2cb24d..0778bf864 100644 --- a/apps/nextjs/src/components/board/items/actions/create-item.ts +++ b/apps/nextjs/src/components/board/items/actions/create-item.ts @@ -3,35 +3,48 @@ import { createId } from "@homarr/common"; import type { Modify } from "@homarr/common/types"; import type { WidgetKind } from "@homarr/definitions"; import { widgetDefaultSizes } from "@homarr/definitions"; +import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { Board, EmptySection, Item, ItemLayout } from "~/app/[locale]/boards/_types"; -import { getFirstEmptyPosition } from "./empty-position"; +import { getFirstEmptyPosition, getNearestEmptyPosition } from "./empty-position"; import { getSectionElements } from "./section-elements"; +export interface BoardPlacementHint { + sectionId: string; + layoutId: string; + xOffset: number; + yOffset: number; +} + export interface CreateItemInput { id?: string; kind: WidgetKind; options?: Record; integrationIds?: string[]; + advancedOptions?: BoardItemAdvancedOptions; + placement?: BoardPlacementHint; } export const createItemCallback = - ({ id, kind, options = {}, integrationIds = [] }: CreateItemInput) => + ({ id, kind, options = {}, integrationIds = [], advancedOptions, placement }: CreateItemInput) => (previous: Board): Board => { const firstSection = previous.sections .filter((section): section is EmptySection => section.kind === "empty") .toSorted((sectionA, sectionB) => sectionA.yOffset - sectionB.yOffset) .at(0); - if (!firstSection) return previous; + if (!firstSection && !placement) return previous; + + const defaultSectionId = firstSection?.id ?? placement?.sectionId; + if (!defaultSectionId) return previous; const widget = { id: id ?? createId(), kind, options, - layouts: createItemLayouts(previous, firstSection, kind), + layouts: createItemLayouts(previous, defaultSectionId, kind, placement), integrationIds, - advancedOptions: { + advancedOptions: advancedOptions ?? { title: null, customCssClasses: [], borderColor: "", @@ -53,16 +66,31 @@ const defaultItemSize = { width: 1, height: 1 }; const getWidgetItemSize = (kind: WidgetKind) => widgetDefaultSizes[kind] ?? defaultItemSize; -const createItemLayouts = (board: Board, currentSection: EmptySection, kind: WidgetKind): ItemLayout[] => { +const createItemLayouts = ( + board: Board, + defaultSectionId: string, + kind: WidgetKind, + placement?: BoardPlacementHint, +): ItemLayout[] => { const layouts = getBoardLayouts(board); const itemSize = getWidgetItemSize(kind); return layouts.map((layoutId) => { const boardLayout = board.layouts.find((layout) => layout.id === layoutId); - const elements = getSectionElements(board, { sectionId: currentSection.id, layoutId }); + const targetSectionId = placement?.layoutId === layoutId ? placement.sectionId : defaultSectionId; + const elements = getSectionElements(board, { sectionId: targetSectionId, layoutId }); + const targetSection = board.sections.find((section) => section.id === targetSectionId); + const dynamicLayout = + targetSection?.kind === "dynamic" + ? targetSection.layouts.find((layout) => layout.layoutId === layoutId) + : undefined; + const columnCount = dynamicLayout?.width ?? boardLayout?.columnCount ?? 1; + const rowCount = dynamicLayout?.height ?? 9999; const emptyPosition = boardLayout - ? getFirstEmptyPosition(elements, boardLayout.columnCount, 9999, itemSize) + ? placement?.layoutId === layoutId + ? getNearestEmptyPosition(elements, columnCount, rowCount, placement, itemSize) + : getFirstEmptyPosition(elements, columnCount, rowCount, itemSize) : { xOffset: 0, yOffset: 0 }; if (!emptyPosition) { @@ -73,7 +101,7 @@ const createItemLayouts = (board: Board, currentSection: EmptySection, kind: Wid width: itemSize.width, height: itemSize.height, ...emptyPosition, - sectionId: currentSection.id, + sectionId: targetSectionId, layoutId, }; }); diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..ec1803545 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -7,19 +7,56 @@ export const getFirstEmptyPosition = ( size: { width: number; height: number } = { width: 1, height: 1 }, ) => { for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { - for (let xOffset = 0; xOffset < columnCount; xOffset++) { - const isOccupied = elements.some( - (element) => - element.yOffset < yOffset + size.height && - element.yOffset + element.height > yOffset && - element.xOffset < xOffset + size.width && - element.xOffset + element.width > xOffset, - ); - - if (!isOccupied) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { + if (isPositionEmpty(elements, { xOffset, yOffset }, size)) { return { xOffset, yOffset }; } } } return undefined; }; + +export const getNearestEmptyPosition = ( + elements: Pick[], + columnCount: number, + rowCount: number, + preferred: { xOffset: number; yOffset: number }, + size: { width: number; height: number }, +) => { + const maxX = columnCount - size.width; + const maxY = rowCount - size.height; + if (maxX < 0 || maxY < 0) return undefined; + + const clamped = { + xOffset: Math.min(Math.max(0, preferred.xOffset), maxX), + yOffset: Math.min(Math.max(0, preferred.yOffset), maxY), + }; + if (isPositionEmpty(elements, clamped, size)) return clamped; + + const lastOccupiedRow = elements.reduce((maximum, element) => Math.max(maximum, element.yOffset + element.height), 0); + const searchMaxY = Math.min(maxY, Math.max(clamped.yOffset + 20, lastOccupiedRow + size.height)); + let best: { xOffset: number; yOffset: number; distance: number } | undefined; + + for (let yOffset = 0; yOffset <= searchMaxY; yOffset++) { + for (let xOffset = 0; xOffset <= maxX; xOffset++) { + if (!isPositionEmpty(elements, { xOffset, yOffset }, size)) continue; + const distance = Math.abs(xOffset - clamped.xOffset) + Math.abs(yOffset - clamped.yOffset); + if (!best || distance < best.distance) best = { xOffset, yOffset, distance }; + } + } + + return best && { xOffset: best.xOffset, yOffset: best.yOffset }; +}; + +const isPositionEmpty = ( + elements: Pick[], + position: { xOffset: number; yOffset: number }, + size: { width: number; height: number }, +) => + !elements.some( + (element) => + element.yOffset < position.yOffset + size.height && + element.yOffset + element.height > position.yOffset && + element.xOffset < position.xOffset + size.width && + element.xOffset + element.width > position.xOffset, + ); diff --git a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts index e2a0ff93c..f09383171 100644 --- a/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/create-item.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, describe, expect, test, vi } from "vitest"; import * as boardContext from "@homarr/boards/context"; @@ -10,6 +10,10 @@ import { ItemMockBuilder } from "./mocks/item-mock"; import { LayoutMockBuilder } from "./mocks/layout-mock"; describe("item actions create-item", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + test("should add it to first section", () => { // Arrange const itemKind = "clock"; @@ -108,4 +112,58 @@ describe("item actions create-item", () => { { height: 1, width: 1 }, ); }); + + test("uses the clicked section and nearest cell for the active layout", () => { + const activeLayoutId = "desktop"; + const secondaryLayoutId = "mobile"; + const rootSectionId = "root"; + const targetSectionId = "target"; + const activeLayout = new LayoutMockBuilder({ id: activeLayoutId, columnCount: 12 }).build(); + const secondaryLayout = new LayoutMockBuilder({ id: secondaryLayoutId, columnCount: 4 }).build(); + const targetSection = new DynamicSectionMockBuilder({ id: targetSectionId }) + .addLayout({ + layoutId: activeLayoutId, + parentSectionId: rootSectionId, + width: 4, + height: 3, + }) + .build(); + const board = new BoardMockBuilder() + .addLayout(activeLayout) + .addLayout(secondaryLayout) + .addEmptySection({ id: rootSectionId, yOffset: 0 }) + .addSection(targetSection) + .build(); + + vi.spyOn(boardContext, "getBoardLayouts").mockReturnValue([activeLayoutId, secondaryLayoutId]); + + const result = createItemCallback({ + kind: "clock", + placement: { + sectionId: targetSectionId, + layoutId: activeLayoutId, + xOffset: 2, + yOffset: 1, + }, + })(board); + + expect(result.items.at(0)?.layouts).toEqual([ + { + layoutId: activeLayoutId, + sectionId: targetSectionId, + xOffset: 2, + yOffset: 1, + width: 1, + height: 1, + }, + { + layoutId: secondaryLayoutId, + sectionId: rootSectionId, + xOffset: 0, + yOffset: 0, + width: 1, + height: 1, + }, + ]); + }); }); diff --git a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts index fcc194618..cc505157b 100644 --- a/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts +++ b/apps/nextjs/src/components/board/items/actions/test/empty-position.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest"; import type { Item } from "~/app/[locale]/boards/_types"; -import { getFirstEmptyPosition } from "../empty-position"; +import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position"; describe("get first empty position", () => { test.each([ @@ -106,6 +106,37 @@ describe("get first empty position", () => { expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); }); + + test("does not place a wide item beyond the right edge", () => { + const elements = createElementsFromLayout([["a", "a", "a", " "]]); + + expect(getFirstEmptyPosition(elements, 4, undefined, { width: 2, height: 1 })).toEqual({ + xOffset: 0, + yOffset: 1, + }); + }); +}); + +describe("get nearest empty position", () => { + test("keeps the preferred cell when it fits", () => { + expect(getNearestEmptyPosition([], 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({ + xOffset: 2, + yOffset: 1, + }); + }); + + test("chooses the nearest available cell when the preferred cell is occupied", () => { + const elements = [{ xOffset: 2, yOffset: 1, width: 1, height: 1 }]; + + expect(getNearestEmptyPosition(elements, 4, 4, { xOffset: 2, yOffset: 1 }, { width: 1, height: 1 })).toEqual({ + xOffset: 2, + yOffset: 0, + }); + }); + + test("returns undefined when the item cannot fit within a bounded section", () => { + expect(getNearestEmptyPosition([], 1, 1, { xOffset: 0, yOffset: 0 }, { width: 2, height: 1 })).toBeUndefined(); + }); }); const createElementsFromLayout = (layout: string[][]) => { diff --git a/apps/nextjs/src/components/board/items/item-actions.tsx b/apps/nextjs/src/components/board/items/item-actions.tsx index 29b5615e2..5344a2289 100644 --- a/apps/nextjs/src/components/board/items/item-actions.tsx +++ b/apps/nextjs/src/components/board/items/item-actions.tsx @@ -3,6 +3,7 @@ import { useCallback } from "react"; import { useUpdateBoard } from "@homarr/boards/updater"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; +import type { Board } from "~/app/[locale]/boards/_types"; import type { CreateItemInput } from "./actions/create-item"; import { createItemCallback } from "./actions/create-item"; import type { DuplicateItemInput } from "./actions/duplicate-item"; @@ -34,7 +35,12 @@ export const useItemActions = () => { const createItem = useCallback( (input: CreateItemInput) => { - updateBoard(createItemCallback(input)); + let nextBoard: Board | undefined; + updateBoard((previous) => { + nextBoard = createItemCallback(input)(previous); + return nextBoard; + }); + return nextBoard; }, [updateBoard], ); diff --git a/apps/nextjs/src/components/board/items/item-content.tsx b/apps/nextjs/src/components/board/items/item-content.tsx index ad0f3a305..5ddd9b613 100644 --- a/apps/nextjs/src/components/board/items/item-content.tsx +++ b/apps/nextjs/src/components/board/items/item-content.tsx @@ -1,7 +1,8 @@ -import type { MutableRefObject } from "react"; -import { useRef } from "react"; -import { Badge, Card } from "@mantine/core"; -import { useElementSize } from "@mantine/hooks"; +import type { CSSProperties, KeyboardEvent, MutableRefObject, PointerEvent } from "react"; +import { useEffect, useRef } from "react"; +import { ActionIcon, Badge, Box, Card, FocusTrap } from "@mantine/core"; +import { useElementSize, useMergedRef, useViewportSize } from "@mantine/hooks"; +import { IconMaximize, IconPin, IconX } from "@tabler/icons-react"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import combineClasses from "clsx"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; @@ -10,10 +11,14 @@ import { ErrorBoundary } from "react-error-boundary"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; import { loadWidgetDynamic, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetError } from "@homarr/widgets/errors"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { getAdvancedFocusRect } from "../advanced-focus/geometry"; import classes from "../sections/item.module.css"; import { useItemActions } from "./item-actions"; import itemContentClasses from "./item-content.module.css"; @@ -33,35 +38,160 @@ const getOverflowFromKind = (kind: SectionItem["kind"]) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => { const { ref, width, height } = useElementSize(); + const sourceRef = useRef(null); + const mergedRef = useMergedRef(ref, sourceRef); + const { width: viewportWidth, height: viewportHeight } = useViewportSize(); const board = useRequiredBoard(); + const t = useI18n(); + const [isEditMode] = useEditMode(); const widgetStateRef = useRef | null>(null); + const { active, open, close, dismiss, pin, hover, leave } = useAdvancedFocus(); + const activeFocus = active?.itemId === item.id ? active : null; + const isAdvanced = activeFocus !== null; + const advancedRect = activeFocus + ? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight }) + : null; + + // Responsive layout changes remount grid items. Never leave their focus backdrop orphaned. + useEffect(() => () => dismiss(item.id), [dismiss, item.id]); + + const openPinned = () => { + if (sourceRef.current) open(item.id, sourceRef.current, true); + }; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.currentTarget !== event.target || event.key !== "Enter" || !event.shiftKey || isEditMode) return; + event.preventDefault(); + openPinned(); + }; + const handleSurfacePointerDown = (event: PointerEvent) => { + if (!activeFocus || activeFocus.pinned) return; + + const target = + event.target instanceof Element + ? event.target.closest( + "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), iframe, [tabindex]:not([tabindex='-1']), [contenteditable='true']", + ) + : null; + const alreadyAutofocused = target?.hasAttribute("data-autofocus") ?? false; + if (target && !alreadyAutofocused) target.setAttribute("data-autofocus", "true"); + pin(false); + if (target && !alreadyAutofocused) { + window.setTimeout(() => target.removeAttribute("data-autofocus"), 250); + } + }; return ( <> - - + sourceRef.current && hover(item.id, sourceRef.current)} + onPointerLeave={() => leave(item.id)} + className={combineClasses("grid-stack-item-content", isAdvanced && advancedFocusClasses.sourcePlaceholder)} > - - +
+ + +
+ {isAdvanced && ( + close()} + > + + + )} + {isAdvanced && ( + pin(event.detail === 0)} + > + + + )} + {!isEditMode && !isAdvanced && ( + + + + )} +
+ +
+
+
+
- {item.advancedOptions.title?.trim() && ( + {!isAdvanced && item.advancedOptions.title?.trim() && ( | null>; + displayMode: "compact" | "advanced"; } const InnerContent = ({ item, ...dimensions }: InnerContentProps) => { diff --git a/apps/nextjs/src/components/board/items/item-select-modal.module.css b/apps/nextjs/src/components/board/items/item-select-modal.module.css new file mode 100644 index 000000000..79675c882 --- /dev/null +++ b/apps/nextjs/src/components/board/items/item-select-modal.module.css @@ -0,0 +1,25 @@ +.selectionCard { + overflow: hidden; +} + +.selectionActions { + opacity: 1; + transition: opacity 150ms ease; +} + +@media (hover: hover) and (pointer: fine) { + .selectionActions { + opacity: 0; + } + + .selectionCard:hover .selectionActions, + .selectionCard:focus-within .selectionActions { + opacity: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .selectionActions { + transition: none; + } +} diff --git a/apps/nextjs/src/components/board/items/item-select-modal.tsx b/apps/nextjs/src/components/board/items/item-select-modal.tsx index 2ac349c6d..d7994d77b 100644 --- a/apps/nextjs/src/components/board/items/item-select-modal.tsx +++ b/apps/nextjs/src/components/board/items/item-select-modal.tsx @@ -3,10 +3,12 @@ import { Avatar, Box, Button, Card, Center, Divider, Group, Image, Stack, Text, import { IconApi } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; +import { usePersistBoard } from "@homarr/boards/updater"; import { createId, objectEntries } from "@homarr/common"; import { getIconUrl, getIntegrationName } from "@homarr/definitions"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; import { createModal, modalSizeSelect, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { useI18n } from "@homarr/translation/client"; import { SelectGridLayout, selectGridCardHeight } from "@homarr/ui"; @@ -15,15 +17,47 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import { WidgetEditModal } from "@homarr/widgets/modals"; import { useItemActions } from "./item-actions"; +import type { BoardPlacementHint, CreateItemInput } from "./actions/create-item"; +import { createItemCallback } from "./actions/create-item"; +import classes from "./item-select-modal.module.css"; -export const ItemSelectModal = createModal(({ actions }) => { +interface ItemSelectModalProps { + placement?: BoardPlacementHint; + board: { id: string; name: string }; + persistImmediately: boolean; +} + +export const ItemSelectModal = createModal(({ actions, innerProps }) => { const [search, setSearch] = useState(""); const t = useI18n(); - const { createItem, updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); + const { createItem } = useItemActions(); const { openModal: openEditModal } = useModalAction(WidgetEditModal); const { data: integrationData } = clientApi.integration.all.useQuery(); const { data: customWidgetDefs } = clientApi.customWidget.all.useQuery(); const settings = useSettings(); + const { updateAndPersistBoard } = usePersistBoard(innerProps.board); + + const createAndPersist = (input: CreateItemInput) => { + try { + if (!innerProps.persistImmediately) { + createItem(input); + return; + } + updateAndPersistBoard(createItemCallback(input), { + onError: () => { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + }, + }); + } catch { + showErrorNotification({ + title: t("item.create.notification.error.title"), + message: t("item.create.notification.error.message"), + }); + } + }; const availableKinds = useMemo(() => new Set((integrationData ?? []).map((i) => i.kind)), [integrationData]); @@ -69,8 +103,13 @@ export const ItemSelectModal = createModal(({ actions }) => { const handleAddCustomWidget = (definitionId: string) => { const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefaultValues("customApi", settings); - createItem({ id: itemId, kind: "customApi", integrationIds: [] }); - updateItemOptions({ itemId, newOptions: { ...defaultOptions, definitionId } }); + createAndPersist({ + id: itemId, + kind: "customApi", + integrationIds: [], + options: { ...defaultOptions, definitionId }, + placement: innerProps.placement, + }); actions.closeModal(); }; @@ -88,7 +127,6 @@ export const ItemSelectModal = createModal(({ actions }) => { const itemId = createId(); const defaultOptions = reduceWidgetOptionsWithDefaultValues(kind, settings); - createItem({ id: itemId, kind, integrationIds }); actions.closeModal(); openEditModal( @@ -100,13 +138,19 @@ export const ItemSelectModal = createModal(({ actions }) => { integrationIds, }, onSuccessfulEdit: ({ options, integrationIds: newIntegrationIds, advancedOptions }) => { - updateItemOptions({ itemId, newOptions: options }); - updateItemAdvancedOptions({ itemId, newAdvancedOptions: advancedOptions }); - updateItemIntegrations({ itemId, newIntegrations: newIntegrationIds }); + createAndPersist({ + id: itemId, + kind, + options, + integrationIds: newIntegrationIds, + advancedOptions, + placement: innerProps.placement, + }); }, integrationData: matchingIntegrations, integrationSupport: hasIntegrationSupport, settings, + boardId: innerProps.board.id, }, { title: (titleT) => `${titleT("item.edit.title")} - ${titleT(`widget.${kind}.name`)}`, @@ -144,19 +188,18 @@ export const ItemSelectModal = createModal(({ actions }) => { style={{ gridColumn: "1 / -1" }} /> {filteredCustomWidgets.map((def) => ( - e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} - > + {def.iconUrl ? ( - + ) : ( )} @@ -174,9 +217,8 @@ export const ItemSelectModal = createModal(({ actions }) => { left={0} right={0} p="xs" + className={classes.selectionActions} style={{ - opacity: "var(--_hover-opacity)", - transition: "opacity 150ms ease", background: "linear-gradient(transparent, var(--mantine-color-body) 30%)", }} > @@ -223,14 +265,11 @@ const WidgetItem = ({ h={selectGridCardHeight} withBorder pos="relative" + className={classes.selectionCard} style={{ - overflow: "hidden", - "--_hover-opacity": "0", borderColor: hasMatchingIntegration ? "var(--mantine-color-blue-6)" : undefined, borderWidth: hasMatchingIntegration ? 2 : undefined, }} - onMouseEnter={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "1")} - onMouseLeave={(e) => e.currentTarget.style.setProperty("--_hover-opacity", "0")} > @@ -244,6 +283,11 @@ const WidgetItem = ({ {item.description} + {hasMatchingIntegration && ( + + {t("item.create.integrationAvailable")} + + )} diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index 8520d6b89..d57161787 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -1,35 +1,40 @@ "use client"; -import type { MutableRefObject, ReactNode } from "react"; -import { useCallback, useMemo } from "react"; +import type { MutableRefObject, ReactNode, RefObject } from "react"; +import { useCallback, useMemo, useState } from "react"; import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { IconAlertTriangle, IconCircleCheck, IconCopy, IconLayoutKanban, + IconMaximize, IconRefresh, IconSettings, IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; -import { useSession } from "@homarr/auth/client"; +import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useEditMode } from "@homarr/boards/edit-mode"; +import { usePersistBoard } from "@homarr/boards/updater"; import { useTimeAgo } from "@homarr/common"; import { useConfirmModal, useModalAction } from "@homarr/modals"; +import { showErrorNotification } from "@homarr/notifications"; import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; +import { useAdvancedFocus } from "../advanced-focus/context"; +import { useBoardPermissions } from "../permissions/client"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; import { ItemMoveModal } from "./item-move-modal"; @@ -37,13 +42,16 @@ import { ItemMoveModal } from "./item-move-modal"; interface WidgetContextMenuProps { item: SectionItem; widgetStateRef: MutableRefObject | null>; + sourceRef: RefObject; children: ReactNode; } -export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetContextMenuProps) => { +export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: WidgetContextMenuProps) => { const { data: session } = useSession(); const [isEditMode] = useEditMode(); const board = useRequiredBoard(); + const { hasChangeAccess } = useBoardPermissions(board); + const { updateAndPersistBoard } = usePersistBoard(board); const tItem = useScopedI18n("item"); const tMenu = useScopedI18n("item.menu.label"); const t = useI18n(); @@ -55,19 +63,34 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations, duplicateItem, removeItem } = useItemActions(); const { data: integrationData, isPending } = clientApi.integration.all.useQuery(); - const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const currentDefinition = useMemo(() => widgetImports[item.kind].definition, [item.kind]); const { gridstack } = useSectionContext().refs; const queryClient = useQueryClient(); + const { open: openAdvancedFocus } = useAdvancedFocus(); + const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); + const [menuOpened, setMenuOpened] = useState(false); - const widgetQueryKey = useMemo( - () => ("queryKey" in currentDefinition ? (currentDefinition.queryKey as QueryKey) : [["widget", item.kind]]), - [currentDefinition, item.kind], + const widgetQueryKeys = useMemo( + () => getWidgetQueryKeys(currentDefinition as Parameters[0]), + [currentDefinition], ); - const isWidgetFetching = useIsFetching({ queryKey: widgetQueryKey }) > 0; + const matchesWidgetQuery = useCallback( + (queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)), + [widgetQueryKeys], + ); + const isWidgetFetching = + useIsFetching({ + type: "active", + predicate: (query) => matchesWidgetQuery(query.queryKey), + }) > 0; const handleRefetch = useCallback(() => { - void queryClient.refetchQueries({ queryKey: widgetQueryKey, type: "all" }); - }, [queryClient, widgetQueryKey]); + void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" }))); + }, [queryClient, widgetQueryKeys]); + + const canInteractWithSelectedIntegrations = useMemo(() => { + const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id)); + return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id)); + }, [integrationsWithInteractAccess, item.integrationIds]); const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, @@ -76,11 +99,12 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont type OptionDef = { type: string; skipContextMenu?: boolean }; const toggleOptions = useMemo(() => { + if (!hasChangeAccess) return []; const rawOptions = currentDefinition.createOptions(settings) as unknown as Record; return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); - }, [currentDefinition, settings]); + }, [currentDefinition, hasChangeAccess, settings]); - const widgetContextActions = useMemo(() => { + const widgetContextActions = (() => { const def = currentDefinition as Record; if (typeof def.contextActions !== "function") return []; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -90,17 +114,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); }, integrationIds: item.integrationIds, - context: { isEditMode, boardId: board.id, itemId: item.id }, + context: { + isEditMode, + boardId: board.id, + itemId: item.id, + canInteractWithSelectedIntegrations, + }, widgetStateRef, }); return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; - }, [currentDefinition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); + })(); const persistBoard = useCallback( - (updatedItems: typeof board.items) => { - saveBoard({ ...board, items: updatedItems }); + (updater: (previous: typeof board) => typeof board) => { + updateAndPersistBoard(updater, { + onError: () => { + showErrorNotification({ + title: t("item.menu.notification.saveError.title"), + message: t("item.menu.notification.saveError.message"), + }); + }, + }); }, - [board, saveBoard], + [t, updateAndPersistBoard], ); const openEditModal = useCallback(() => { @@ -113,12 +149,14 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont integrationIds: item.integrationIds, }, onSuccessfulEdit: (editResult) => { - updateItemOptions({ itemId: item.id, newOptions: editResult.options }); - updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); - updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); - if (!isEditMode) { - persistBoard( - board.items.map((boardItem) => + if (isEditMode) { + updateItemOptions({ itemId: item.id, newOptions: editResult.options }); + updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); + updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); + } else { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => boardItem.id !== item.id ? boardItem : { @@ -128,7 +166,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont integrationIds: editResult.integrationIds, }, ), - ); + })); } }, integrationData: (integrationData ?? []).filter( @@ -154,18 +192,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont settings, isEditMode, persistBoard, - board, ]); const handleToggle = useCallback( (key: string) => (checked: boolean) => { - const newOptions = { ...options, [key]: checked }; - updateItemOptions({ itemId: item.id, newOptions }); - persistBoard( - board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })), - ); + if (isEditMode) { + updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } }); + } else { + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } }, + ), + })); + } }, - [options, item.id, updateItemOptions, persistBoard, board], + [options, item.id, updateItemOptions, isEditMode, persistBoard], ); if (!session) return <>{children}; @@ -174,9 +216,28 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); return ( - + {children} + } + onClick={() => { + if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current, true); + }} + disabled={isEditMode} + > + {t("item.advancedFocus.open")} + + {toggleOptions.length > 0 && ( <> {tMenu("options")} @@ -248,20 +309,22 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont {tMenu("refresh")} - } - onClick={openEditModal} - disabled={isPending} - > - {tMenu("settings")} - + {hasChangeAccess && ( + } + onClick={openEditModal} + disabled={isPending} + > + {tMenu("settings")} + + )} {isEditMode && ( @@ -290,16 +353,20 @@ export const WidgetContextMenu = ({ item, widgetStateRef, children }: WidgetCont interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKey: QueryKey; + queryKeys: readonly QueryKey[]; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQueryStatusProps) => { - const queries = queryClient.getQueryCache().findAll({ queryKey }); +const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { + const queries = queryClient.getQueryCache().findAll({ + type: "active", + predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), + }); const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; - const ageLabel = useTimeAgo(new Date(latest)); + const ageLabel = useTimeAgo(new Date(latest || Date.now())); + const ageSuffix = latest > 0 ? ` · ${ageLabel}` : ""; if (isFetching) { return ( @@ -334,7 +401,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.error")} · {ageLabel} + {t("item.menu.status.error")} + {ageSuffix} @@ -345,7 +413,8 @@ const WidgetQueryStatus = ({ queryClient, queryKey, isFetching, t }: WidgetQuery - {t("item.menu.status.success")} · {ageLabel} + {t("item.menu.status.success")} + {ageSuffix} ); diff --git a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css index 89b3ba93e..f6ec2600f 100644 --- a/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css +++ b/apps/nextjs/src/components/board/items/widget-hover-overlay.module.css @@ -13,10 +13,8 @@ transform 150ms ease; } -:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) - .wrapper, -:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) - .wrapper { +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper, +:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper { opacity: 1; transform: translateY(0); } diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx new file mode 100644 index 000000000..606bc5ece --- /dev/null +++ b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx @@ -0,0 +1,177 @@ +"use client"; + +import type { KeyboardEvent, MouseEvent, PointerEvent, PropsWithChildren } from "react"; +import { useEffect, useRef, useState } from "react"; +import type { BoxProps } from "@mantine/core"; +import { Box, Menu } from "@mantine/core"; +import { IconPlus } from "@tabler/icons-react"; + +import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context"; +import { useEditMode } from "@homarr/boards/edit-mode"; +import { useModalAction } from "@homarr/modals"; +import { useSettings } from "@homarr/settings"; +import { useI18n } from "@homarr/translation/client"; + +import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; +import { ItemSelectModal } from "../../items/item-select-modal"; +import { useBoardPermissions } from "../../permissions/client"; +import type { BoardPlacementHint } from "../../items/actions/create-item"; +import type { UseGridstackRefs } from "./use-gridstack"; + +interface MenuState { + x: number; + y: number; + placement: BoardPlacementHint; +} + +interface Props extends BoxProps { + section: Exclude | DynamicSectionItem; + refs: UseGridstackRefs; +} + +export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren) => { + const board = useRequiredBoard(); + const currentLayoutId = useCurrentLayout(); + const settings = useSettings(); + const t = useI18n(); + const { hasChangeAccess } = useBoardPermissions(board); + const { openModal } = useModalAction(ItemSelectModal); + const [isEditMode] = useEditMode(); + const [menu, setMenu] = useState(null); + const longPressTimer = useRef | null>(null); + const returnFocusRef = useRef(null); + + const enabled = hasChangeAccess && settings.enableRightClickOnWidgets; + const clearLongPress = () => { + if (longPressTimer.current === null) return; + clearTimeout(longPressTimer.current); + longPressTimer.current = null; + }; + + useEffect( + () => () => { + if (longPressTimer.current !== null) clearTimeout(longPressTimer.current); + }, + [], + ); + const resolvePlacement = (target: EventTarget | null, pageX: number, pageY: number) => { + const wrapper = refs.wrapperElement.current; + const gridstack = refs.gridstack.current; + if (!(target instanceof Element) || !wrapper || !gridstack) return null; + if (target.closest(".grid-stack") !== wrapper || target.closest(".grid-stack-item")) return null; + + const cell = gridstack.getCellFromPixel({ left: pageX, top: pageY }, true); + const rowLimit = section.kind === "dynamic" ? section.height : undefined; + if ( + cell.x < 0 || + cell.y < 0 || + cell.x >= gridstack.getColumn() || + (rowLimit !== undefined && cell.y >= rowLimit) || + !gridstack.isAreaEmpty(cell.x, cell.y, 1, 1) + ) { + return null; + } + + return { sectionId: section.id, layoutId: currentLayoutId, xOffset: cell.x, yOffset: cell.y }; + }; + + const handleContextMenu = (event: MouseEvent) => { + if (!enabled) return; + const placement = resolvePlacement(event.target, event.pageX, event.pageY); + if (!placement) return; + event.preventDefault(); + event.stopPropagation(); + setMenu({ x: event.clientX, y: event.clientY, placement }); + }; + + const handlePointerDown = (event: PointerEvent) => { + if (!enabled || event.pointerType !== "touch") return; + const placement = resolvePlacement(event.target, event.pageX, event.pageY); + if (!placement) return; + clearLongPress(); + longPressTimer.current = setTimeout(() => { + setMenu({ x: event.clientX, y: event.clientY, placement }); + navigator.vibrate?.(20); + }, 500); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if ( + !enabled || + event.currentTarget !== event.target || + (event.key !== "ContextMenu" && !(event.shiftKey && event.key === "F10")) + ) { + return; + } + const wrapper = event.currentTarget; + const gridstack = refs.gridstack.current; + if (!gridstack) return; + + const rowCount = Math.max(1, section.kind === "dynamic" ? section.height : gridstack.getRow() + 1); + for (let yOffset = 0; yOffset < rowCount; yOffset++) { + for (let xOffset = 0; xOffset < gridstack.getColumn(); xOffset++) { + if (!gridstack.isAreaEmpty(xOffset, yOffset, 1, 1)) continue; + const rect = wrapper.getBoundingClientRect(); + event.preventDefault(); + returnFocusRef.current = wrapper; + setMenu({ + x: rect.left + 24, + y: rect.top + 24, + placement: { sectionId: section.id, layoutId: currentLayoutId, xOffset, yOffset }, + }); + return; + } + } + }; + + return ( + <> + + {children} + + { + if (!opened) { + setMenu(null); + const returnFocus = returnFocusRef.current; + returnFocusRef.current = null; + requestAnimationFrame(() => returnFocus?.focus()); + } + }} + withinPortal + position="bottom-start" + > + + + + + } + onClick={() => { + returnFocusRef.current = null; + if (menu) { + openModal({ placement: menu.placement, board, persistImmediately: !isEditMode }); + } + setMenu(null); + }} + > + {t("item.create.addAtPosition")} + + + + + ); +}; diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx index e851e5c2a..6934008f5 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx +++ b/apps/nextjs/src/components/board/sections/gridstack/gridstack.tsx @@ -1,13 +1,13 @@ "use client"; import type { BoxProps } from "@mantine/core"; -import { Box } from "@mantine/core"; import combineClasses from "clsx"; import type { DynamicSectionItem, Section } from "~/app/[locale]/boards/_types"; import { SectionContent } from "../content"; import { SectionProvider } from "../section-context"; import { useSectionItems } from "../use-section-items"; +import { BoardEmptySpaceContextMenu } from "./board-empty-space-context-menu"; import { useGridstack } from "./use-gridstack"; interface Props extends BoxProps { @@ -22,15 +22,16 @@ export const GridStack = ({ section, ...props }: Props) => { return ( - - + ); }; diff --git a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts index b5e468de6..0c593b0a1 100644 --- a/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts +++ b/apps/nextjs/src/components/board/sections/gridstack/use-gridstack.ts @@ -14,6 +14,7 @@ import { initializeGridstack } from "./init-gridstack"; export interface UseGridstackRefs { wrapper: Ref; + wrapperElement: RefObject; items: RefObject>>; gridstack: RefObject; } @@ -287,6 +288,7 @@ export const useGridstack = (section: Omit, itemIds: string[]) refs: { items: itemRefs, wrapper: wrapperRef, + wrapperElement: wrapperElementRef, gridstack: gridRef, }, }; diff --git a/apps/nextjs/src/components/layout/constants.ts b/apps/nextjs/src/components/layout/constants.ts new file mode 100644 index 000000000..2799f8304 --- /dev/null +++ b/apps/nextjs/src/components/layout/constants.ts @@ -0,0 +1 @@ +export const appShellHeaderHeight = 60; diff --git a/apps/nextjs/src/components/layout/shell.tsx b/apps/nextjs/src/components/layout/shell.tsx index b51cf3220..56001cafb 100644 --- a/apps/nextjs/src/components/layout/shell.tsx +++ b/apps/nextjs/src/components/layout/shell.tsx @@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core"; import { useAtomValue } from "jotai"; import { useOptionalBackgroundProps } from "./background"; +import { appShellHeaderHeight } from "./constants"; import { navigationCollapsedAtom } from "./header/burger"; interface ClientShellProps { @@ -23,7 +24,7 @@ export const ClientShell = ({ return ( { + let browser: Browser; + let baseUrl: string; + let stopContainerAsync: (() => Promise) | undefined; + + beforeAll(async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials); + await seedInteractionBoardAsync(db, ownerId); + await seedCredentialsUserAsync(db, { + ...disabledModifierCredentials, + enableRightClickOnWidgets: false, + boardPermission: "modify", + }); + await seedCredentialsUserAsync(db, { + ...viewerCredentials, + enableRightClickOnWidgets: true, + boardPermission: "view", + }); + + const homarrContainer = await createHomarrContainer({ + environment: { AUTH_PROVIDERS: "credentials" }, + mounts: { "/appdata": localMountPath }, + }).start(); + stopContainerAsync = async () => { + await homarrContainer.stop(); + }; + baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + browser = await chromium.launch(); + }, 120_000); + + afterAll(async () => { + await browser?.close(); + await stopContainerAsync?.(); + }); + + test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const compactSurface = widget.locator(".clock-wrapper"); + const advancedDialog = page.getByRole("dialog", { name: "Date and time advanced view" }); + + await compactSurface.evaluate((element) => { + element.setAttribute("data-lifecycle-probe", "same-instance"); + }); + await widget.focus(); + await page.keyboard.down("Shift"); + await widget.hover(); + await expect(advancedDialog).toBeVisible({ timeout: 2_000 }); + await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(widget).toBeFocused(); + await page.keyboard.up("Shift"); + await expect(advancedDialog).toBeHidden(); + await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance"); + await expect(widget).toBeFocused(); + + await page.keyboard.down("Shift"); + await widget.hover(); + await expect(advancedDialog).toBeVisible({ timeout: 2_000 }); + await page.getByRole("button", { name: "Keep advanced view open" }).click(); + const pinnedButton = page.getByRole("button", { name: "Advanced view is pinned" }); + await expect(pinnedButton).toBeFocused(); + await page.keyboard.up("Shift"); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Shift+Tab"); + await expect(advancedDialog).toBeVisible(); + await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + await expect(widget).toBeFocused(); + + await page.keyboard.press("Shift+Enter"); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + await expect(widget).toBeFocused(); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(advancedDialog).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(advancedDialog).toBeHidden(); + } finally { + await context.close(); + } + }, 60_000); + + test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first(); + const iframe = iframeSlot.locator("iframe"); + await iframe.evaluate((element) => { + element.srcdoc = ''; + }); + const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe"); + await frame.locator("#state").fill("changed"); + + await iframeSlot.focus(); + await page.keyboard.press("Shift+Enter"); + await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeVisible(); + await expect(frame.locator("#state")).toHaveValue("changed"); + await page.keyboard.press("Escape"); + await expect(frame.locator("#state")).toHaveValue("changed"); + + await iframeSlot.focus(); + await page.keyboard.press("Shift+Enter"); + await page.setViewportSize({ width: 800, height: 900 }); + await expect(page.getByRole("dialog", { name: "iFrame advanced view" })).toBeHidden(); + await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible(); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps advanced controls below the fixed header on short screens", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 }); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + await widget.focus(); + await page.keyboard.press("Shift+Enter"); + const closeButton = page.getByRole("button", { name: "Close advanced view" }); + const bounds = await closeButton.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + expect(bounds.x).toBeGreaterThanOrEqual(0); + expect(bounds.y).toBeGreaterThanOrEqual(60); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(768); + await closeButton.click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden(); + } finally { + await context.close(); + } + }, 60_000); + + test("opens the add-at-position chooser only when right click and change access are enabled", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials); + + try { + const grid = page.locator(".grid-stack[data-kind='empty']"); + const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" }); + await expect(grid).toHaveAttribute("aria-label", "Add item here"); + + await grid.focus(); + await page.keyboard.press("Shift+F10"); + await expect(addHereMenuItem).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(addHereMenuItem).toBeHidden(); + await expect(grid).toBeFocused(); + + await touchAndHoldEmptyGridSpaceAsync(grid); + await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 }); + await page.keyboard.press("Escape"); + + await rightClickEmptyGridSpaceAsync(grid); + await addHereMenuItem.click(); + await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); + } finally { + await context.close(); + } + }, 60_000); + + test("honors the user setting gate even when the user can modify the board", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const grid = page.locator(".grid-stack[data-kind='empty']"); + + await widget.click({ button: "right" }); + await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0); + await expect(grid).not.toHaveAttribute("aria-label", "Add item here"); + await rightClickEmptyGridSpaceAsync(grid); + await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0); + } finally { + await context.close(); + } + }, 60_000); + + test("keeps read-only advanced viewing but blocks add-at-position without change access", async () => { + const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials); + + try { + const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first(); + const grid = page.locator(".grid-stack[data-kind='empty']"); + + await widget.click({ button: "right" }); + await page.getByRole("menuitem", { name: "Open advanced view" }).click(); + await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible(); + await page.keyboard.press("Escape"); + + await expect(grid).not.toHaveAttribute("aria-label", "Add item here"); + await rightClickEmptyGridSpaceAsync(grid); + await expect(page.getByRole("menuitem", { name: "Add item here" })).toHaveCount(0); + } finally { + await context.close(); + } + }, 60_000); +}); + +const openBoardAsync = async ( + browser: Browser, + baseUrl: string, + credentials: { username: string; password: string }, + viewport = { width: 1280, height: 900 }, +): Promise<{ context: BrowserContext; page: Page }> => { + const context = await browser.newContext({ reducedMotion: "reduce", viewport }); + const page = await context.newPage(); + + await page.goto(`${baseUrl}/auth/login`); + await page.getByLabel("Username").fill(credentials.username); + await page.locator("#password").fill(credentials.password); + await page.locator("css=button[type='submit']").click(); + await page.waitForURL(baseUrl, { timeout: 15_000 }); + await page.goto(`${baseUrl}/boards/${boardName}`); + await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({ + timeout: 15_000, + }); + + return { context, page }; +}; + +const rightClickEmptyGridSpaceAsync = async (grid: Locator) => { + const bounds = await grid.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + + await grid.click({ + button: "right", + position: { x: bounds.width - 16, y: Math.min(24, bounds.height / 2) }, + }); +}; + +const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => { + const bounds = await grid.boundingBox(); + expect(bounds).not.toBeNull(); + if (!bounds) return; + + const clientX = bounds.x + bounds.width - 16; + const clientY = bounds.y + Math.min(24, bounds.height / 2); + await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY }); + await new Promise((resolve) => setTimeout(resolve, 550)); + await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY }); +}; + +const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => { + const boardId = createId(); + const baseLayoutId = createId(); + const desktopLayoutId = createId(); + const sectionId = createId(); + const clockItemId = createId(); + const iframeItemId = createId(); + + await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false }); + await db.insert(layouts).values([ + { id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId }, + { id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId }, + ]); + await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId }); + await db.insert(items).values([ + { + id: clockItemId, + kind: "clock", + boardId, + options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + { + id: iframeItemId, + kind: "iframe", + boardId, + options: stringify({ embedUrl: "https://example.com", allowScrolling: true }), + advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }), + }, + ]); + await db.insert(itemLayouts).values( + [baseLayoutId, desktopLayoutId].flatMap((layoutId) => [ + { + itemId: clockItemId, + sectionId, + layoutId, + xOffset: 0, + yOffset: 0, + width: 4, + height: 3, + }, + { + itemId: iframeItemId, + sectionId, + layoutId, + xOffset: 4, + yOffset: 0, + width: 4, + height: 3, + }, + ]), + ); + await db + .update(users) + .set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true }) + .where(eq(users.id, ownerId)); +}; + +const seedCredentialsUserAsync = async ( + db: SqliteDatabase, + input: { + username: string; + password: string; + enableRightClickOnWidgets: boolean; + boardPermission: "view" | "modify"; + }, +) => { + const userId = createId(); + const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) }); + if (!board) throw new Error("Interaction board was not seeded"); + + await db.insert(users).values({ + id: userId, + name: input.username, + provider: "credentials", + password: await hashPasswordAsync(input.password), + enableRightClickOnWidgets: input.enableRightClickOnWidgets, + homeBoardId: board.id, + completedBoardTour: true, + completedManageTour: true, + }); + await db.insert(boardUserPermissions).values({ + boardId: board.id, + userId, + permission: input.boardPermission, + }); +}; diff --git a/packages/api/src/router/test/widgets/patchmon.spec.ts b/packages/api/src/router/test/widgets/patchmon.spec.ts index df37b5647..27f5cfe40 100644 --- a/packages/api/src/router/test/widgets/patchmon.spec.ts +++ b/packages/api/src/router/test/widgets/patchmon.spec.ts @@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => { }); const caller = createCaller(db, null); - await expect(caller.getStats({ integrationId })).rejects.toThrow( - new TRPCError({ code: "UNAUTHORIZED" }), - ); + await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" })); }); test("should throw FORBIDDEN for authenticated users without board or integration access", async () => { diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts new file mode 100644 index 000000000..7933b0d16 --- /dev/null +++ b/packages/api/src/router/test/widgets/timetable.spec.ts @@ -0,0 +1,114 @@ +import { stringify } from "superjson"; +import { describe, expect, it, vi } from "vitest"; + +import type { Session } from "@homarr/auth"; +import { createId } from "@homarr/common"; +import type { Database } from "@homarr/db"; +import { boards, items, users } from "@homarr/db/schema"; +import { createDb } from "@homarr/db/test"; + +import { timetableRouter } from "../../widgets/timetable"; + +const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() })); + +vi.mock("@homarr/request-handler/timetable", () => ({ + timetableGetTimetableRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.getTimetable(input); + return { data: { stationId: "station", timestamp: new Date(0), entries: [] } }; + }, + }), + }, + timetableSearchStationsRequestHandler: { + handler: (input: unknown) => ({ + getDataAsync: async () => { + mocks.searchStations(input); + return { data: [] }; + }, + }), + }, +})); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const createCaller = (db: Database, session: Session | null) => + timetableRouter.createCaller({ db, deviceType: undefined, session }); + +const createTimetableItemAsync = async ( + db: Database, + { baseUrl, isPublic = true }: { baseUrl: string; isPublic?: boolean }, +) => { + const ownerId = createId(); + const boardId = createId(); + const itemId = createId(); + await db.insert(users).values({ id: ownerId }); + await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic }); + await db.insert(items).values({ + id: itemId, + boardId, + kind: "timetable", + options: stringify({ baseUrl }), + }); + return { boardId, itemId, ownerId }; +}; + +describe("timetableRouter source authorization", () => { + it("allows itemless setup only for the fixed default provider", async () => { + const caller = createCaller(createDb(), null); + + await expect(caller.searchStations({ baseUrl: "https://search.ch", query: "Bern" })).resolves.toEqual([]); + await expect(caller.searchStations({ baseUrl: "http://127.0.0.1:8080", query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); + + it("loads a persisted private endpoint only through a viewable timetable item", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl }); + const caller = createCaller(db, null); + + await caller.getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }); + expect(mocks.getTimetable).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl })); + await expect( + caller.getTimetable({ baseUrl: "http://127.0.0.1:8080", itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "FORBIDDEN" }); + }); + + it("rejects a persisted endpoint when its board is not viewable", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { itemId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false }); + + await expect( + createCaller(db, null).getTimetable({ baseUrl, itemId, stationId: "station", limit: 10 }), + ).rejects.toMatchObject({ code: "NOT_FOUND" }); + }); + + it("allows board modifiers to search an unsaved self-hosted endpoint", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false }); + const caller = createCaller(db, createSession(ownerId)); + + await caller.searchStations({ baseUrl, boardId, query: "Bern" }); + expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl })); + }); + + it("does not treat public board view access as configuration access", async () => { + const db = createDb(); + const baseUrl = "http://timetable.internal:8080"; + const { boardId, itemId } = await createTimetableItemAsync(db, { baseUrl }); + + await expect(createCaller(db, null).searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({ + code: "NOT_FOUND", + }); + await expect(createCaller(db, null).searchStations({ baseUrl, itemId, query: "Bern" })).rejects.toMatchObject({ + code: "FORBIDDEN", + }); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts new file mode 100644 index 000000000..905ac00a4 --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; + +import { everyBeszelIntegrationFailed } from "../beszel-failures"; + +describe("everyBeszelIntegrationFailed", () => { + it("keeps partial successes", () => { + expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false); + }); + + it("detects when every integration failed", () => { + expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true); + }); + + it("allows an empty integration selection", () => { + expect(everyBeszelIntegrationFailed([])).toBe(false); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts new file mode 100644 index 000000000..12fbe5778 --- /dev/null +++ b/packages/api/src/router/widgets/__tests__/custom-api-security.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security"; + +describe("custom API request hardening", () => { + it("accepts HTTP endpoints without embedded credentials", () => { + expect(validateCustomApiUrl("https://api.example.com/v1/data").hostname).toBe("api.example.com"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com/data"])("rejects unsafe URL %s", (url) => { + expect(() => validateCustomApiUrl(url)).toThrow(); + }); + + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedJsonResponseAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/api/src/router/widgets/beszel-failures.ts b/packages/api/src/router/widgets/beszel-failures.ts new file mode 100644 index 000000000..e7d75f87d --- /dev/null +++ b/packages/api/src/router/widgets/beszel-failures.ts @@ -0,0 +1,5 @@ +export const everyBeszelIntegrationFailed = (results: readonly unknown[]) => + results.length > 0 && + results.every( + (result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined, + ); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 738adecf7..1380bc405 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -13,6 +13,7 @@ import { import { settleIntegrationQueries } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { everyBeszelIntegrationFailed } from "./beszel-failures"; import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); @@ -20,6 +21,20 @@ const MAX_PENDING_LIVE_EVENTS = 4; const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); +function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) { + if (everyBeszelIntegrationFailed(results)) { + const firstError = results[0]; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Every Beszel integration failed while loading ${operation}: ${ + typeof firstError === "object" && firstError !== null && "error" in firstError + ? String(firstError.error) + : "unknown error" + }`, + }); + } +} + export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); + throwIfEveryBeszelIntegrationFailed(results, "systems"); return results; }), @@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); + throwIfEveryBeszelIntegrationFailed(results, "alerts"); return results; }), diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 729e82634..68d968412 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -1,16 +1,13 @@ import { z } from "zod/v4"; -import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { radarrReleaseTypes } from "@homarr/integrations/types"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -const logger = createLogger({ module: "calendarRouter" }); - export const calendarRouter = createTRPCRouter({ findAllEvents: publicProcedure .meta({ @@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - const settled = await Promise.allSettled( - ctx.integrations.map(async (integration) => { - const { integrationIds: _integrationIds, ...handlerInput } = input; - const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); - const { data } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries(ctx.integrations, async (integration) => { + const { integrationIds: _integrationIds, ...handlerInput } = input; + const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); + const { data } = await innerHandler.getDataAsync(); - return { - events: data, - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - }; - }), - ); - - return settled.flatMap((result, index) => { - if (result.status === "fulfilled") { - return [result.value]; - } - const integration = ctx.integrations[index]; - logger.warn( - new ErrorWithMetadata( - "Calendar integration request failed; skipping events for this integration", - { - integrationId: integration?.id, - integrationKind: integration?.kind, - }, - { cause: result.reason }, - ), - ); - return []; + return { + events: data, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + }; }); }), }); diff --git a/packages/api/src/router/widgets/custom-api-security.ts b/packages/api/src/router/widgets/custom-api-security.ts new file mode 100644 index 000000000..ec2d5c81f --- /dev/null +++ b/packages/api/src/router/widgets/custom-api-security.ts @@ -0,0 +1,21 @@ +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024; + +export function validateCustomApiUrl(urlString: string): URL { + if (urlString.length > 2_048) throw new Error("Custom API URL is too long"); + + const url = new URL(urlString); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Custom API URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Custom API URL must not contain credentials"); + return url; +} + +export async function readBoundedJsonResponseAsync( + response: Response, + maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Custom API response"); +} diff --git a/packages/api/src/router/widgets/custom-api.ts b/packages/api/src/router/widgets/custom-api.ts index 09fafa880..a306e71ca 100644 --- a/packages/api/src/router/widgets/custom-api.ts +++ b/packages/api/src/router/widgets/custom-api.ts @@ -9,13 +9,12 @@ import { createLogger } from "@homarr/core/infrastructure/logs"; import { createTRPCRouter, protectedProcedure } from "../../trpc"; import { applyAuth } from "../custom-widget/auth"; import { extractActionButtonDisplay, extractDisplayDataWithFallback } from "../custom-widget/display-data"; +import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "./custom-api-security"; const logger = createLogger({ module: "widget:customApi" }); const FETCH_TIMEOUT_MS = 10_000; -const validateUrl = (urlString: string): URL => new URL(urlString); - export const customApiRouter = createTRPCRouter({ getData: protectedProcedure.input(z.object({ definitionId: z.string() })).query(async ({ ctx, input }) => { const definition = await ctx.db.query.customWidgetDefinitions.findFirst({ @@ -47,7 +46,7 @@ export const customApiRouter = createTRPCRouter({ value: decryptSecret(s.value), })); - const url = validateUrl(definition.url); + const url = validateCustomApiUrl(definition.url); const headers = new Headers({ Accept: "application/json" }); if (definition.method !== "GET" && definition.requestBody) { @@ -64,7 +63,7 @@ export const customApiRouter = createTRPCRouter({ method: definition.method, headers, body: definition.method !== "GET" ? definition.requestBody : undefined, - redirect: "follow", + redirect: "error", signal: controller.signal, }); @@ -75,7 +74,7 @@ export const customApiRouter = createTRPCRouter({ }); } - const json: unknown = await response.json(); + const json = await readBoundedJsonResponseAsync(response); return extractDisplayDataWithFallback(json, definition.displayType, displayConfig); } catch (error) { if (error instanceof TRPCError) throw error; diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts index 08951da7c..fdeb77831 100644 --- a/packages/api/src/router/widgets/firewall.ts +++ b/packages/api/src/router/widgets/firewall.ts @@ -18,20 +18,33 @@ const queryFirewall = < }, >( handler: THandler, + createFallback: () => Awaited["getDataAsync"]>>["data"], ) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => - settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - summary: data as Awaited["getDataAsync"]>>["data"], - }; - }), + settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + summary: data as Awaited["getDataAsync"]>>["data"], + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) }, + summary: createFallback(), + error: error instanceof Error ? error.message : "Integration request failed", + }), + throwOnAllFailures: true, + }, + ), ); export const firewallRouter = createTRPCRouter({ - getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), - getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), - getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), - getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), + getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })), + getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []), + getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })), + getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })), }); diff --git a/packages/api/src/router/widgets/indexer-manager.ts b/packages/api/src/router/widgets/indexer-manager.ts index 54a949dab..fe411d9be 100644 --- a/packages/api/src/router/widgets/indexer-manager.ts +++ b/packages/api/src/router/widgets/indexer-manager.ts @@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({ getIndexersStatus: publicProcedure .concat(createIndexerManagerIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = indexerManagerRequestHandler.handler(integration, {}); - const { data: indexers } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = indexerManagerRequestHandler.handler(integration, {}); + const { data: indexers } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - indexers, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + indexers, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + indexers: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), testAllIndexers: protectedProcedure .concat(createIndexerManagerIntegrationMiddleware("interact")) diff --git a/packages/api/src/router/widgets/media-organizer.ts b/packages/api/src/router/widgets/media-organizer.ts index dd0d687cb..2f38423f1 100644 --- a/packages/api/src/router/widgets/media-organizer.ts +++ b/packages/api/src/router/widgets/media-organizer.ts @@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const mediaOrganizerRouter = createTRPCRouter({ @@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .query(async ({ ctx, input }) => { - const results = await Promise.all( - ctx.integrations.map(async (integration) => { + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize }); const { data } = await innerHandler.getDataAsync(); return { integrationId: integration.id, + integrationName: integration.name, ...data, + error: undefined as string | undefined, }; - }), + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + missing: [], + missingCount: 0, + queued: [], + queuedCount: 0, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, ); - return results; }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index fbd192499..eb13b25d1 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({ data.map((request) => ({ ...request, integrationId: integration.id, + integration, })), ) .toSorted((dataA, dataB) => { @@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({ } as const; await answerActions[input.answer](input.requestId); + return { success: true }; }), }); diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 591d58d2b..80ce9018d 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({ const { data } = await mediaServerRequestHandler .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) .getDataAsync(); - return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; + return { + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: data, + }; }); }), }); diff --git a/packages/api/src/router/widgets/notifications.ts b/packages/api/src/router/widgets/notifications.ts index bae8ba9fd..86124164c 100644 --- a/packages/api/src/router/widgets/notifications.ts +++ b/packages/api/src/router/widgets/notifications.ts @@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({ getNotifications: publicProcedure .unstable_concat(createNotificationsIntegrationMiddleware("query")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = notificationsRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = notificationsRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - data, - }; - }); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + data: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts index 5e43efe3e..56fa10fd1 100644 --- a/packages/api/src/router/widgets/rssFeed.ts +++ b/packages/api/src/router/widgets/rssFeed.ts @@ -34,6 +34,11 @@ export const rssFeedRouter = createTRPCRouter({ }), ); + const failures = settled.filter((result) => result.status === "rejected"); + if (settled.length > 0 && failures.length === settled.length) { + throw failures[0]?.reason; + } + return settled .flatMap((result, index) => { if (result.status === "fulfilled") return result.value.data.entries; diff --git a/packages/api/src/router/widgets/smart-home.ts b/packages/api/src/router/widgets/smart-home.ts index c37468752..a0f38c7fa 100644 --- a/packages/api/src/router/widgets/smart-home.ts +++ b/packages/api/src/router/widgets/smart-home.ts @@ -1,8 +1,12 @@ import { z } from "zod/v4"; +import { TRPCError } from "@trpc/server"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; -import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smart-home-entity-state"; +import { + smartHomeEntityStateRequestHandler, + toSafeEntityDetails, +} from "@homarr/request-handler/smart-home-entity-state"; import type { IntegrationAction } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration"; @@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({ .query(async ({ ctx: { integration }, input }) => { const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const { data } = await innerHandler.getDataAsync(); - return data; + return data.state; + }), + entityDetails: publicProcedure + .meta({ + mcp: { + enabled: true, + description: + "Get the state, attributes, and update timestamps for a Home Assistant entity. REQUIRED: integrationId (Home Assistant integration ID), entityId (for example 'sensor.temperature')", + }, + }) + .input(z.object({ entityId: z.string() })) + .concat(createSmartHomeIntegrationMiddleware("query")) + .query(async ({ ctx: { integration }, input }) => { + const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); + const { data } = await innerHandler.getDataAsync(); + return toSafeEntityDetails(data); }), switchEntity: protectedProcedure .meta({ @@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({ .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); const success = await client.triggerToggleAsync(input.entityId); - - return success; + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" }); + } + return true; }), executeAutomation: protectedProcedure .meta({ @@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({ .input(z.object({ automationId: z.string() })) .mutation(async ({ ctx: { integration }, input }) => { const client = await createIntegrationAsync(integration); - await client.triggerAutomationAsync(input.automationId); + const success = await client.triggerAutomationAsync(input.automationId); + if (!success) { + throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not execute the automation" }); + } + return true; }), }); diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts index ef60ce320..84f680382 100644 --- a/packages/api/src/router/widgets/timetable.ts +++ b/packages/api/src/router/widgets/timetable.ts @@ -1,36 +1,90 @@ +import { TRPCError } from "@trpc/server"; +import { parse } from "superjson"; import z from "zod"; +import type { Session } from "@homarr/auth"; +import type { Database } from "@homarr/db"; +import { and, eq } from "@homarr/db"; +import { boards, items } from "@homarr/db/schema"; import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler, } from "@homarr/request-handler/timetable"; +import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { throwIfActionForbiddenAsync } from "../board/board-access"; -const baseUrlSchema = z.string().url(); +const baseUrlSchema = z.string().url().max(2_048); +const timetableSourceSchema = z.object({ + baseUrl: baseUrlSchema, + itemId: z.string().max(100).optional(), + boardId: z.string().max(100).optional(), +}); + +const resolveTimetableBaseUrlAsync = async ( + ctx: { db: Database; session: Session | null }, + input: z.infer, + allowBoardConfiguration = false, +) => { + const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl); + if (allowBoardConfiguration) { + if (input.boardId) { + await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify"); + return requestedBaseUrl; + } + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" }); + } + return DEFAULT_TIMETABLE_BASE_URL; + } + if (!input.itemId) { + if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) { + throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" }); + } + return DEFAULT_TIMETABLE_BASE_URL; + } + + const item = await ctx.db.query.items.findFirst({ + where: and(eq(items.id, input.itemId), eq(items.kind, "timetable")), + }); + if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" }); + + await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view"); + const options = parse>(item.options); + const savedBaseUrl = normalizeTimetableBaseUrl( + typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL, + ); + if (requestedBaseUrl !== savedBaseUrl) { + throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" }); + } + return savedBaseUrl; +}; export const timetableRouter = createTRPCRouter({ getTimetable: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - stationId: z.string(), + timetableSourceSchema.extend({ + stationId: z.string().min(1).max(200), limit: z.number().int().min(1).max(100), }), ) - .query(async ({ input }) => { - const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync(); return data; }), searchStations: publicProcedure .input( - z.object({ - baseUrl: baseUrlSchema, - query: z.string(), + timetableSourceSchema.extend({ + query: z.string().max(200), }), ) - .query(async ({ input }) => { - const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + .query(async ({ ctx, input }) => { + const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true); + const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input; + const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync(); return data; }), }); diff --git a/packages/api/src/router/widgets/vpn.ts b/packages/api/src/router/widgets/vpn.ts index 407e4986f..79ee5308b 100644 --- a/packages/api/src/router/widgets/vpn.ts +++ b/packages/api/src/router/widgets/vpn.ts @@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({ getSummaries: publicProcedure .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); - return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, - summary: data, - }; - }); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); + return { + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: timestamp, + }, + summary: data as typeof data | null, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + updatedAt: new Date(0), + }, + summary: null, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts index a9dbff5ba..773117877 100644 --- a/packages/api/src/settle-integrations.ts +++ b/packages/api/src/settle-integrations.ts @@ -11,6 +11,7 @@ interface IntegrationLike { interface Options { fallback?: (integration: TIntegration, error: unknown) => TResult; + throwOnAllFailures?: boolean; } export async function settleIntegrationQueries( @@ -37,15 +38,18 @@ export async function settleIntegrationQueries 0) { + if ( + errors.length > 0 && + (results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length)) + ) { throw errors[0]; } diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts index 09f80bc31..70024da7f 100644 --- a/packages/api/src/test/settle-integrations.spec.ts +++ b/packages/api/src/test/settle-integrations.spec.ts @@ -47,4 +47,21 @@ describe("settleIntegrationQueries", () => { }), ).resolves.toEqual(["First", "Second"]); }); + + test("can retain partial fallbacks while rejecting total failure", async () => { + const error = new Error("Integration unavailable"); + const options = { + fallback: (integration: (typeof integrations)[number]) => integration.name, + throwOnAllFailures: true, + }; + + await expect( + settleIntegrationQueries( + integrations, + (integration) => (integration.id === "first" ? Promise.reject(error) : Promise.resolve(integration.name)), + options, + ), + ).resolves.toEqual(["First", "Second"]); + await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error); + }); }); diff --git a/packages/boards/src/updater.spec.ts b/packages/boards/src/updater.spec.ts new file mode 100644 index 000000000..1f78f7e97 --- /dev/null +++ b/packages/boards/src/updater.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; + +import { getBoardSaveScopeId, shouldRefetchAfterSaveError } from "./updater"; + +describe("board persistence coordination", () => { + it("uses one mutation scope for every save of the same board", () => { + expect(getBoardSaveScopeId("board-1")).toBe(getBoardSaveScopeId("board-1")); + expect(getBoardSaveScopeId("board-1")).not.toBe(getBoardSaveScopeId("board-2")); + }); + + it("only refetches after a failed save when no newer local board exists", () => { + const failedBoard = { id: "board-1", items: ["first"] }; + const newerBoard = { id: "board-1", items: ["first", "second"] }; + + expect(shouldRefetchAfterSaveError(failedBoard, failedBoard)).toBe(true); + expect(shouldRefetchAfterSaveError(newerBoard, failedBoard)).toBe(false); + }); +}); diff --git a/packages/boards/src/updater.ts b/packages/boards/src/updater.ts index 49a301a8c..6763c48ce 100644 --- a/packages/boards/src/updater.ts +++ b/packages/boards/src/updater.ts @@ -5,13 +5,19 @@ import { useCallback } from "react"; import type { RouterOutputs } from "@homarr/api"; import { clientApi } from "@homarr/api/client"; +type Board = RouterOutputs["board"]["getHomeBoard"]; +type UpdateCallback = (previous: Board) => Board; + let boardName: string | null = null; export const updateBoardName = (name: string | null) => { boardName = name; }; -type UpdateCallback = (prev: RouterOutputs["board"]["getHomeBoard"]) => RouterOutputs["board"]["getHomeBoard"]; +export const getBoardSaveScopeId = (boardId: string) => `board-save:${boardId}`; + +export const shouldRefetchAfterSaveError = (currentBoard: T | undefined, failedBoard: T) => + currentBoard === failedBoard; export const useUpdateBoard = () => { const utils = clientApi.useUtils(); @@ -21,9 +27,13 @@ export const useUpdateBoard = () => { if (!boardName) { throw new Error("Board name is not set"); } - utils.board.getBoardByName.setData({ name: boardName }, (previous) => - previous ? updaterWithoutUndefined(previous) : previous, - ); + let updatedBoard: Board | undefined; + utils.board.getBoardByName.setData({ name: boardName }, (previous) => { + if (!previous) return previous; + updatedBoard = updaterWithoutUndefined(previous); + return updatedBoard; + }); + return updatedBoard; }, [utils], ); @@ -32,3 +42,37 @@ export const useUpdateBoard = () => { updateBoard, }; }; + +interface PersistBoardOptions { + onError?: (error: unknown) => void; +} + +export const usePersistBoard = ({ id, name }: Pick) => { + const utils = clientApi.useUtils(); + const { updateBoard } = useUpdateBoard(); + const { mutateAsync: saveBoardAsync } = clientApi.board.saveBoard.useMutation({ + scope: { id: getBoardSaveScopeId(id) }, + }); + + const updateAndPersistBoard = useCallback( + (updater: UpdateCallback, options?: PersistBoardOptions) => { + const updatedBoard = updateBoard(updater); + if (!updatedBoard) return undefined; + const persistedBoard = utils.board.getBoardByName.getData({ name }); + if (!persistedBoard) return undefined; + + void saveBoardAsync(persistedBoard).catch((error: unknown) => { + const currentBoard = utils.board.getBoardByName.getData({ name }); + if (shouldRefetchAfterSaveError(currentBoard, persistedBoard)) { + void utils.board.getBoardByName.invalidate({ name }); + } + options?.onError?.(error); + }); + + return persistedBoard; + }, + [name, saveBoardAsync, updateBoard, utils], + ); + + return { updateAndPersistBoard }; +}; diff --git a/packages/common/src/bounded-response.ts b/packages/common/src/bounded-response.ts new file mode 100644 index 000000000..d1432fb39 --- /dev/null +++ b/packages/common/src/bounded-response.ts @@ -0,0 +1,45 @@ +interface ReadableResponse { + headers: { get: (name: string) => string | null }; + body: { + getReader: () => { + read: () => Promise<{ done: boolean; value?: Uint8Array }>; + cancel: () => Promise; + }; + } | null; +} + +export async function readBoundedJsonResponseAsync( + response: ReadableResponse, + maxBytes: number, + label = "Response", +): Promise { + const declaredLength = Number(response.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > maxBytes) { + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + + if (!response.body) return null; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let byteLength = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + if (!value) continue; + byteLength += value.byteLength; + if (byteLength > maxBytes) { + await reader.cancel(); + throw new Error(`${label} exceeds ${maxBytes} bytes`); + } + chunks.push(value); + } + + const body = new Uint8Array(byteLength); + let offset = 0; + for (const chunk of chunks) { + body.set(chunk, offset); + offset += chunk.byteLength; + } + return JSON.parse(new TextDecoder().decode(body)) as unknown; +} diff --git a/packages/common/src/server.ts b/packages/common/src/server.ts index d6e62d5b9..502ae5f6b 100644 --- a/packages/common/src/server.ts +++ b/packages/common/src/server.ts @@ -2,3 +2,4 @@ export * from "./security"; export * from "./encryption"; export * from "./request"; export * from "./errors"; +export * from "./bounded-response"; diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts index 441d7fe3a..2dee921d0 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-integration.ts @@ -54,6 +54,7 @@ export class AudiobookshelfIntegration extends Integration { const statsByLibrary = await Promise.all( libraries.map(async (library) => ({ + library, mediaType: library.mediaType, stats: await this.getLibraryStatsAsync(library.id), })), @@ -75,6 +76,14 @@ export class AudiobookshelfIntegration extends Integration { totalPodcasts: counts.totalPodcasts, totalListeningTimeSeconds: listeningStats.totalTime, activeSessions, + libraries: statsByLibrary.map(({ library, stats }) => ({ + id: library.id, + name: library.name, + mediaType: library.mediaType, + totalItems: stats.totalItems, + totalDurationSeconds: stats.totalDuration ?? null, + totalSizeBytes: stats.totalSize ?? null, + })), }; } diff --git a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts index 495a24c08..4c853c139 100644 --- a/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts +++ b/packages/integrations/src/audiobookshelf/audiobookshelf-types.ts @@ -30,6 +30,14 @@ export interface AudiobookshelfDashboardData { totalPodcasts: number; totalListeningTimeSeconds: number; activeSessions: number; + libraries?: { + id: string; + name: string; + mediaType: "book" | "podcast"; + totalItems: number; + totalDurationSeconds: number | null; + totalSizeBytes: number | null; + }[]; } export type AudiobookshelfLibrary = z.infer; diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index 9496482d6..ce4892d10 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -36,7 +36,6 @@ export interface ImmichAlbum { export interface ImmichAsset { id: string; - originalPath: string; thumbhash: string | null; fileModifiedAt: string; fileCreatedAt: string; @@ -156,7 +155,6 @@ export class ImmichIntegration extends Integration { thumbhash: asset.thumbhash, fileCreatedAt: asset.fileCreatedAt, fileModifiedAt: asset.fileModifiedAt, - originalPath: asset.originalPath, updatedAt: asset.updatedAt, publicLink, }; diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index f172cec36..71b0652fc 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -61,6 +61,7 @@ export type { SystemHealthMonitoring } from "./interfaces/health-monitoring/heal export { UpstreamMediaRequestStatus } from "./interfaces/media-requests/media-request-types"; export type { MediaRequestList, MediaRequestStats } from "./interfaces/media-requests/media-request-types"; export type { StreamSession } from "./interfaces/media-server/media-server-types"; +export type { EntityState } from "./interfaces/smart-home/smart-home-types"; export type { TdarrQueue, TdarrPieSegment, diff --git a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts index efc113dc7..2208c4953 100644 --- a/packages/integrations/src/interfaces/smart-home/smart-home-types.ts +++ b/packages/integrations/src/interfaces/smart-home/smart-home-types.ts @@ -1,4 +1,4 @@ -interface EntityState { +export interface EntityState { attributes: Record; entity_id: string; last_changed: Date; diff --git a/packages/integrations/src/overseerr/overseerr-integration.spec.ts b/packages/integrations/src/overseerr/overseerr-integration.spec.ts new file mode 100644 index 000000000..1fc4484d0 --- /dev/null +++ b/packages/integrations/src/overseerr/overseerr-integration.spec.ts @@ -0,0 +1,30 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { OverseerrIntegration } from "./overseerr-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new OverseerrIntegration({ + id: "overseerr-1", + name: "Overseerr", + url: "https://overseerr.example.com", + externalUrl: null, + decryptedSecrets: [{ kind: "apiKey", value: "test-key" }], +}); + +describe("Overseerr media request actions", () => { + beforeEach(() => vi.clearAllMocks()); + + it.each([ + ["approve", () => integration.approveRequestAsync(42)], + ["decline", () => integration.declineRequestAsync(42)], + ])("rejects %s when Overseerr returns a non-success response", async (_action, run) => { + mockFetch.mockResolvedValue(new Response(null, { status: 500 })); + + await expect(run()).rejects.toThrow("Response from integration did not indicate success"); + }); +}); diff --git a/packages/integrations/src/overseerr/overseerr-integration.ts b/packages/integrations/src/overseerr/overseerr-integration.ts index 9de482008..226cebbf3 100644 --- a/packages/integrations/src/overseerr/overseerr-integration.ts +++ b/packages/integrations/src/overseerr/overseerr-integration.ts @@ -1,8 +1,8 @@ import { z } from "zod/v4"; +import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createLogger } from "@homarr/core/infrastructure/logs"; -import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error"; import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; @@ -253,49 +253,27 @@ export class OverseerrIntegration public async approveRequestAsync(requestId: number): Promise { logger.info("Approving media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/approve`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to approve media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully approved media request", { requestId, integration: this.integration.name }); } public async declineRequestAsync(requestId: number): Promise { logger.info("Declining media request", { requestId, integration: this.integration.name }); - await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { + const response = await fetchWithTrustedCertificatesAsync(this.url(`/api/v1/request/${requestId}/decline`), { method: "POST", headers: { "X-Api-Key": this.getSecretValue("apiKey"), }, - }).then((response) => { - if (!response.ok) { - logger.error( - new ErrorWithMetadata("Failed to decline media request", { - requestId, - integration: this.integration.name, - reason: `${response.status} ${response.statusText}`, - url: response.url, - }), - ); - } - - logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); }); + if (!response.ok) throw new ResponseError(response); + logger.info("Successfully declined media request", { requestId, integration: this.integration.name }); } private async getItemInformationAsync(id: number, type: MediaRequest["type"]): Promise { diff --git a/packages/integrations/src/patchmon/patchmon-types.ts b/packages/integrations/src/patchmon/patchmon-types.ts index 1f912d6c9..96c3db293 100644 --- a/packages/integrations/src/patchmon/patchmon-types.ts +++ b/packages/integrations/src/patchmon/patchmon-types.ts @@ -52,9 +52,9 @@ const mapOsDistributionEntry = ( osVersion: entry.os_version, }); -export const parsePatchMonStatsResponseAsync = async ( - response: { json: () => Promise }, -): Promise> => { +export const parsePatchMonStatsResponseAsync = async (response: { + json: () => Promise; +}): Promise> => { let json: unknown; try { json = await response.json(); @@ -82,7 +82,5 @@ export const mapPatchMonStats = (data: z.infer b.count - a.count), + osDistribution: [...data.os_distribution].map(mapOsDistributionEntry).sort((a, b) => b.count - a.count), }); diff --git a/packages/integrations/src/plex/test/plex-integration.spec.ts b/packages/integrations/src/plex/test/plex-integration.spec.ts index 64e046256..2a7284b64 100644 --- a/packages/integrations/src/plex/test/plex-integration.spec.ts +++ b/packages/integrations/src/plex/test/plex-integration.spec.ts @@ -137,4 +137,4 @@ describe("PlexIntegration.getMediaReleasesAsync", () => { }, ]); }); -}); \ No newline at end of file +}); diff --git a/packages/integrations/src/traefik/traefik-bounds.spec.ts b/packages/integrations/src/traefik/traefik-bounds.spec.ts new file mode 100644 index 000000000..1f14aff89 --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.spec.ts @@ -0,0 +1,12 @@ +import { Response } from "undici"; +import { describe, expect, it } from "vitest"; + +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; + +describe("readBoundedTraefikJsonAsync", () => { + it("rejects a response that exceeds its byte budget", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + + await expect(readBoundedTraefikJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); +}); diff --git a/packages/integrations/src/traefik/traefik-bounds.ts b/packages/integrations/src/traefik/traefik-bounds.ts new file mode 100644 index 000000000..99a6c8b1b --- /dev/null +++ b/packages/integrations/src/traefik/traefik-bounds.ts @@ -0,0 +1,12 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const MAX_TRAEFIK_RESPONSE_BYTES = 2 * 1024 * 1024; + +export async function readBoundedTraefikJsonAsync( + response: Response, + maxBytes = MAX_TRAEFIK_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Traefik response"); +} diff --git a/packages/integrations/src/traefik/traefik-integration.spec.ts b/packages/integrations/src/traefik/traefik-integration.spec.ts new file mode 100644 index 000000000..ced92e33a --- /dev/null +++ b/packages/integrations/src/traefik/traefik-integration.spec.ts @@ -0,0 +1,43 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { TraefikIntegration } from "./traefik-integration"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); +const integration = new TraefikIntegration({ + id: "traefik-1", + name: "Traefik", + url: "https://traefik.example.com", + externalUrl: null, + decryptedSecrets: [], +}); + +describe("TraefikIntegration response bounds", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFetch.mockImplementation((url) => { + const path = new URL(url.toString()).pathname; + if (path === "/api/version") return Promise.resolve(jsonResponse({ Version: "3.0.0" })); + if (path === "/api/http/routers") { + return Promise.resolve( + jsonResponse(Array.from({ length: 250 }, (_, index) => ({ name: `router-${index}`, status: "enabled" }))), + ); + } + return Promise.resolve(jsonResponse([])); + }); + }); + + it("caps detailed resource allocation while retaining bounded summary counts", async () => { + const result = await integration.getDashboardDataAsync(); + + expect(result.resources).toHaveLength(200); + expect(result.resources.at(-1)?.name).toBe("router-199"); + expect(result.http.routers.total).toBe(250); + }); +}); + +const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 }); diff --git a/packages/integrations/src/traefik/traefik-integration.ts b/packages/integrations/src/traefik/traefik-integration.ts index 0dc8cf57f..2e0e72b4c 100644 --- a/packages/integrations/src/traefik/traefik-integration.ts +++ b/packages/integrations/src/traefik/traefik-integration.ts @@ -4,10 +4,12 @@ import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/h import type { IntegrationTestingInput } from "../base/integration"; import { Integration } from "../base/integration"; import type { TestingResult } from "../base/test-connection/test-connection-service"; +import { readBoundedTraefikJsonAsync } from "./traefik-bounds"; import type { TraefikDashboardData, TraefikProtocolSummary, TraefikResource, + TraefikResourceDetail, TraefikResourceStatus, TraefikResourceSummary, } from "./traefik-types"; @@ -24,6 +26,8 @@ type ResourcePath = | "/api/udp/services" | "/api/entrypoints"; +const MAX_RESOURCE_DETAILS = 200; + export class TraefikIntegration extends Integration { protected async testingAsync(input: IntegrationTestingInput): Promise { const response = await input.fetchAsync(this.url("/api/version"), { headers: this.getAuthHeaders() }); @@ -32,7 +36,7 @@ export class TraefikIntegration extends Integration { throw new ResponseError(response); } - traefikVersionSchema.parse(await response.json()); + traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return { success: true }; } @@ -60,6 +64,38 @@ export class TraefikIntegration extends Integration { this.getResourcesAsync("/api/udp/routers"), this.getResourcesAsync("/api/udp/services"), ]); + + const allResults = [ + versionResult, + entryPointsResult, + httpRoutersResult, + httpServicesResult, + httpMiddlewaresResult, + tcpRoutersResult, + tcpServicesResult, + tcpMiddlewaresResult, + udpRoutersResult, + udpServicesResult, + ]; + if (allResults.every((result) => result.status === "rejected")) { + throw versionResult.status === "rejected" ? versionResult.reason : new Error("Every Traefik endpoint failed"); + } + + const endpointResults = [ + ["version", versionResult], + ["entrypoints", entryPointsResult], + ["http/routers", httpRoutersResult], + ["http/services", httpServicesResult], + ["http/middlewares", httpMiddlewaresResult], + ["tcp/routers", tcpRoutersResult], + ["tcp/services", tcpServicesResult], + ["tcp/middlewares", tcpMiddlewaresResult], + ["udp/routers", udpRoutersResult], + ["udp/services", udpServicesResult], + ] as const; + const failedEndpoints = endpointResults + .filter(([, result]) => result.status === "rejected") + .map(([endpoint]) => endpoint); const version = versionResult.status === "fulfilled" ? versionResult.value : null; const entryPoints = entryPointsResult.status === "fulfilled" ? entryPointsResult.value : []; const httpRouters = httpRoutersResult.status === "fulfilled" ? httpRoutersResult.value : []; @@ -71,9 +107,30 @@ export class TraefikIntegration extends Integration { const udpRouters = udpRoutersResult.status === "fulfilled" ? udpRoutersResult.value : []; const udpServices = udpServicesResult.status === "fulfilled" ? udpServicesResult.value : []; + const resources: TraefikResourceDetail[] = []; + const resourceGroups = [ + ["http", "router", httpRouters], + ["http", "service", httpServices], + ["http", "middleware", httpMiddlewares], + ["tcp", "router", tcpRouters], + ["tcp", "service", tcpServices], + ["tcp", "middleware", tcpMiddlewares], + ["udp", "router", udpRouters], + ["udp", "service", udpServices], + ] as const; + for (const [protocol, type, entries] of resourceGroups) { + const remaining = MAX_RESOURCE_DETAILS - resources.length; + if (remaining === 0) break; + resources.push(...this.createResourceDetails(protocol, type, entries.slice(0, remaining))); + } + return { version, - entryPoints: entryPoints.map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + entryPoints: entryPoints + .slice(0, MAX_RESOURCE_DETAILS) + .map((entryPoint, index) => entryPoint.name ?? `entrypoint-${index + 1}`), + resources, + failedEndpoints, http: this.createProtocolSummary(httpRouters, httpServices, httpMiddlewares), tcp: this.createProtocolSummary(tcpRouters, tcpServices, tcpMiddlewares), udp: { @@ -83,15 +140,29 @@ export class TraefikIntegration extends Integration { }; } + private createResourceDetails( + protocol: TraefikResourceDetail["protocol"], + type: TraefikResourceDetail["type"], + resources: TraefikResource[], + ): TraefikResourceDetail[] { + return resources.map((resource, index) => ({ + protocol, + type, + name: resource.name ?? `${type}-${index + 1}`, + provider: resource.provider ?? null, + status: this.normalizeStatus(resource.status), + })); + } + private async getVersionAsync() { const response = await this.fetchOkAsync("/api/version"); - const version = traefikVersionSchema.parse(await response.json()); + const version = traefikVersionSchema.parse(await readBoundedTraefikJsonAsync(response)); return version.Version ?? null; } private async getResourcesAsync(path: ResourcePath) { const response = await this.fetchOkAsync(path); - return traefikResourcesSchema.parse(await response.json()); + return traefikResourcesSchema.parse(await readBoundedTraefikJsonAsync(response)); } private createProtocolSummary( @@ -137,6 +208,7 @@ export class TraefikIntegration extends Integration { const response = await fetchWithTrustedCertificatesAsync(this.url(path), { headers: this.getAuthHeaders(), timeout: 10_000, + bodyTimeout: 10_000, }); if (!response.ok) { diff --git a/packages/integrations/src/traefik/traefik-types.ts b/packages/integrations/src/traefik/traefik-types.ts index 5874ef41b..188941465 100644 --- a/packages/integrations/src/traefik/traefik-types.ts +++ b/packages/integrations/src/traefik/traefik-types.ts @@ -31,9 +31,19 @@ export interface TraefikProtocolSummary { middlewares: TraefikResourceSummary; } +export interface TraefikResourceDetail { + protocol: "http" | "tcp" | "udp"; + type: "router" | "service" | "middleware"; + name: string; + provider: string | null; + status: TraefikResourceStatus; +} + export interface TraefikDashboardData { version: string | null; entryPoints: string[]; + resources: TraefikResourceDetail[]; + failedEndpoints: string[]; http: TraefikProtocolSummary; tcp: TraefikProtocolSummary; udp: Omit; diff --git a/packages/request-handler/src/smart-home-entity-state.spec.ts b/packages/request-handler/src/smart-home-entity-state.spec.ts new file mode 100644 index 000000000..bee81d2de --- /dev/null +++ b/packages/request-handler/src/smart-home-entity-state.spec.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { toSafeEntityDetails } from "./smart-home-entity-state"; + +describe("toSafeEntityDetails", () => { + it("keeps display attributes and removes sensitive Home Assistant metadata", () => { + const details = toSafeEntityDetails({ + entity_id: "camera.front_door", + state: "streaming", + last_changed: new Date("2026-01-01T00:00:00Z"), + last_updated: new Date("2026-01-01T00:00:01Z"), + attributes: { + friendly_name: "Front door", + icon: "mdi:camera", + access_token: "secret-camera-token", + latitude: 48.85, + }, + }); + + expect(details.attributes).toEqual({ friendly_name: "Front door", icon: "mdi:camera" }); + expect(details.attributes).not.toHaveProperty("access_token"); + expect(details.attributes).not.toHaveProperty("latitude"); + }); +}); diff --git a/packages/request-handler/src/smart-home-entity-state.ts b/packages/request-handler/src/smart-home-entity-state.ts index 3c8851522..1d0d2471c 100644 --- a/packages/request-handler/src/smart-home-entity-state.ts +++ b/packages/request-handler/src/smart-home-entity-state.ts @@ -1,10 +1,29 @@ import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { EntityState } from "@homarr/integrations"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; +const safeAttributeKeys = ["friendly_name", "unit_of_measurement", "device_class", "icon"] as const; + +export const toSafeEntityDetails = (entity: EntityState) => { + const attributes: Partial> = {}; + for (const key of safeAttributeKeys) { + const value = entity.attributes[key]; + if (typeof value === "string") attributes[key] = value; + } + + return { + attributes, + entity_id: entity.entity_id, + last_changed: entity.last_changed, + last_updated: entity.last_updated, + state: entity.state, + }; +}; + export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandler< - string, + EntityState, IntegrationKindByCategory<"smartHomeServer">, { entityId: string } >({ @@ -16,6 +35,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle throw new Error(`Unable to fetch data from Home Assistant error='${result.error as string}'`); } - return result.data.state; + return result.data; }, }); diff --git a/packages/request-handler/src/timetable-url.ts b/packages/request-handler/src/timetable-url.ts new file mode 100644 index 000000000..f7241a542 --- /dev/null +++ b/packages/request-handler/src/timetable-url.ts @@ -0,0 +1,26 @@ +import type { Response } from "undici"; + +import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server"; + +export const DEFAULT_TIMETABLE_BASE_URL = "https://search.ch"; +export const MAX_TIMETABLE_RESPONSE_BYTES = 512 * 1024; + +export const normalizeTimetableBaseUrl = (baseUrl: string) => { + if (baseUrl.length > 2_048) throw new Error("Timetable base URL is too long"); + + const url = new URL(baseUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new Error("Timetable base URL must use HTTP or HTTPS"); + } + if (url.username || url.password) throw new Error("Timetable base URL must not contain credentials"); + if (url.search || url.hash) throw new Error("Timetable base URL must not contain a query or fragment"); + + return url.toString().replace(/\/$/, ""); +}; + +export async function readBoundedTimetableJsonAsync( + response: Response, + maxBytes = MAX_TIMETABLE_RESPONSE_BYTES, +): Promise { + return await readBoundedJsonResponse(response, maxBytes, "Timetable response"); +} diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts new file mode 100644 index 000000000..cb08cb313 --- /dev/null +++ b/packages/request-handler/src/timetable.spec.ts @@ -0,0 +1,79 @@ +import { Response } from "undici"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; + +vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() })); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +beforeEach(() => vi.clearAllMocks()); + +describe("normalizeTimetableBaseUrl", () => { + it("normalizes an HTTP endpoint", () => { + expect(normalizeTimetableBaseUrl("https://search.ch/")).toBe("https://search.ch"); + }); + + it.each(["file:///etc/passwd", "https://user:secret@example.com", "https://example.com?redirect=/admin"])( + "rejects unsafe base URL %s", + (url) => expect(() => normalizeTimetableBaseUrl(url)).toThrow(), + ); +}); + +describe("timetable response bounds", () => { + it("rejects a response once its byte budget is exceeded", async () => { + const response = new Response(JSON.stringify({ value: "x".repeat(100) })); + await expect(readBoundedTimetableJsonAsync(response, 32)).rejects.toThrow("exceeds 32 bytes"); + }); + + it("caps and caches station results and rejects redirects", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify( + Array.from({ length: 125 }, (_, index) => ({ + id: String(index), + label: `Station ${index}`, + iconclass: "train", + })), + ), + ), + ); + const input = { baseUrl: "http://timetable.internal", query: "bounded-stations" }; + + const first = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + const second = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); + + expect(first.data).toHaveLength(100); + expect(second.data).toHaveLength(100); + expect(mockFetch).toHaveBeenCalledOnce(); + expect(mockFetch).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ redirect: "error", bodyTimeout: 10_000 }), + ); + }); + + it("never caches more departures than requested", async () => { + mockFetch.mockResolvedValue( + new Response( + JSON.stringify({ + connections: Array.from({ length: 5 }, (_, index) => ({ + time: `2026-01-01T00:0${index}:00Z`, + line: "1", + color: "ffffff", + terminal: { name: `Stop ${index}` }, + dep_delay: "0", + })), + }), + ), + ); + + const result = await timetableGetTimetableRequestHandler + .handler({ baseUrl: "http://timetable.internal", stationId: "bounded-departures", limit: 2 }) + .getDataAsync(); + + expect(result.data.entries).toHaveLength(2); + }); +}); diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts index 2be7355d8..b55a688f7 100644 --- a/packages/request-handler/src/timetable.ts +++ b/packages/request-handler/src/timetable.ts @@ -4,6 +4,7 @@ import { ResponseError } from "@homarr/common/server"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { createRequestHandler } from "./lib/request-handler"; +import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url"; export interface Station { id: string; @@ -31,6 +32,9 @@ export interface Timetable { } const supportedStationTypes = ["bus", "tram", "train", "ship", "cablecar", "funicular", "chairlift"]; +const MAX_STATION_RESULTS = 100; +const MAX_TIMETABLE_ENTRIES = 100; +const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const; export const timetableSearchStationsRequestHandler = createRequestHandler< Station[], @@ -53,7 +57,7 @@ export const timetableGetTimetableRequestHandler = createRequestHandler< }); const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record) => { - const url = new URL(`${baseUrl.replace(/\/$/, "")}${path}`); + const url = new URL(`${normalizeTimetableBaseUrl(baseUrl)}${path}`); for (const [key, value] of Object.entries(queryParams)) { url.searchParams.set(key, value.toString()); } @@ -63,10 +67,12 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record => { const response = await fetchWithTrustedCertificatesAsync( buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }), + timetableFetchOptions, ); if (!response.ok) throw new ResponseError(response); - const data = await searchSchema.parseAsync(await response.json()); + const body = await readBoundedTimetableJsonAsync(response); + const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body); return data .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type))) .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null)) @@ -99,10 +105,15 @@ const getTimetableAsync = async ( date, time, }), + timetableFetchOptions, ); if (!response.ok) throw new ResponseError(response); - const data = await timetableSchema.parseAsync(await response.json()); + const body = await readBoundedTimetableJsonAsync(response); + const envelope = timetableEnvelopeSchema.parse(body); + const data = await timetableSchema.parseAsync({ + connections: envelope.connections.slice(0, Math.min(options.limit, MAX_TIMETABLE_ENTRIES)), + }); return { stationId: options.stationId, timestamp: now, @@ -145,6 +156,8 @@ const timetableSchema = z.object({ ), }); +const timetableEnvelopeSchema = z.object({ connections: z.array(z.unknown()) }); + const searchSchema = z.array( z.object({ id: z.string().optional(), diff --git a/packages/request-handler/src/vpn.ts b/packages/request-handler/src/vpn.ts index ebca0fbeb..97182ab11 100644 --- a/packages/request-handler/src/vpn.ts +++ b/packages/request-handler/src/vpn.ts @@ -1,28 +1,16 @@ -import { createLogger } from "@homarr/core/infrastructure/logs"; import type { IntegrationKindByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; import type { VpnSummary } from "@homarr/integrations/types"; import { createIntegrationRequestHandler } from "./lib/integration-request-handler"; -const logger = createLogger({ module: "vpnRequestHandler" }); - export const vpnSummaryHandler = createIntegrationRequestHandler< - VpnSummary | null, + VpnSummary, IntegrationKindByCategory<"vpn">, Record >({ async requestAsync(integration, _input) { - try { - const integrationInstance = await createIntegrationAsync(integration); - return await integrationInstance.getSummaryAsync(); - } catch (error) { - logger.warn("Failed to fetch VPN summary, marking as unavailable", { - integrationId: integration.id, - integrationKind: integration.kind, - error: error instanceof Error ? error.message : String(error), - }); - return null; - } + const integrationInstance = await createIntegrationAsync(integration); + return await integrationInstance.getSummaryAsync(); }, }); diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index de211bf66..e8a9192a7 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -1632,12 +1632,33 @@ "error": "Error", "loading": "Loading", "idle": "Idle" + }, + "notification": { + "saveError": { + "title": "Unable to save widget", + "message": "The widget changes were rolled back" + } } }, "create": { "title": "Choose item to add", "search": "Filter items", - "addToBoard": "Add to board" + "addToBoard": "Add to board", + "addAtPosition": "Add item here", + "integrationAvailable": "Connected integration available", + "notification": { + "error": { + "title": "Unable to add item", + "message": "The item could not be added to this board" + } + } + }, + "advancedFocus": { + "open": "Open advanced view", + "close": "Close advanced view", + "pin": "Keep advanced view open", + "pinned": "Advanced view is pinned", + "dialog": "{widget} advanced view" }, "moveResize": { "title": "Move / resize item", @@ -1791,6 +1812,26 @@ "showBroadcastMessage": { "label": "Show broadcast messages" } + }, + "noProjectSelected": "No project selected", + "runnerStatus": "Runner: {status}", + "updatedAt": "Updated {date}", + "metric": { + "running": "Running", + "completed": "Done", + "failed": "Failed", + "canceled": "Canceled", + "download": "Download", + "upload": "Upload" + }, + "status": { + "running": "Running", + "completed": "Completed", + "failed": "Failed", + "canceled": "Canceled", + "stopped": "Stopped", + "idle": "Idle", + "unknown": "Unknown" } }, "dnsHoleSummary": { @@ -1825,6 +1866,11 @@ "dnsQueriesToday": "Queries today", "domainsBeingBlocked": "Domains on blocklist" }, + "status": { + "enabled": "Enabled", + "disabled": "Disabled", + "unknown": "Unknown" + }, "domainsTooltip": "Due to multiple integrations Homarr can't calculate the exact number of domains being blocked" }, "dnsHoleControls": { @@ -1850,7 +1896,8 @@ } }, "error": { - "internalServerError": "Failed to control DNS Hole" + "internalServerError": "Failed to control DNS Hole", + "bulkActionsFailed": "{count, plural, one {# action failed} other {# actions failed}}" }, "controls": { "enableAll": "Enable All", @@ -2014,6 +2061,8 @@ "height": "Height" }, "placeholder": "Start writing your notes", + "saveFailed": "Unable to save note", + "documentStats": "{words, plural, one {# word} other {# words}} · {characters, plural, one {# character} other {# characters}}", "dismiss": { "title": "Dismiss changes?", "message": "You have unsaved changes in your notebook. Are you sure you want to discard them?", @@ -2043,13 +2092,23 @@ "noNotes": "No notes found", "integrationRequired": "Select an Anchor integration first", "updatedAt": "Updated {date}", + "createdAt": "Created {date}", "untitled": "Untitled", "edit": "Edit", "save": "Save", "cancel": "Cancel", "readOnlyViewer": "You have viewer access to this note. Editing is disabled.", "saveForbidden": "You do not have permission to edit this note.", - "saveFailed": "Failed to save this note. Please try again." + "saveFailed": "Failed to save this note. Please try again.", + "permission": { + "owner": "Owner", + "editor": "Editor", + "viewer": "Viewer" + }, + "status": { + "pinned": "Pinned", + "archived": "Archived" + } }, "iframe": { "name": "iFrame", @@ -2108,6 +2167,18 @@ "clickable": { "label": "Clickable" } + }, + "advanced": { + "entityId": "Entity ID: {id}", + "lastUpdated": "Updated {date}", + "attribute": { + "unit": "Unit", + "deviceClass": "Device class", + "icon": "Icon" + } + }, + "error": { + "toggleFailed": "Unable to update entity" } }, "smartHome-executeAutomation": { @@ -2123,6 +2194,13 @@ }, "spotlightAction": { "run": "Run {name}" + }, + "advanced": { + "lastExecuted": "Last run at {time}", + "automationId": "Automation ID: {id}" + }, + "error": { + "executeFailed": "Unable to run automation" } }, "stockPrice": { @@ -2498,7 +2576,13 @@ "runtime": "Runtime", "load": "Load", "input": "Input", - "output": "Output" + "output": "Output", + "batteryVoltage": "Battery voltage", + "power": "Power", + "temperature": "Temperature", + "manufacturer": "Manufacturer", + "model": "Model", + "serial": "Serial" }, "error": { "internalServerError": "Failed to fetch UPS data" @@ -2755,6 +2839,11 @@ "target": "Target codec" } } + }, + "summary": { + "sessions": "{count, plural, one {# session} other {# sessions}}", + "playing": "{count} playing", + "transcoding": "{count} transcoding" } }, "paperlessNgx": { @@ -3037,7 +3126,16 @@ } }, "description": "Slideshow of pictures from an Immich album or your full library", - "noPhotos": "No photos found" + "noPhotos": "No photos found", + "albumPhoto": "Album photo", + "actions": { + "previousPhoto": "Previous photo", + "nextPhoto": "Next photo", + "pauseSlideshow": "Pause slideshow", + "resumeSlideshow": "Resume slideshow", + "toggleSlideshow": "Pause or resume slideshow", + "photo": "Photo {number}" + } }, "downloads": { "name": "Download Client", @@ -3610,6 +3708,13 @@ "error": { "internalServerError": "Unable to get data from firewall" }, + "versionUnknown": "Unknown version", + "metricAccessible": "{metric}: {value}%, {status}", + "status": { + "normal": "Normal", + "warning": "Warning", + "critical": "Critical" + }, "option": { "interfaces": "Network interfaces to display" }, @@ -3673,6 +3778,11 @@ "unhealthy": "Unhealthy", "ok": "OK", "fail": "Fail", + "range": { + "twelveHours": "12h", + "oneDay": "24h", + "all": "All" + }, "noSectionsEnabled": "Enable at least one section in widget options.", "error": { "internalServerError": "Failed to fetch speedtest data" @@ -3714,6 +3824,11 @@ "downCount": "Down", "pausedCount": "Paused" }, + "status": { + "up": "Up", + "down": "Down", + "paused": "Paused" + }, "error": { "internalServerError": "Failed to fetch uptime data" } @@ -3743,6 +3858,19 @@ "services": "Services", "middlewares": "Middlewares" }, + "resourceType": { + "router": "Router", + "service": "Service", + "middleware": "Middleware" + }, + "resourceStatus": { + "enabled": "Enabled", + "disabled": "Disabled", + "warning": "Warning", + "error": "Error", + "unknown": "Unknown" + }, + "failedEndpoint": "Failed: {endpoint}", "status": { "healthy": "Healthy", "errors": "{count} errors", @@ -3889,11 +4017,30 @@ }, "noDefinition": "No definition selected", "definitionNotFound": "Widget definition not found. It may have been deleted.", + "fetchFailed": "Unable to load custom API data", "noDisplayOutput": "No display output", "executeConfirm": "Are you sure you want to execute this action?", "executing": "Executing...", "executeSuccess": "Action executed successfully", - "executeFailed": "Action failed. Check the widget configuration." + "executeFailed": "Action failed. Check the widget configuration.", + "actions": { + "pausePolling": "Pause polling", + "resumePolling": "Resume polling", + "togglePolling": "Pause or resume polling" + }, + "displayType": { + "singleValue": "Single value", + "keyValue": "Key/value", + "table": "Table", + "statGrid": "Stat grid", + "progressBars": "Progress bars", + "statusIndicator": "Status indicator", + "countGrid": "Count grid", + "raw": "Raw data", + "actionButton": "Action button", + "customJsx": "Custom JSX", + "unknown": "Custom data" + } }, "beszelSystemTable": { "name": "Beszel Systems (Table)", @@ -4081,6 +4228,14 @@ } }, "selectSystem": "Select system", + "period": { + "live": "Live", + "oneHour": "1h", + "twelveHours": "12h", + "oneDay": "24h", + "oneWeek": "1w", + "thirtyDays": "30d" + }, "empty": { "noSystems": "No systems found" }, diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx index 3d575bfb0..abeb7e350 100644 --- a/packages/widgets/src/_inputs/common.tsx +++ b/packages/widgets/src/_inputs/common.tsx @@ -9,6 +9,7 @@ export interface CommonWidgetInputProps { options: Omit, "defaultValue" | "type">; initialOptions: Record; itemId?: string; + boardId?: string; } type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string; diff --git a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx index 3b5b636aa..532fd04f8 100644 --- a/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx +++ b/packages/widgets/src/_inputs/widget-dynamic-select-input.tsx @@ -17,7 +17,13 @@ export interface DynamicSelectOption { label: string; } -export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidgetInputProps<"dynamicSelect">) => { +export const WidgetDynamicSelectInput = ({ + property, + kind, + options, + itemId, + boardId, +}: CommonWidgetInputProps<"dynamicSelect">) => { const t = useI18n(); const tWidget = useWidgetInputTranslation(kind, property); const form = useFormContext(); @@ -28,6 +34,8 @@ export const WidgetDynamicSelectInput = ({ property, kind, options }: CommonWidg debouncedSearch, form.values.integrationIds, form.values.options, + itemId, + boardId, ); const currentOption = inputProps.value as DynamicSelectOption | null; const onChange = inputProps.onChange as (value: DynamicSelectOption | null) => void; diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 90a1c998c..781a09930 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -3,13 +3,14 @@ import { startTransition, useCallback, useEffect, useMemo, useState } from "react"; import type { ComponentProps } from "react"; import dynamic from "next/dynamic"; -import { Button, Center, Group, Stack, Text, TextInput } from "@mantine/core"; +import { Badge, Button, Center, Group, Stack, Text, TextInput } from "@mantine/core"; import { Quill } from "react-quill-new"; import type { DeltaStatic } from "react-quill-new"; import type ReactQuillComponent from "react-quill-new"; import { z } from "zod/v4"; import { clientApi } from "@homarr/api/client"; +import { useIntegrationsWithInteractAccess } from "@homarr/auth/client"; import { useTimeAgo } from "@homarr/common"; import type { AnchorNotePermission } from "@homarr/integrations"; import { useScopedI18n } from "@homarr/translation/client"; @@ -106,6 +107,13 @@ const stringifyDelta = (delta: unknown): string => { return JSON.stringify(emptyDelta()); }; +export const storedContentToPlainText = (content?: string | null): string => { + return parseStoredContent(content) + .ops.map(({ insert }) => (typeof insert === "string" ? insert : "")) + .join("") + .trim(); +}; + const canEditPermission = (permission: AnchorNotePermission) => { return permission === "owner" || permission === "editor"; }; @@ -119,7 +127,14 @@ const isForbiddenError = (error: unknown): boolean => { return (data as { code?: unknown }).code === "FORBIDDEN"; }; -export default function AnchorNoteWidget({ options, integrationIds }: WidgetComponentProps<"anchorNote">) { +export default function AnchorNoteWidget({ + options, + integrationIds, + width, + height, + displayMode, + widgetStateRef, +}: WidgetComponentProps<"anchorNote">) { const t = useScopedI18n("widget.anchorNote"); const noteId = options.noteId.trim(); if (!noteId) { @@ -134,27 +149,74 @@ export default function AnchorNoteWidget({ options, integrationIds }: WidgetComp // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const integrationId = integrationIds[0]!; - return ; + return ( + + ); +} + +interface AnchorNotePersistedState { + integrationId: string; + noteId: string; + isEditing: boolean; + draftTitle: string; + draftContent: string; + saveError: string | null; } interface AnchorNoteWidgetContentProps { options: WidgetComponentProps<"anchorNote">["options"]; integrationId: string; noteId: string; + width: number; + height: number; + isAdvanced: boolean; + widgetStateRef?: WidgetComponentProps<"anchorNote">["widgetStateRef"]; } -const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteWidgetContentProps) => { +const AnchorNoteWidgetContent = ({ + options, + integrationId, + noteId, + width, + height, + isAdvanced, + widgetStateRef, +}: AnchorNoteWidgetContentProps) => { const t = useScopedI18n("widget.anchorNote"); + const persistedState = widgetStateRef?.current?.anchorNote as AnchorNotePersistedState | undefined; + const canHydratePersistedState = persistedState?.integrationId === integrationId && persistedState.noteId === noteId; const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({ integrationId, noteId, }); const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation(); - const [isEditing, setIsEditing] = useState(false); - const [draftTitle, setDraftTitle] = useState(note?.title ?? ""); - const [draftContent, setDraftContent] = useState(note?.content ?? ""); - const [saveError, setSaveError] = useState(null); + const [isEditing, setIsEditing] = useState(canHydratePersistedState ? (persistedState?.isEditing ?? false) : false); + const [draftTitle, setDraftTitle] = useState( + canHydratePersistedState ? (persistedState?.draftTitle ?? "") : (note?.title ?? ""), + ); + const [draftContent, setDraftContent] = useState( + canHydratePersistedState ? (persistedState?.draftContent ?? "") : (note?.content ?? ""), + ); + const [saveError, setSaveError] = useState( + canHydratePersistedState ? (persistedState?.saveError ?? null) : null, + ); + + useEffect(() => { + if (!widgetStateRef) return; + widgetStateRef.current = { + ...widgetStateRef.current, + anchorNote: { integrationId, noteId, isEditing, draftTitle, draftContent, saveError }, + }; + }, [draftContent, draftTitle, integrationId, isEditing, noteId, saveError, widgetStateRef]); useEffect(() => { if (isEditing || !note) return; @@ -165,7 +227,8 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW }); }, [isEditing, note]); - const canEdit = canEditPermission(note?.permission ?? "viewer"); + const hasInteractAccess = useIntegrationsWithInteractAccess().some(({ id }) => id === integrationId); + const canEdit = canEditPermission(note?.permission ?? "viewer") && hasInteractAccess; const isViewer = !note || note.permission === "viewer"; const updatedAt = useMemo(() => (note ? new Date(note.updatedAt) : new Date()), [note]); const updatedAtRelative = useTimeAgo(updatedAt, 30000); @@ -178,6 +241,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]); const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]); + const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]); const handleEditorChange = useCallback( (_html, _delta, source, editor) => { if (!isEditing || source !== "user") return; @@ -260,7 +324,7 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW if (!note) return ; return ( - + {isEditing ? ( @@ -278,6 +342,22 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW {t("readOnlyViewer")} )} + {!isEditing && isAdvanced && ( + + + {t(`permission.${note.permission}`)} + + {note.isPinned && {t("status.pinned")}} + {note.isArchived && ( + + {t("status.archived")} + + )} + + {t("createdAt", { date: new Date(note.createdAt).toLocaleDateString() })} + + + )} {saveError && ( {saveError} @@ -301,17 +381,31 @@ const AnchorNoteWidgetContent = ({ options, integrationId, noteId }: AnchorNoteW )} -
- -
+ {isEditing || isAdvanced ? ( +
+ +
+ ) : ( + + {plainText || t("emptyContent")} + + )}
); }; diff --git a/packages/widgets/src/anchor-note/index.ts b/packages/widgets/src/anchor-note/index.ts index a54aada3f..9baaccf2d 100644 --- a/packages/widgets/src/anchor-note/index.ts +++ b/packages/widgets/src/anchor-note/index.ts @@ -5,6 +5,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("anchorNote", { icon: IconNotes, + queryKey: [["widget", "anchorNotes"]], + maxIntegrations: 1, supportedIntegrations: ["anchor"], createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 56e079a8c..aa3dad356 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -19,7 +19,7 @@ import classes from "./app.module.css"; import { PingDot } from "./ping/ping-dot"; import { PingIndicator } from "./ping/ping-indicator"; -export default function AppWidget({ options, isEditMode, height, width }: WidgetComponentProps<"app">) { +export default function AppWidget({ options, isEditMode, height, width, displayMode }: WidgetComponentProps<"app">) { const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); @@ -51,6 +51,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget if (!app) return ; const isTiny = height < 100 || width < 100; + const isAdvanced = displayMode === "advanced"; const isColumnLayout = options.layout.startsWith("column"); return ( @@ -83,7 +84,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined} > - {options.showTitle && ( + {(options.showTitle || isAdvanced) && ( )} - {options.descriptionDisplayMode === "normal" && ( + {(options.descriptionDisplayMode === "normal" || isAdvanced) && ( {app.description?.split("\n").map((line, index) => ( @@ -109,6 +110,11 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget ))} )} + {isAdvanced && app.href && ( + + {getDisplayHost(app.href)} + + )} @@ -154,3 +160,11 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren { + try { + return new URL(href).host; + } catch { + return href; + } +}; diff --git a/packages/widgets/src/app/index.ts b/packages/widgets/src/app/index.ts index 62f01287d..7755ac105 100644 --- a/packages/widgets/src/app/index.ts +++ b/packages/widgets/src/app/index.ts @@ -15,6 +15,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("app", { icon: IconApps, + queryKeys: [[["app", "byId"]], [["widget", "app"]]], createOptions(settings) { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx index 5ac03eadc..50b213257 100644 --- a/packages/widgets/src/archive-team-warrior/component.tsx +++ b/packages/widgets/src/archive-team-warrior/component.tsx @@ -1,16 +1,20 @@ "use client"; -import { Avatar, Badge, Card, Group, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { formatByteRate } from "@homarr/common"; import { getIconUrl } from "@homarr/definitions"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; export default function ArchiveTeamWarriorWidget({ integrationIds, options, + width, + height, + displayMode, }: WidgetComponentProps<"archiveTeamWarrior">) { const integrationId = integrationIds[0]; @@ -18,23 +22,39 @@ export default function ArchiveTeamWarriorWidget({ return null; } - return ; + return ( + + ); } const ArchiveTeamWarriorWidgetContent = ({ integrationId, options, + width, + height, + isAdvanced, }: { integrationId: string; options: WidgetComponentProps<"archiveTeamWarrior">["options"]; + width: number; + height: number; + isAdvanced: boolean; }) => { + const t = useScopedI18n("widget.archiveTeamWarrior"); const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId }); const status = data.status; - const projectName = status.project?.title ?? status.selectedProject ?? "No project selected"; + const projectName = status.project?.title ?? status.selectedProject ?? t("noProjectSelected"); + const statusKey = getStatusKey(status.status); return ( - + @@ -43,12 +63,12 @@ const ArchiveTeamWarriorWidgetContent = ({ - - {status.status} + + {t(`status.${statusKey}`)} - {options.showBroadcastMessage && status.broadcastMessage && ( + {options.showBroadcastMessage && status.broadcastMessage && (isAdvanced || height >= 160) && ( {status.broadcastMessage} @@ -56,19 +76,52 @@ const ArchiveTeamWarriorWidgetContent = ({ )} - - - - - + + + + + {status.bandwidth && ( - - + + )} + {isAdvanced && ( + <> + + + {t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })} + + + {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })} + + + + + {status.items.map((item) => ( + + + + + {item.name} + + + {item.project ?? projectName} + + + + {t(`status.${item.status}`)} + + + + ))} + + + + )} ); }; @@ -85,3 +138,33 @@ const Metric = ({ label, value }: { label: string; value: number | string }) => ); const formatBandwidth = (value?: number) => formatByteRate(Math.round(value ?? 0)); + +type WarriorStatusKey = "running" | "completed" | "failed" | "canceled" | "stopped" | "idle" | "unknown"; + +const getStatusKey = (status?: string | null): WarriorStatusKey => { + switch (status?.trim().toLowerCase()) { + case "running": + return "running"; + case "done": + case "completed": + return "completed"; + case "failed": + return "failed"; + case "canceled": + case "cancelled": + return "canceled"; + case "stopped": + return "stopped"; + case "idle": + return "idle"; + default: + return "unknown"; + } +}; + +const getStatusColor = (status: WarriorStatusKey): string => { + if (status === "running") return "green"; + if (status === "failed") return "red"; + if (status === "canceled" || status === "stopped") return "yellow"; + return "gray"; +}; diff --git a/packages/widgets/src/archive-team-warrior/index.ts b/packages/widgets/src/archive-team-warrior/index.ts index 5e0998ec2..e639c7e9f 100644 --- a/packages/widgets/src/archive-team-warrior/index.ts +++ b/packages/widgets/src/archive-team-warrior/index.ts @@ -5,6 +5,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("archiveTeamWarrior", { icon: IconArchive, + queryKey: [["widget", "archiveTeamWarrior"]], + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showBroadcastMessage: factory.switch({ diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index 9e410f527..d31aca8fe 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -1,17 +1,91 @@ "use client"; +import { Badge, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; + import { clientApi } from "@homarr/api/client"; +import { formatBytes, formatDuration } from "@homarr/common"; +import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { AudioStatsContent } from "./audio-stats-content"; -export default function AudioStatsWidget({ integrationIds, options, width }: WidgetComponentProps<"audioStats">) { +export default function AudioStatsWidget({ + integrationIds, + options, + width, + height, + displayMode = "compact", +}: WidgetComponentProps<"audioStats">) { const { data: response } = clientApi.widget.audioStats.getStats.useQuery({ integrationId: integrationIds[0] ?? "", }); + const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery( + { integrationIds, showOnlyPlaying: false }, + { enabled: displayMode === "advanced" && response?.kind === "navidrome" }, + ); if (!response) return ; - return ; + const summary = ( + + ); + if (displayMode === "compact") return summary; + + const sessions = streamResults.flatMap((result) => + result.sessions.map((session) => ({ integrationId: result.integrationId, session })), + ); + const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; + return ( + +
{summary}
+ + {response.kind === "navidrome" ? ( + = 760 ? 2 : 1} spacing="xs"> + {sessions.map(({ integrationId, session }) => { + const currentlyPlaying = session.currentlyPlaying; + if (!currentlyPlaying) return null; + return ( + + + + {currentlyPlaying.name} + + + {currentlyPlaying.seasonName ?? currentlyPlaying.albumName ?? session.user.username} + + + {session.sessionName} + + ); + })} + + ) : ( + = 760 ? 2 : 1} spacing="xs"> + {(audiobookStats?.libraries ?? []).map((library) => ( + + + + {library.name} + + + {library.totalItems} + + + + {library.totalDurationSeconds === null ? "—" : formatDuration(library.totalDurationSeconds * 1000)} ·{" "} + {library.totalSizeBytes === null ? "—" : formatBytes(library.totalSizeBytes)} + + + ))} + + )} + +
+ ); } diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts index ffcd2dd93..c5783fb89 100644 --- a/packages/widgets/src/audio-stats/index.ts +++ b/packages/widgets/src/audio-stats/index.ts @@ -13,6 +13,7 @@ const hideUnlessAudiobookshelf = { export const { definition, componentLoader } = createWidgetDefinition("audioStats", { icon: IconHeadphones, + queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]], supportedIntegrations: ["navidrome", "audiobookshelf"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/bazarr/component.tsx b/packages/widgets/src/bazarr/component.tsx index 094c32ee1..5e4d32ffd 100644 --- a/packages/widgets/src/bazarr/component.tsx +++ b/packages/widgets/src/bazarr/component.tsx @@ -36,7 +36,13 @@ const iconSizeByWidth = [ { minWidth: 0, size: 16 }, ] as const; -export default function BazarrWidget({ integrationIds, options, width }: WidgetComponentProps<"bazarr">) { +export default function BazarrWidget({ + integrationIds, + options, + width, + height, + displayMode, +}: WidgetComponentProps<"bazarr">) { const t = useScopedI18n("widget.bazarr"); const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery( { integrationId: integrationIds[0] ?? "" }, @@ -53,11 +59,11 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC } as const; const visibleStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const gridCols = getGridCols(width); - const iconSize = getIconSize(width); + const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced"); + const iconSize = getIconSize(Math.min(width, height)); if (visibleStatKeys.length === 0) { return ( @@ -92,12 +98,17 @@ export default function BazarrWidget({ integrationIds, options, width }: WidgetC ); } -function getGridCols(width: number): number { - const match = gridColsByWidth.find(({ minWidth }) => width >= minWidth); - return match?.cols ?? 1; +export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number { + if (itemCount <= 1) return 1; + if (isAdvanced && width >= 640) return Math.min(itemCount, 4); + + const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1; + const rowsAtWidth = Math.ceil(itemCount / widthMatch); + const rowHeight = height / rowsAtWidth; + return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2)); } -function getIconSize(width: number): number { +export function getIconSize(width: number): number { const match = iconSizeByWidth.find(({ minWidth }) => width >= minWidth); return match?.size ?? 16; } diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index f2e79f570..7ac60e0de 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -5,8 +5,10 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { icon: IconBadgeCc, + queryKey: [["widget", "bazarr"]], supportedIntegrations: ["bazarr"], integrationsRequired: true, + maxIntegrations: 1, createOptions() { return optionsBuilder.from((factory) => ({ showMissingEpisodes: factory.switch({ diff --git a/packages/widgets/src/bazarr/layout.spec.ts b/packages/widgets/src/bazarr/layout.spec.ts new file mode 100644 index 000000000..2401ce4e6 --- /dev/null +++ b/packages/widgets/src/bazarr/layout.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; + +import { getGridCols, getIconSize } from "./component"; + +describe("Bazarr responsive layout", () => { + test("uses two columns for a short widget so all stats remain visible", () => { + expect(getGridCols(220, 100, 4)).toBe(2); + }); + + test("uses four columns in a wide advanced surface", () => { + expect(getGridCols(800, 400, 4, true)).toBe(4); + }); + + test("sizes icons from the constrained dimension", () => { + expect(getIconSize(180)).toBe(16); + expect(getIconSize(340)).toBe(22); + }); +}); diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 3f934423d..f81835739 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -35,6 +35,8 @@ export default function BeszelAlertsWidget({ options, integrationIds, isEditMode, + height, + displayMode = "compact", }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); const alertsInput = useMemo( @@ -67,6 +69,8 @@ export default function BeszelAlertsWidget({ const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); + const showOkAlerts = displayMode === "advanced" || triggeredAlerts.length === 0 || height >= 260; + const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360); if (alertsError) throw alertsError; @@ -117,9 +121,9 @@ export default function BeszelAlertsWidget({
)} - {triggeredAlerts.length > 0 && okAlerts.length > 0 && } + {triggeredAlerts.length > 0 && showOkAlerts && okAlerts.length > 0 && } - {okAlerts.length > 0 && ( + {showOkAlerts && okAlerts.length > 0 && ( @@ -140,7 +144,7 @@ export default function BeszelAlertsWidget({ )} - {options.showHistory && history.length > 0 && ( + {showHistory && history.length > 0 && ( <> diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index 5e3799a90..f7dd6dee7 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -6,6 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, + queryKey: [["widget", "beszel"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index dd2f56c54..8359b2353 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -330,7 +330,9 @@ const metricRenderers = [ ] as const; const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { - const visibleMetrics = metricRenderers.filter((m) => m.visible(system, options)).slice(0, maxMetrics); + const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options)); + const visibleMetrics = enabledMetrics.slice(0, maxMetrics); + const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; return ( {visibleMetrics.map((m) => m.render(system, t, size))} + {hiddenMetricCount > 0 && ( + + +{hiddenMetricCount} + + )} ); }; @@ -372,6 +379,7 @@ export default function BeszelSystemGridWidget({ isEditMode, width, height, + displayMode = "compact", }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); @@ -411,14 +419,18 @@ export default function BeszelSystemGridWidget({ ); } - const cols = getColCount(width, height, filteredSystems.length); + const advanced = displayMode === "advanced"; + const cols = advanced + ? Math.min(filteredSystems.length, width >= 900 ? 2 : 1) + : getColCount(width, height, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const scrollEnabled = rawCellHeight < MIN_CELL_HEIGHT; - const effectiveCellHeight = scrollEnabled ? MIN_CELL_HEIGHT : rawCellHeight; + const minimumCellHeight = advanced ? 320 : MIN_CELL_HEIGHT; + const scrollEnabled = rawCellHeight < minimumCellHeight; + const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size); return ( @@ -428,7 +440,7 @@ export default function BeszelSystemGridWidget({ style={{ display: "grid", gridTemplateColumns: `repeat(${cols}, 1fr)`, - gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${MIN_CELL_HEIGHT}px)` : `repeat(${rows}, 1fr)`, + gridTemplateRows: scrollEnabled ? `repeat(${rows}, ${minimumCellHeight}px)` : `repeat(${rows}, 1fr)`, gap: size.gap, overflow: scrollEnabled ? "auto" : "hidden", }} diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index 91dcd7292..1b964c409 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -1,7 +1,7 @@ "use client"; import { useCallback, useMemo } from "react"; -import { Box, Button, Center, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core"; +import { Box, Button, Center, Loader, Menu, ScrollArea, SegmentedControl, Select, Stack, Text } from "@mantine/core"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -16,12 +16,15 @@ import type { WidgetComponentProps } from "../definition"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; +import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; export default function BeszelSystemStatsWidget({ options, integrationIds, isEditMode, width, + height, + displayMode = "compact", boardId, itemId, setOptions, @@ -37,13 +40,11 @@ export default function BeszelSystemStatsWidget({ error: systemsError, } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systems = useMemo( - () => systemsResult.flatMap((result) => result.systems.map((system) => ({ value: system.id, label: system.name }))), - [systemsResult], - ); - const selectedSystem = options.systemId || systems[0]?.value || ""; - const selectedLabel = systems.find((system) => system.value === selectedSystem)?.label; - const systemExists = systems.some((system) => system.value === selectedSystem); + const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); + const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId); + const selectedValue = selectedSystem?.value ?? ""; + const selectedLabel = selectedSystem?.label; + const systemExists = selectedSystem !== undefined; const handleSelectSystem = useCallback( (value: string) => { @@ -55,6 +56,16 @@ export default function BeszelSystemStatsWidget({ [setOptions, hasChangeAccess, boardId, itemId, saveItemOptions], ); + const handleTimePeriod = useCallback( + (value: string) => { + setOptions({ newOptions: { timePeriod: value as BeszelTimePeriod } }); + if (hasChangeAccess && boardId && itemId) { + saveItemOptions({ boardId, itemId, newOptions: { timePeriod: value } }); + } + }, + [boardId, hasChangeAccess, itemId, saveItemOptions, setOptions], + ); + if (systemsError) throw systemsError; if (systemsPending) return ( @@ -90,7 +101,7 @@ export default function BeszelSystemStatsWidget({