feat: modernize dashboard widgets

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-13 19:54:28 +02:00
1 parent 1a5e083fd0
commit 6f2cd7fc2b
270 files changed
+10189 -3544

No files matched your search

+20 -12
View File
@@ -1,31 +1,39 @@
# Product # Product
## Register
product
## Users ## Users
Homarr is used by self-hosters and administrators who assemble dashboards for services running across home labs and private infrastructure. They need dense, reliable controls and status at a glance, with enough guidance to configure advanced features without hiding how they work. Homarr is for self-hosters and administrators who assemble dashboards for services across home labs and private infrastructure. They need dense, reliable controls and status at a glance, while retaining enough guidance to configure advanced features without hiding how they work.
## Product Purpose ## Product Purpose
Homarr provides one place to organize, observe, and operate self-hosted services. Success means a user can build a useful dashboard quickly, understand its state, recover from configuration errors, and extend it without requiring a bespoke Homarr feature for every service. Boards must load immediately, stay visually stable, keep widgets legible across screen sizes, and make editing feel direct and forgiving. Homarr provides one place to organize, observe, and operate self-hosted services. Success means users can build useful dashboards quickly, understand their state, recover from configuration errors, act on services, and reshape their workspace without repeatedly leaving the board. Boards must load immediately, stay visually stable, and keep widgets legible across screen sizes.
## Brand Personality ## Brand Personality
Direct, capable, and approachable. Copy should be concise and technical when precision matters, while the interface should remain familiar to users who are not developers. Direct, capable, precise, and approachable. Homarr should feel calm under load, be truthful about system state, and reward users who refine details.
## Anti-references ## Anti-references
Avoid decorative admin dashboards, wizard flows that fight ordinary scrolling, hidden configuration magic, inconsistent controls, excessive modal usage, and interfaces that expose implementation complexity before the user needs it. - Decorative dashboards, oversized metric cards, and motion without state meaning.
- Wizard flows that fight ordinary scrolling or rigid layouts that force one workflow.
- Hidden configuration magic and implementation complexity exposed before it is needed.
- Clever custom controls where familiar platform or Mantine interactions already work.
## Design Principles ## Design Principles
1. Keep the user in control by making configuration and consequences visible. 1. Keep configuration, consequences, and system state visible.
2. Prefer familiar product patterns and progressive disclosure over custom interaction concepts. 2. Make simple tasks short without limiting advanced workflows.
3. Make simple tasks short without limiting advanced use cases. 3. Preserve expert depth while prioritizing urgent information at small sizes.
4. Isolate failures so one service or widget cannot disrupt the dashboard. 4. Reveal dense workspaces progressively through a consistent advanced focus mode.
5. Use the existing Mantine design language consistently across management and board surfaces. 5. Isolate failures and show loading, stale, partial, empty, and failed states truthfully.
6. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback. 6. Use the existing Mantine design language consistently across management and board surfaces.
7. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible. 7. Keep spatial actions direct: moving, resizing, nesting, and using fixed rails happen on the canvas with immediate feedback.
8. Preserve predictable widget composition with fixed logical tile sizes and horizontal scrolling before controls become inaccessible.
## Accessibility & Inclusion ## Accessibility & Inclusion
Target WCAG 2.2 AA. Support keyboard navigation, screen readers, visible focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations. Target WCAG 2.2 AA. Every pointer interaction needs a keyboard and coarse-pointer path. Support screen readers, visible and restored focus, sufficient contrast, reduced motion, non-color status cues, responsive layouts, actionable error messages, and keyboard equivalents for drag and resize operations.
@@ -6,16 +6,23 @@ tags:
Homarr has the following keyboard shortcuts: Homarr has the following keyboard shortcuts:
| Shortcut | Description | | Shortcut | Description |
| -------- | ----------- | | ------------------------------------------ | ---------------------------------------------------------- |
| `MOD` + `K` | Opens the search bar | | `MOD` + `K` | Opens the search bar |
| `MOD` + `J` | Toggles Dark Mode | | `MOD` + `J` | Toggles Dark Mode |
| `MOD` + `E` | Toggles Edit 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 :::tip
Here `MOD` refers to the modifier key, which is `Ctrl` on Windows and `Cmd` on macOS. 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 :::note
@@ -191,6 +191,11 @@ When entering the edit mode, you'll get some additional buttons to work with:
- **Add item**: Click this button to add new elements to your page. - **Add item**: Click this button to add new elements to your page.
- **Exit edit mode**: Click this button 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 that supports advanced view to expand it in place while the rest of the board
stays visible but dimmed. Release `Shift` to close it. You can also focus that widget and press `Shift` + `Enter` to
keep its advanced view open until you close it. Widgets without a distinct advanced experience adapt in place.
To continue, click the option "New Item": To continue, click the option "New Item":
<img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} /> <img src={manageBoardHeaderAddItem} alt={"Screenshot of the add item menu"} width={200} />
@@ -248,6 +248,20 @@ The styling will only be applied on the respective board page.
This will disable the status check for apps. 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 that supports advanced view to expand it in place while the other widgets
remain visible but dimmed. Release `Shift` to close it. Focus that widget and press `Shift` + `Enter` to keep the view
open until you close it. On touch devices, open it from the widget menu. Other widgets adapt in place instead.
- Right-click a widget to inspect its live query status, refresh its data, change quick options, open settings, or run
supported actions. Available controls follow your board and integration permissions.
- If you can modify the board, right-click or touch and hold an empty grid cell to add an item there. Focus a board
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 ### Access control
With the access control, you can permit a single user or a group to your board. With the access control, you can permit a single user or a group to your board.
+20 -16
View File
@@ -1,35 +1,37 @@
--- ---
title: 'Anchor Note' title: "Anchor Note"
description: 'Display and edit a selected note from Anchor' description: "Display and edit a selected note from Anchor"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { anchorNoteWidget } from '.';
import { anchorIntegration } from '@site/docs/integrations/anchor';
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";
<WidgetHeader <WidgetHeader widget={anchorNoteWidget} categories={["Notes"]} />
widget={anchorNoteWidget}
categories={['Notes']}
/>
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. 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 ### Screenshots
import anchorNoteWidgetScreenshot from './img/widget.png'; import anchorNoteWidgetScreenshot from "./img/widget.png";
<img src={anchorNoteWidgetScreenshot} alt="anchor-note-widget" width={256} height={256} /> <img src={anchorNoteWidgetScreenshot} alt="anchor-note-widget" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: anchorIntegration items={[
}]} /> {
integration: anchorIntegration,
},
]}
/>
### Interactions ### Interactions
@@ -37,6 +39,8 @@ import anchorNoteWidgetScreenshot from './img/widget.png';
- Click **Save** to persist changes to Anchor. - Click **Save** to persist changes to Anchor.
- Click **Cancel** to discard unsaved changes. - Click **Cancel** to discard unsaved changes.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+15 -14
View File
@@ -1,37 +1,38 @@
--- ---
title: 'App' title: "App"
description: 'Embeds an app into the board.' description: "Embeds an app into the board."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { appWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
widget={appWidget}
categories={['Links', 'Apps']}
/>
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. 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. It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots ### Screenshots
import githubApp from './img/github.png'; import githubApp from "./img/github.png";
import jellyseerrApp from './img/jellyseerr-description-tooltip.png'; import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
import piHoleApp from './img/pi-hole-ping-icon.png'; import piHoleApp from "./img/pi-hole-ping-icon.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} /> <img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} /> <img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} /> <img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={appWidget.configuration} /> <WidgetConfig configuration={appWidget.configuration} />
@@ -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 ".";
<WidgetHeader widget={archiveTeamWarriorWidget} categories={["Archiving", "Monitoring"]} />
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
<WidgetIntegrations
items={[
{
integration: archiveTeamWarriorIntegration,
note: "Shows status and active archival work from one Warrior instance",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={archiveTeamWarriorWidget.configuration} />
@@ -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",
},
],
},
};
@@ -4,6 +4,8 @@ description: "Displays media and listening statistics from Navidrome or Audioboo
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -36,6 +38,8 @@ Only options relevant to your linked integration are shown in the configuration
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+23 -17
View File
@@ -1,26 +1,25 @@
--- ---
title: 'Bazarr' title: "Bazarr"
description: 'Displays missing subtitle counts and health indicators from your Bazarr instance.' description: "Displays missing subtitle counts and health indicators from your Bazarr instance."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { bazarrWidget } from '.';
import { bazarrIntegration } from '@site/docs/integrations/bazarr';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={bazarrWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Subtitles', 'Media']} 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";
<WidgetHeader widget={bazarrWidget} categories={["Subtitles", "Media"]} />
This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings. This widget shows subtitle status from your Bazarr instance, including missing episode and movie subtitles, provider issues, and health warnings.
### Screenshots ### Screenshots
import widgetOverview from './img/widget-overview.png'; import widgetOverview from "./img/widget-overview.png";
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} /> <img src={widgetOverview} alt="bazarr-widget-overview" width={128 * 4} />
@@ -28,14 +27,21 @@ import widgetOverview from './img/widget-overview.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: bazarrIntegration, items={[
note: "Displays missing subtitle counts and health indicators" {
}]} /> integration: bazarrIntegration,
note: "Displays missing subtitle counts and health indicators",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={bazarrWidget.configuration} /> <WidgetConfig configuration={bazarrWidget.configuration} />
+23 -17
View File
@@ -1,22 +1,21 @@
--- ---
title: 'Beszel Alerts' title: "Beszel Alerts"
description: 'View Beszel alert configurations and history with triggered/ok status indicators.' description: "View Beszel alert configurations and history with triggered/ok status indicators."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { beszelAlertsWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
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";
<WidgetHeader import beszelAlerts from "./img/beszel-alerts.png";
widget={beszelAlertsWidget}
categories={['System Monitoring', 'Alerts']} <WidgetHeader widget={beszelAlertsWidget} categories={["System Monitoring", "Alerts"]} />
/>
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. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: beszelIntegration, items={[
note: "Displays alert configurations and trigger history" {
}]} /> integration: beszelIntegration,
note: "Displays alert configurations and trigger history",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={beszelAlertsWidget.configuration} /> <WidgetConfig configuration={beszelAlertsWidget.configuration} />
@@ -1,22 +1,21 @@
--- ---
title: 'Beszel Systems (Grid)' title: "Beszel Systems (Grid)"
description: 'Card grid view of all Beszel-monitored systems with real-time metrics.' description: "Card grid view of all Beszel-monitored systems with real-time metrics."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { beszelSystemGridWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
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";
<WidgetHeader import beszelSystemGrid from "./img/beszel-system-grid.png";
widget={beszelSystemGridWidget}
categories={['System Monitoring']} <WidgetHeader widget={beszelSystemGridWidget} categories={["System Monitoring"]} />
/>
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. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: beszelIntegration, items={[
note: "Displays all monitored systems as a card grid" {
}]} /> integration: beszelIntegration,
note: "Displays all monitored systems as a card grid",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={beszelSystemGridWidget.configuration} /> <WidgetConfig configuration={beszelSystemGridWidget.configuration} />
@@ -4,6 +4,8 @@ description: "Time-series charts for CPU, memory, disk, network, and Docker cont
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -32,6 +34,8 @@ This widget displays time-series charts for a selected Beszel system, including
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
@@ -1,21 +1,19 @@
--- ---
title: 'Beszel Systems (Table)' title: "Beszel Systems (Table)"
description: 'Table view of all Beszel-monitored systems with sortable columns and status indicators.' description: "Table view of all Beszel-monitored systems with sortable columns and status indicators."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { beszelSystemTableWidget } from '.';
import { beszelIntegration } from '@site/docs/integrations/beszel';
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";
<WidgetHeader <WidgetHeader widget={beszelSystemTableWidget} categories={["System Monitoring"]} />
widget={beszelSystemTableWidget}
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk. This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
@@ -30,14 +28,21 @@ Select a system row to open the complete statistics view, including system, file
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: beszelIntegration, items={[
note: "Displays all monitored systems in a sortable table" {
}]} /> integration: beszelIntegration,
note: "Displays all monitored systems in a sortable table",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={beszelSystemTableWidget.configuration} /> <WidgetConfig configuration={beszelSystemTableWidget.configuration} />
+15 -14
View File
@@ -1,29 +1,27 @@
--- ---
title: 'Bookmarks' title: "Bookmarks"
description: 'Displays multiple app links' description: "Displays multiple app links"
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { bookmarksWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
widget={bookmarksWidget}
categories={['Links', 'Apps']}
/>
The bookmarks widget allows you to display multiple app links in a single widget. 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. You can add links to your favorite applications, websites, or any other resources you want quick access to.
### Screenshots ### Screenshots
import gridIconsOnly from './img/grid-icons-only.png'; import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from './img/grid-with-title.png'; import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from './img/vertical-list-all.png'; import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from './img/vertical-list-simple.png'; import vericalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'> <div className='flex gap-1'>
<div className='flex flex-col gap-1'> <div className='flex flex-col gap-1'>
@@ -33,12 +31,15 @@ import vericalListSimple from './img/vertical-list-simple.png';
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} /> <img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} /> <img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={bookmarksWidget.configuration} /> <WidgetConfig configuration={bookmarksWidget.configuration} />
+56 -45
View File
@@ -1,65 +1,73 @@
--- ---
title: 'Calendar' title: "Calendar"
description: 'Display events from your integrations in a calendar view within a certain relative time period' description: "Display events from your integrations in a calendar view within a certain relative time period"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { calendarWidget } from '.'; import { calendarWidget } from ".";
import { lidarrIntegration } from '@site/docs/integrations/lidarr'; import { lidarrIntegration } from "@site/docs/integrations/lidarr";
import { nextcloudIntegration } from '@site/docs/integrations/nextcloud'; import { nextcloudIntegration } from "@site/docs/integrations/nextcloud";
import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant";
import { radarrIntegration } from '@site/docs/integrations/radarr'; import { radarrIntegration } from "@site/docs/integrations/radarr";
import { readarrIntegration } from '@site/docs/integrations/readarr'; import { readarrIntegration } from "@site/docs/integrations/readarr";
import { sonarrIntegration } from '@site/docs/integrations/sonarr'; import { sonarrIntegration } from "@site/docs/integrations/sonarr";
import { icalIntegration } from '@site/docs/integrations/ical'; import { icalIntegration } from "@site/docs/integrations/ical";
<WidgetHeader widget={calendarWidget} categories={["Media manager", "Servarr"]} />
<WidgetHeader
widget={calendarWidget}
categories={['Media manager', "Servarr"]}
/>
The calendar widget shows a month view of events from different integrations. 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. It allows you to view calendar events or upcoming releases from your media managers.
### Screenshots ### Screenshots
import popover from './img/popover.png'; import popover from "./img/popover.png";
import withIntegrations from './img/with-integrations.png'; import withIntegrations from "./img/with-integrations.png";
import withoutIntegrations from './img/without-integrations.png'; import withoutIntegrations from "./img/without-integrations.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={withIntegrations} alt="calendar with-integrations big-size" width={256 + 4} height={256 + 4} /> <img src={withIntegrations} alt="calendar with-integrations big-size" width={256 + 4} height={256 + 4} />
<div className='flex flex-col gap-1'> <div className="flex flex-col gap-1">
<img src={popover} alt="calendar popover-details the-mandalorian" width={128 * 3} height={128} /> <img src={popover} alt="calendar popover-details the-mandalorian" width={128 * 3} height={128} />
<img src={withoutIntegrations} alt="calendar without-integrations small-size" width={256} height={128} /> <img src={withoutIntegrations} alt="calendar without-integrations small-size" width={256} height={128} />
</div> </div>
</div> </div>
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: nextcloudIntegration, items={[
}, { {
integration: lidarrIntegration, integration: nextcloudIntegration,
}, { },
integration: radarrIntegration, {
}, { integration: lidarrIntegration,
integration: readarrIntegration, },
}, { {
integration: sonarrIntegration, integration: radarrIntegration,
}, { },
integration: icalIntegration {
}, { integration: readarrIntegration,
integration: homeAssistantIntegration },
}]} /> {
integration: sonarrIntegration,
},
{
integration: icalIntegration,
},
{
integration: homeAssistantIntegration,
},
]}
/>
### Interactions ### 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 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. - Click the date again or click outside to close the pinned popover.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={calendarWidget.configuration} /> <WidgetConfig configuration={calendarWidget.configuration} />
+17 -17
View File
@@ -1,38 +1,38 @@
--- ---
title: 'Date and time' title: "Date and time"
description: 'Displays the current date and time.' description: "Displays the current date and time."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { clockWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={clockWidget} categories={["Time"]} />
widget={clockWidget}
categories={['Time']}
/>
The clock widget displays the current date and time in a customizable format. The clock widget displays the current date and time in a customizable format.
### Screenshots ### Screenshots
import localClock from './img/local.png'; import localClock from "./img/local.png";
import londonClock from './img/london-with-seconds.png'; import londonClock from "./img/london-with-seconds.png";
import tokyoClock from './img/tokyo-12-hour.png'; import tokyoClock from "./img/tokyo-12-hour.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={localClock} alt="clock format-24-hours timezone-local" width={128} height={128} /> <img src={localClock} alt="clock format-24-hours timezone-local" width={128} height={128} />
<img src={londonClock} alt="clock format-24-hours with-seconds timezone-london" width={256} height={128} /> <img src={londonClock} alt="clock format-24-hours with-seconds timezone-london" width={256} height={128} />
<img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} /> <img src={tokyoClock} alt="clock format-12-hours timezone-tokyo" width={256} height={128} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={clockWidget.configuration} /> <WidgetConfig configuration={clockWidget.configuration} />
+24 -19
View File
@@ -1,28 +1,33 @@
--- ---
title: 'Coolify' title: "Coolify"
description: 'Overview of your Coolify instance with servers, applications, and services.' description: "Overview of your Coolify instance with servers, applications, and services."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { coolifyWidget } from '.';
import { coolifyIntegration } from '@site/docs/integrations/coolify';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={coolifyWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['System monitoring', 'Deployment']} 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";
<WidgetHeader widget={coolifyWidget} categories={["System monitoring", "Deployment"]} />
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. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: coolifyIntegration, items={[
}]} /> {
integration: coolifyIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
@@ -34,11 +39,11 @@ The Coolify widget displays a compact overview of your Coolify instance. It can
### Sections ### Sections
| Section | Description | | Section | Description |
|---|---| | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
| **Servers** | Lists Coolify servers, reachability status, and application/service counts per server. | | **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. | | **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 ### Notes
+8 -7
View File
@@ -1,24 +1,26 @@
--- ---
title: "Custom API" title: "Custom API"
description: "The Custom API widget shows a Custom JSX v2 definition with server-side API requests, widget options, and actions." description: "Displays data from a custom API endpoint using configurable display types and optional JSX templates."
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { customApiWidget } from "."; import { customApiWidget } from ".";
<WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} /> <WidgetHeader widget={customApiWidget} categories={["Custom", "API"]} />
The Custom API widget shows a Custom JSX v2 definition. Create the definition in [Management → Custom Widgets](/docs/management/custom-widgets).
A definition contains API sources, named requests, a widget options schema, and one safe JSX template. A definition contains API sources, named requests, a widget options schema, and one safe JSX template.
Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately. Temporary inputs exist only while the tile is mounted. Homarr encrypts source credentials separately.
Homarr does not send these credentials to the browser. Homarr does not include them in an export. Homarr does not send these credentials to the browser. Homarr does not include them in an export.
### Add the widget <WidgetInteractionGuide advancedView={false} />
### Adding the widget
<AddingWidget /> <AddingWidget />
@@ -29,9 +31,8 @@ Select a refresh interval. The board item stores the definition ID and the valid
Credentials remain on the server. Credentials remain on the server.
:::note :::note
Custom API widgets must first be created in **Management → Custom Widgets** before they appear in the widget picker.
Enable the definition before you use it. The definition must also be available to the board. Action buttons can only be executed by administrators.
::: :::
### Permissions and actions ### Permissions and actions
@@ -1,57 +1,64 @@
--- ---
title: 'DNS Hole Controls' title: "DNS Hole Controls"
description: "Control PiHole or AdGuard from your dashboard." description: "Control PiHole or AdGuard from your dashboard."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { dnsHoleControlsWidget } from '.'; import { dnsHoleControlsWidget } from ".";
import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home";
import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; import { piHoleIntegration } from "@site/docs/integrations/pi-hole";
import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns";
<WidgetHeader widget={dnsHoleControlsWidget} categories={["DNS Hole"]} />
<WidgetHeader 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.
widget={dnsHoleControlsWidget}
categories={['DNS Hole']}
/>
This widget allows you to enable or disable your DNS Hole.
### Screenshots ### Screenshots
import bigSize from './img/big.png'; import bigSize from "./img/big.png";
import smallSize from './img/small.png'; import smallSize from "./img/small.png";
<img src={bigSize} alt="dns-hole-controls big-size with-toggle-all-buttons" width={256} height={128} /> <img src={bigSize} alt="dns-hole-controls big-size with-toggle-all-buttons" width={256} height={128} />
<img src={smallSize} alt="dns-hole-controls small-size no-toggle-all-buttons" width={128} height={128} /> <img src={smallSize} alt="dns-hole-controls small-size no-toggle-all-buttons" width={128} height={128} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: adguardHomeIntegration, items={[
}, { {
integration: piHoleIntegration, integration: adguardHomeIntegration,
}, { },
integration: technitiumDnsIntegration, {
}]} /> integration: piHoleIntegration,
},
{
integration: technitiumDnsIntegration,
},
]}
/>
### Interactions ### Interactions
- Enable / Disable one dns-hole by clicking on the badge. - Enable or disable one DNS service by clicking its status badge.
- Disable one dns-hole in x seconds by clicking on the clock icon. - Temporarily disable one service with the clock action.
- Do all of the above for all dns-holes by using the top button row. - 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.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={dnsHoleControlsWidget.configuration} /> <WidgetConfig configuration={dnsHoleControlsWidget.configuration} />
@@ -1,38 +1,36 @@
--- ---
title: 'DNS Hole Summary' title: "DNS Hole Summary"
description: "Displays the summary of your DNS Hole." description: "Displays the summary of your DNS Hole."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { dnsHoleSummaryWidget } from '.'; import { dnsHoleSummaryWidget } from ".";
<WidgetHeader widget={dnsHoleSummaryWidget} categories={["DNS Hole"]} />
<WidgetHeader 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.
widget={dnsHoleSummaryWidget}
categories={['DNS Hole']}
/>
This widget allows you to display statistics about your DNS Hole like total queries, blocked queries, and more.
### Screenshots ### Screenshots
import gridLayout from './img/grid.png'; import gridLayout from "./img/grid.png";
import verticalLayout from './img/vertical.png'; import verticalLayout from "./img/vertical.png";
import horizontalLayout from './img/horizontal.png'; import horizontalLayout from "./img/horizontal.png";
import { adguardHomeIntegration } from '@site/docs/integrations/adguard-home'; import { adguardHomeIntegration } from "@site/docs/integrations/adguard-home";
import { piHoleIntegration } from '@site/docs/integrations/pi-hole'; import { piHoleIntegration } from "@site/docs/integrations/pi-hole";
import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns'; import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns";
<div className='flex gap-2'> <div className="flex gap-2">
<img src={gridLayout} alt="grid-layout pi-hole-colors" width={256 + 8} height={256 + 8} /> <img src={gridLayout} alt="grid-layout pi-hole-colors" width={256 + 8} height={256 + 8} />
<div className='flex flex-col gap-2'> <div className="flex flex-col gap-2">
<img src={verticalLayout} alt="vertical-layout no-colors" width={128} height={128} /> <img src={verticalLayout} alt="vertical-layout no-colors" width={128} height={128} />
<img src={horizontalLayout} alt="horizontal-layout pi-hole-colors" width={128 * 3} height={128} /> <img src={horizontalLayout} alt="horizontal-layout pi-hole-colors" width={128 * 3} height={128} />
</div> </div>
@@ -40,17 +38,26 @@ import { technitiumDnsIntegration } from '@site/docs/integrations/technitium-dns
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: adguardHomeIntegration, items={[
}, { {
integration: piHoleIntegration, integration: adguardHomeIntegration,
}, { },
integration: technitiumDnsIntegration, {
}]} /> integration: piHoleIntegration,
},
{
integration: technitiumDnsIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={dnsHoleSummaryWidget.configuration} /> <WidgetConfig configuration={dnsHoleSummaryWidget.configuration} />
@@ -1,24 +1,20 @@
--- ---
title: 'Docker stats' title: "Docker stats"
description: 'Stats of your containers' description: "Stats of your containers"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from '@site/src/components/widgets/adding'; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from '@site/src/components/widgets/configuration'; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { IconAd } from "@tabler/icons-react"; import { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { dockerContainersWidget } from '.'; import { dockerContainersWidget } from ".";
import { dockerIntegration } from '@site/docs/integrations/docker'; import { dockerIntegration } from "@site/docs/integrations/docker";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
<WidgetHeader
widget={dockerContainersWidget}
categories={['Containers']}
/>
This widget displays the resource usage of your Docker containers and allows you to manage them. This widget displays the resource usage of your Docker containers and allows you to manage them.
@@ -28,15 +24,19 @@ This widget can only be used with administrator privileges.
### Screenshots ### Screenshots
import dockerTable from './img/table.png'; import dockerTable from "./img/table.png";
<img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} /> <img src={dockerTable} alt="docker-stats table docker-containers" width={128 * 3} height={128 * 2} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: dockerIntegration, items={[
}]} /> {
integration: dockerIntegration,
},
]}
/>
### Interactions ### Interactions
+9 -8
View File
@@ -1,9 +1,11 @@
--- ---
title: 'Download Client' title: "Download Client"
description: 'Allows you to view and manage your Downloads from both Torrent and Usenet clients.' description: "Allows you to view and manage your Downloads from both Torrent and Usenet clients."
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { aria2Integration } from "@site/docs/integrations/aria2"; import { aria2Integration } from "@site/docs/integrations/aria2";
import { delugeIntegration } from "@site/docs/integrations/deluge"; import { delugeIntegration } from "@site/docs/integrations/deluge";
import { nzbgetIntegration } from "@site/docs/integrations/nzbget"; import { nzbgetIntegration } from "@site/docs/integrations/nzbget";
@@ -17,11 +19,7 @@ import { WidgetHeader } from "@site/src/components/widgets/header";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { downloadsWidget } from "."; import { downloadsWidget } from ".";
<WidgetHeader widget={downloadsWidget} categories={["Torrent client", "Usenet client", "Downloads"]} />
<WidgetHeader
widget={downloadsWidget}
categories={['Torrent client', 'Usenet client', 'Downloads']}
/>
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. 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.
@@ -47,7 +45,7 @@ import contextMenuImg from "./img/context-menu.png";
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget - **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu - **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal - **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category - **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category; open tooltips close when switching between compact and advanced mode
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts - **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel - **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar - **Global controls** — pause/resume all downloads across all clients from the footer bar
@@ -99,9 +97,12 @@ import contextMenuImg from "./img/context-menu.png";
- **Filter** by integration or status using the filter button in the footer - **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction) - **Sort** by clicking column headers (with persistent sort direction)
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={downloadsWidget.configuration} /> <WidgetConfig configuration={downloadsWidget.configuration} />
+20 -16
View File
@@ -1,22 +1,20 @@
--- ---
title: 'Firewall Monitoring' title: "Firewall Monitoring"
description: 'Displays a summary of firewalls.' description: "Displays a summary of firewalls."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { 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";
<WidgetHeader <WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
widget={firewallWidget}
categories={['Firewall']}
/>
### Screenshots ### Screenshots
@@ -24,9 +22,15 @@ import { opnsenseIntegration } from '../../integrations/opnsense';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: opnsenseIntegration, items={[
}]} /> {
integration: opnsenseIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
@@ -4,6 +4,8 @@ description: "Displays information showing the health and status of your system(
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -62,6 +64,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+20 -15
View File
@@ -1,19 +1,17 @@
--- ---
title: 'iFrame' title: "iFrame"
description: 'Embed any content from the internet. Some websites may restrict access.' description: "Embed any content from the internet. Some websites may restrict access."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { iframeWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={iframeWidget} categories={["Embed"]} />
widget={iframeWidget}
categories={['Embed']}
/>
If your application is not supported natively by Homarr, you can integrate it using the iframe widget. 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), 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 ### Screenshots
import wikipediaHomepage from './img/wikipedia.png'; import wikipediaHomepage from "./img/wikipedia.png";
import youtubeStream from './img/nasa-iss-stream.png'; import youtubeStream from "./img/nasa-iss-stream.png";
<div> <div>
<img src={wikipediaHomepage} alt="iframe wikipedia-homepage" width={128 * 3} /> <img src={wikipediaHomepage} alt="iframe wikipedia-homepage" width={128 * 3} />
<img src={youtubeStream} alt="iframe youtube-iss-stream" width={128 * 3} /> <img src={youtubeStream} alt="iframe youtube-iss-stream" width={128 * 3} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
@@ -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: For example, to allow your site to be embedded in iframes from specific domains:
**Apache (.htaccess or httpd.conf):** **Apache (.htaccess or httpd.conf):**
```apache ```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com" Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com" Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
``` ```
**Nginx:** **Nginx:**
```nginx ```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"; add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM 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 - **Any YouTube video or stream:** https://www.youtube.com/embed/dQw4w9WgXcQ?si=MWUbbG66UNTla_Ni
### Configuration ### Configuration
<WidgetConfig configuration={iframeWidget.configuration} /> <WidgetConfig configuration={iframeWidget.configuration} />
### Transparent iframes ### Transparent iframes
@@ -75,15 +78,16 @@ To create a transparent iframe like below you can use the following html code an
```html ```html
<html style="color-scheme: dark;"> <html style="color-scheme: dark;">
<body style="background: none; display: flex; justify-content: center; align-items: center;"> <body style="background: none; display: flex; justify-content: center; align-items: center;">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg" width="90" height="90" /> <img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg" width="90" height="90" />
</body> </body>
</html> </html>
``` ```
![transparent iframe](./img/transparent-iframe.png) ![transparent iframe](./img/transparent-iframe.png)
### Third party content ### Third party content
:::warning :::warning
The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository. 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 ### 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: 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. - [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. - [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") ![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")
@@ -4,37 +4,39 @@ description: "Shows a slideshow of pictures from an Immich album or your full li
hide_title: true hide_title: true
--- ---
import { immichIntegration } from '../../integrations/immich';import { import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { 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 ".";
<WidgetHeader <WidgetHeader widget={immichAlbumCarouselWidget} categories={["Photos Service"]} />
widget={immichAlbumCarouselWidget}
categories={['Photos Service']}
/>
### Screenshots ### Screenshots
import screen from './img/screen.png'; import screen from "./img/screen.png";
<img src={screen} height={256} alt="immich album size-big" /> <img src={screen} height={256} alt="immich album size-big" />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: immichIntegration, items={[
}]} /> {
integration: immichIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={immichAlbumCarouselWidget.configuration} /> <WidgetConfig configuration={immichAlbumCarouselWidget.configuration} />
@@ -1,40 +1,42 @@
--- ---
title: 'Immich Server Stats' title: "Immich Server Stats"
description: 'Information about your Immich instance' description: "Information about your Immich instance"
hide_title: true hide_title: true
--- ---
import { immichIntegration } from '../../integrations/immich'; 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 { immichServerStatsWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
widget={immichServerStatsWidget}
categories={['Photos server']}
/>
### Screenshots ### Screenshots
import screen from './img/screen.png'; import screen from "./img/screen.png";
<img src={screen} height={256} alt="immich server stats size-big" /> <img src={screen} height={256} alt="immich server stats size-big" />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: immichIntegration, items={[
}]} /> {
integration: immichIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={immichServerStatsWidget.configuration} />
<WidgetConfig configuration={immichServerStatsWidget.configuration} />
@@ -1,47 +1,50 @@
--- ---
title: 'Indexer manager status' title: "Indexer manager status"
description: 'View the status of your indexers.' description: "View the status of your indexers."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { 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 ".";
<WidgetHeader <WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
widget={indexerManagerWidget}
categories={['Indexer']}
/>
### Screenshots ### Screenshots
import bigItem from './img/big.png'; import bigItem from "./img/big.png";
import smallItem from './img/small.png'; import smallItem from "./img/small.png";
import { prowlarrIntegration } from '@site/docs/integrations/prowlarr'; import { prowlarrIntegration } from "@site/docs/integrations/prowlarr";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={bigItem} height={256} alt="indexer-manager size-big" /> <img src={bigItem} height={256} alt="indexer-manager size-big" />
<img src={smallItem} height={128} alt="indexer-manager size-small" /> <img src={smallItem} height={128} alt="indexer-manager size-small" />
</div> </div>
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: prowlarrIntegration, items={[
}]} /> {
integration: prowlarrIntegration,
},
]}
/>
### Interactions ### Interactions
- Test all indexers by clicking on the test button. - Test all indexers by clicking on the test button.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={indexerManagerWidget.configuration} />
<WidgetConfig configuration={indexerManagerWidget.configuration} />
+30 -24
View File
@@ -1,29 +1,28 @@
--- ---
title: 'Media Missing & Queue' title: "Media Missing & Queue"
description: 'Track missing media and active downloads from Radarr and Sonarr' description: "Track missing media and active downloads from Radarr and Sonarr"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={mediaMissingWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Media', 'Downloads']} 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";
<WidgetHeader widget={mediaMissingWidget} categories={["Media", "Downloads"]} />
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. 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 ### Screenshots
import widgetMissing from './img/missing.png'; import widgetMissing from "./img/missing.png";
import widgetQueued from './img/queued.png'; import widgetQueued from "./img/queued.png";
import widgetCompact from './img/compact.png'; import widgetCompact from "./img/compact.png";
<div className="flex gap-2 flex-wrap"> <div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} /> <img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
@@ -33,18 +32,23 @@ import widgetCompact from './img/compact.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: radarrIntegration, items={[
}, { {
integration: sonarrIntegration, integration: radarrIntegration,
}]} /> },
{
integration: sonarrIntegration,
},
]}
/>
### Tabs ### Tabs
| Tab | Description | | Tab | Description |
|---|---| | ----------- | ----------------------------------------------------------------------------------------------------- |
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. | | **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 ### 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. 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.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+30 -23
View File
@@ -1,30 +1,28 @@
--- ---
title: 'Media releases' title: "Media releases"
description: 'Display newly added medias or upcoming releases from different integrations' description: "Display newly added medias or upcoming releases from different integrations"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { mediaReleasesWidget } from '.';
import { embyIntegration } from '@site/docs/integrations/emby';
import { jellyfinIntegration } from '@site/docs/integrations/jellyfin';
import { plexIntegration } from '@site/docs/integrations/plex';
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";
<WidgetHeader <WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
widget={mediaReleasesWidget}
categories={['Releases', 'Media']}
/>
Provides a list of newly added media or upcoming releases from your media server. Provides a list of newly added media or upcoming releases from your media server.
### Screenshots ### Screenshots
import backdrop from './img/backdrop.png'; import backdrop from "./img/backdrop.png";
import poster from './img/poster.png'; import poster from "./img/poster.png";
<div className="flex gap-1"> <div className="flex gap-1">
<img src={backdrop} alt="media-releases layout-backdrop" width={256} /> <img src={backdrop} alt="media-releases layout-backdrop" width={256} />
@@ -33,17 +31,26 @@ import poster from './img/poster.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: embyIntegration, items={[
}, { {
integration: jellyfinIntegration, integration: embyIntegration,
}, { },
integration: plexIntegration, {
}]} /> integration: jellyfinIntegration,
},
{
integration: plexIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaReleasesWidget.configuration} /> <WidgetConfig configuration={mediaReleasesWidget.configuration} />
@@ -4,6 +4,8 @@ description: "See a list of all media requests from your integration"
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; 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. - 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. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
@@ -1,39 +1,42 @@
--- ---
title: 'Media Requests Stats' title: "Media Requests Stats"
description: 'Statistics about your media requests' description: "Statistics about your media requests"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { mediaRequestStatsWidget } from '.'; import { mediaRequestStatsWidget } from ".";
import { jellyseerrIntegration } from '@site/docs/integrations/jellyseerr'; import { jellyseerrIntegration } from "@site/docs/integrations/jellyseerr";
import { overseerrIntegration } from '@site/docs/integrations/overseerr'; import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
<WidgetHeader
widget={mediaRequestStatsWidget}
categories={['Media requests']}
/>
Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections. Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: jellyseerrIntegration, items={[
}, { {
integration: overseerrIntegration, integration: jellyseerrIntegration,
}]} /> },
{
integration: overseerrIntegration,
},
]}
/>
### Screenshots ### Screenshots
import mediaRequestStats from './img/stats.png'; import mediaRequestStats from "./img/stats.png";
<img src={mediaRequestStats} alt="media-request-stats" width={256} height={256} /> <img src={mediaRequestStats} alt="media-request-stats" width={256} height={256} />
@@ -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. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+16 -15
View File
@@ -1,13 +1,15 @@
--- ---
title: 'Media server streams' title: "Media server streams"
description: 'Show the current streams on your media servers' description: "Show the current streams on your media servers"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from "@theme/Tabs"; import Tabs from "@theme/Tabs";
import TabItem from "@theme/TabItem"; import TabItem from "@theme/TabItem";
@@ -17,20 +19,18 @@ import { jellyfinIntegration } from "@site/docs/integrations/jellyfin";
import { navidromeIntegration } from "@site/docs/integrations/navidrome"; import { navidromeIntegration } from "@site/docs/integrations/navidrome";
import { plexIntegration } from "@site/docs/integrations/plex"; import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
<WidgetHeader The media server widget shows active sessions across your media servers.
widget={mediaServerWidget} It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
categories={['Media server']}
/>
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.
Select a session to inspect its media metadata in an anchored popover. Advanced mode adds search, sorting, location, bitrate, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable. Compact visibility switches do not hide location or bitrate from the complete advanced session view.
If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
Sessions without user metadata remain visible and are labeled as an unknown user.
### Screenshots ### Screenshots
import mediaTable from './img/table.png'; import mediaTable from "./img/table.png";
<img src={mediaTable} alt="media-server-streams" width={128 * 3 * 1.5} height={128 * 1.5} /> <img src={mediaTable} alt="media-server-streams" width={128 * 3 * 1.5} height={128 * 1.5} />
@@ -54,6 +54,7 @@ import mediaTable from './img/table.png';
}, },
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
@@ -1,35 +1,31 @@
--- ---
title: 'Media transcoding' title: "Media transcoding"
description: 'Statistics, current queue and worker status of your media transcoding' description: "Statistics, current queue and worker status of your media transcoding"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { mediaTranscodingWidget } from '.'; import { mediaTranscodingWidget } from ".";
import { tdarrIntegration } from '@site/docs/integrations/tdarr'; import { tdarrIntegration } from "@site/docs/integrations/tdarr";
<WidgetHeader widget={mediaTranscodingWidget} categories={["Media transcoding"]} />
<WidgetHeader The media transcoding widget provides you with an overview of your media transcoding status.
widget={mediaTranscodingWidget}
categories={['Media transcoding']}
/>
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. It shows you the current queue, worker status, and statistics about your transcoding jobs.
### Screenshots ### Screenshots
import queueCard from './img/queue.png'; import queueCard from "./img/queue.png";
import workersCard from './img/workers.png'; import workersCard from "./img/workers.png";
import statisticsCard from './img/statistics.png'; import statisticsCard from "./img/statistics.png";
<div className="flex gap-1 flex-wrap"> <div className="flex gap-1 flex-wrap">
<img src={workersCard} alt="media-transcoding workers-tab" width={128 * 3} /> <img src={workersCard} alt="media-transcoding workers-tab" width={128 * 3} />
@@ -39,13 +35,20 @@ import statisticsCard from './img/statistics.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: tdarrIntegration, items={[
}]} /> {
integration: tdarrIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={mediaTranscodingWidget.configuration} /> <WidgetConfig configuration={mediaTranscodingWidget.configuration} />
@@ -1,44 +1,45 @@
--- ---
title: 'Minecraft Server Status' title: "Minecraft Server Status"
description: 'Displays the status of a Minecraft server.' description: "Displays the status of a Minecraft server."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
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 { 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 { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import { minecraftServerStatusWidget } from '.'; import { minecraftServerStatusWidget } from ".";
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
<WidgetHeader
widget={minecraftServerStatusWidget}
categories={['Gaming', 'Minecraft']}
/>
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). 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 ### Screenshots
import javaItem from './img/java.png'; import javaItem from "./img/java.png";
import bedrockItem from './img/bedrock.png'; import bedrockItem from "./img/bedrock.png";
<div className='flex gap-1'> <div className="flex gap-1">
<img src={javaItem} alt="minecraft-server-status java-edition" width={128 * 1.5} height={128 * 1.5} /> <img src={javaItem} alt="minecraft-server-status java-edition" width={128 * 1.5} height={128 * 1.5} />
<img src={bedrockItem} alt="minecraft-server-status bedrock-editon" width={128 * 1.5} height={128 * 1.5} /> <img src={bedrockItem} alt="minecraft-server-status bedrock-editon" width={128 * 1.5} height={128 * 1.5} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={minecraftServerStatusWidget.configuration} /> <WidgetConfig configuration={minecraftServerStatusWidget.configuration} />
### Data source ### Data source
We use the Minecraft Server Status API to fetch the server status. 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` If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
@@ -1,44 +1,49 @@
--- ---
title: 'Network Status' title: "Network Status"
description: 'Display connected devices on a network' description: "Display connected devices on a network"
hide_title: true hide_title: true
--- ---
import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; 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 { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
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 { 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 { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import { networkControllerStatusWidget } from '.'; import { networkControllerStatusWidget } from ".";
<WidgetHeader widget={networkControllerStatusWidget} categories={["Network controller"]} />
<WidgetHeader
widget={networkControllerStatusWidget}
categories={['Network controller']}
/>
Shows connected users and connected guest devices on wired or wireless networks. Shows connected users and connected guest devices on wired or wireless networks.
### Screenshots ### Screenshots
import wifiCard from './img/wifi.png'; import wifiCard from "./img/wifi.png";
import wiredCard from './img/wired.png'; import wiredCard from "./img/wired.png";
<img src={wifiCard} alt="network-controller-status wifi" width={128 * 1.5} height={128 * 1.5} /> <img src={wifiCard} alt="network-controller-status wifi" width={128 * 1.5} height={128 * 1.5} />
<img src={wiredCard} alt="network-controller-status wired" width={128 * 1.5} height={128 * 1.5} /> <img src={wiredCard} alt="network-controller-status wired" width={128 * 1.5} height={128 * 1.5} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: unifiControllerIntegration, items={[
}]} /> {
integration: unifiControllerIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={networkControllerStatusWidget.configuration} /> <WidgetConfig configuration={networkControllerStatusWidget.configuration} />
@@ -1,38 +1,42 @@
--- ---
title: 'Network Controller Summary' title: "Network Controller Summary"
description: 'Displays the summary of a Network Controller' description: "Displays the summary of a Network Controller"
hide_title: true hide_title: true
--- ---
import { unifiControllerIntegration } from '@site/docs/integrations/unifi-controller'; 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 { unifiControllerIntegration } from "@site/docs/integrations/unifi-controller";
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 { 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 { IconAd } from "@tabler/icons-react";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import { networkControllerSummaryWidget } from '.'; import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
<WidgetHeader
widget={networkControllerSummaryWidget}
categories={['Network controller']}
/>
Shows information whether your WAN is connected, Wi-Fi is active and several other information. Shows information whether your WAN is connected, Wi-Fi is active and several other information.
### Screenshots ### Screenshots
import summaryCard from './img/summary.png'; import summaryCard from "./img/summary.png";
<img src={summaryCard} alt="network-controller-summary" width={128 * 1.5} height={128 * 1.5} /> <img src={summaryCard} alt="network-controller-summary" width={128 * 1.5} height={128 * 1.5} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: unifiControllerIntegration, items={[
}]} /> {
integration: unifiControllerIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
@@ -4,6 +4,8 @@ description: "A simple notebook widget that supports markdown."
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
@@ -24,6 +26,8 @@ import editOn from "./img/edit.png";
<img src={editOn} alt="notebook edit-on" width={128 * 3} /> <img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+20 -23
View File
@@ -1,44 +1,41 @@
--- ---
title: 'Notifications' title: "Notifications"
description: 'Display notification history from an integration' description: "Display notification history from an integration"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={notificationsWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Notifications', "Messaging"]} 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";
<WidgetHeader widget={notificationsWidget} categories={["Notifications", "Messaging"]} />
The notifications widget allows you to display notifications from various integrations in a unified view. The notifications widget allows you to display notifications from various integrations in a unified view.
### Screenshots ### Screenshots
import notificationsScreenshot from './img/notifications.png'; import notificationsScreenshot from "./img/notifications.png";
<img src={notificationsScreenshot} alt="notifications" width={256} height={256} /> <img src={notificationsScreenshot} alt="notifications" width={256} height={256} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations <WidgetIntegrations
items={[ items={[{ integration: ntfyIntegration }, { integration: nextcloudIntegration }, { integration: gotifyIntegration }]}
{ integration: ntfyIntegration },
{ integration: nextcloudIntegration },
{ integration: gotifyIntegration }
]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+21 -16
View File
@@ -1,34 +1,39 @@
--- ---
title: 'Paperless-ngx' title: "Paperless-ngx"
description: 'Displays document management statistics including inbox ratio, document counts, and metadata.' description: "Displays document management statistics including inbox ratio, document counts, and metadata."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { paperlessNgxWidget } from '.';
import { paperlessNgxIntegration } from '@site/docs/integrations/paperless-ngx';
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";
<WidgetHeader <WidgetHeader widget={paperlessNgxWidget} categories={["Documents"]} />
widget={paperlessNgxWidget}
categories={['Documents']}
/>
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. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: paperlessNgxIntegration, items={[
note: "Displays document statistics and inbox ratio" {
}]} /> integration: paperlessNgxIntegration,
note: "Displays document statistics and inbox ratio",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={paperlessNgxWidget.configuration} /> <WidgetConfig configuration={paperlessNgxWidget.configuration} />
+34 -29
View File
@@ -1,21 +1,19 @@
--- ---
title: 'PatchMon' title: "PatchMon"
description: 'Displays host patch statistics from PatchMon including update and security counts.' description: "Displays host patch statistics from PatchMon including update and security counts."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
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";
<WidgetHeader <WidgetHeader widget={patchmonWidget} categories={["Health Monitoring"]} />
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
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. 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: When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red | | Stat | Green | Yellow | Red |
|------|-------|--------|-----| | --------------------------- | ------------- | ------ | --------- |
| Needs Updates | 0 | 1–4 | 5+ | | Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ | | Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ | | Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% | | Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ | | 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. 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: The widget adapts its layout based on the available space:
| Widget size | Layout | | Widget size | Layout |
|-------------|--------| | ----------------- | ------------------------------------------------------------------------------------------------------------------- |
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats | | **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×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×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer | | **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 | | **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated ### Last updated
@@ -76,14 +74,21 @@ When the widget has enough space, a "last updated" timestamp appears at the bott
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: patchmonIntegration, items={[
note: "Displays host patch and security update statistics" {
}]} /> integration: patchmonIntegration,
note: "Displays host patch and security update statistics",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={patchmonWidget.configuration} /> <WidgetConfig configuration={patchmonWidget.configuration} />
@@ -4,6 +4,8 @@ description: "Displays a list of the current version of the given repositories w
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header"; 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`. 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. Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+15 -12
View File
@@ -1,37 +1,40 @@
--- ---
title: 'RSS Feed' title: "RSS Feed"
description: 'Monitor and display one or more generic RSS, ATOM or JSON feeds.' description: "Monitor and display one or more generic RSS, ATOM or JSON feeds."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { rssFeedWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
widget={rssFeedWidget}
categories={['Feeds']}
/>
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
### Screenshots ### Screenshots
import screenshot from './img/screenshot.png'; import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} /> <img src={screenshot} alt="rss-feed noted-lol" width={128 * 4} />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={rssFeedWidget.configuration} /> <WidgetConfig configuration={rssFeedWidget.configuration} />
### Examples ### Examples
Here's a small collection of examples, that you can use for testing or actual use (in no particular order): 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://noted.lol/rss/
- https://blog.google/products/chrome/rss/ - https://blog.google/products/chrome/rss/
- https://www.w3.org/blog/feed/ - 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 - http://feeds.foxnews.com/foxnews/latest
There is a good list of feeds here: There is a good list of feeds here:
https://github.com/greysonp/rss-feeds/blob/master/feeds.json https://github.com/greysonp/rss-feeds/blob/master/feeds.json
@@ -1,34 +1,32 @@
--- ---
title: 'Entity State' title: "Entity State"
description: 'Display the state of an entity and toggle it optionally' description: "Display the state of an entity and toggle it optionally"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { smartHomeEntityStateWidget } from '.'; import { smartHomeEntityStateWidget } from ".";
import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant";
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
<WidgetHeader
widget={smartHomeEntityStateWidget}
categories={['Smart home']}
/>
The entity state enables you to display the current state of an entity. 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. It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
### Screenshots ### Screenshots
import sun from './img/sun.png'; import sun from "./img/sun.png";
import lightSwitch from './img/light-switch.png'; import lightSwitch from "./img/light-switch.png";
import temperature from './img/temperature.png'; import temperature from "./img/temperature.png";
<img src={sun} alt="smart-home-entity-state sun" width={128 * 1.5} height={128 * 1.5} /> <img src={sun} alt="smart-home-entity-state sun" width={128 * 1.5} height={128 * 1.5} />
<img src={lightSwitch} alt="smart-home-entity-state light-switch" width={128 * 1.5} height={128 * 1.5} /> <img src={lightSwitch} alt="smart-home-entity-state light-switch" width={128 * 1.5} height={128 * 1.5} />
@@ -36,32 +34,40 @@ import temperature from './img/temperature.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: homeAssistantIntegration, items={[
}]} /> {
integration: homeAssistantIntegration,
},
]}
/>
### Interactions ### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled). - Toggle entity state by clicking on the entity (only when clickable enabled).
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={smartHomeEntityStateWidget.configuration} /> <WidgetConfig configuration={smartHomeEntityStateWidget.configuration} />
### How to find the entity ID ### How to find the entity ID
To find the entity ID in Home Assistant, follow these steps: 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). 1. Open Home Assistant and go to the **Developer Tools** section (usually found in the sidebar).
2. Click on the **States** tab. 2. Click on the **States** tab.
3. In the list of entities, find the entity you want to use. 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`). 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. 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";
<div> <div>
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} /> <img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
</div> </div>
@@ -1,50 +1,60 @@
--- ---
title: 'Execute Automation' title: "Execute Automation"
description: 'Trigger an automation with one click' description: "Trigger an automation with one click"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { 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 { IconAd } from "@tabler/icons-react";
import Tabs from '@theme/Tabs'; import Tabs from "@theme/Tabs";
import TabItem from '@theme/TabItem'; import TabItem from "@theme/TabItem";
import { smartHomeExecuteAutomationWidget } from '.'; import { smartHomeExecuteAutomationWidget } from ".";
import { homeAssistantIntegration } from '@site/docs/integrations/home-assistant'; import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant";
<WidgetHeader widget={smartHomeExecuteAutomationWidget} categories={["Automation"]} />
<WidgetHeader
widget={smartHomeExecuteAutomationWidget}
categories={['Automation']}
/>
The execute automation widget allows you to trigger an automation with one click. The execute automation widget allows you to trigger an automation with one click.
### Screenshots ### Screenshots
import empty from './img/empty.png'; import empty from "./img/empty.png";
import withDisplayname from './img/with-displayname.png'; import withDisplayname from "./img/with-displayname.png";
<div className="flex gap-1"> <div className="flex gap-1">
<img src={empty} alt="smart-home-execute-automation empty" width={128 * 1.5} height={128 * 1.5} /> <img src={empty} alt="smart-home-execute-automation empty" width={128 * 1.5} height={128 * 1.5} />
<img src={withDisplayname} alt="smart-home-execute-automation with-displayname" width={128 * 1.5} height={128 * 1.5} /> <img
src={withDisplayname}
alt="smart-home-execute-automation with-displayname"
width={128 * 1.5}
height={128 * 1.5}
/>
</div> </div>
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: homeAssistantIntegration, items={[
}]} /> {
integration: homeAssistantIntegration,
},
]}
/>
### Interactions ### Interactions
- Trigger the automation by clicking on the widget. - Trigger the automation by clicking on the widget.
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} /> <WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
@@ -1,39 +1,45 @@
--- ---
title: 'Speedtest Tracker' title: "Speedtest Tracker"
description: 'Displays speed test results from your Speedtest Tracker instance.' description: "Displays speed test results from your Speedtest Tracker instance."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { speedtestTrackerWidget } from '.';
import { speedtestTrackerIntegration } from '@site/docs/integrations/speedtest-tracker';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={speedtestTrackerWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Speedtest']} 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";
<WidgetHeader widget={speedtestTrackerWidget} categories={["Speedtest"]} />
This widget displays internet speed test results from your [Speedtest Tracker](https://docs.speedtest-tracker.dev/) instance, 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. including the latest result, historical averages, and a live chart of the last 12 hours.
### Screenshots ### Screenshots
import screenshot from './img/screenshot.png'; import screenshot from "./img/screenshot.png";
<img src={screenshot} alt="speedtest-tracker image" width={500} height={355} /> <img src={screenshot} alt="speedtest-tracker image" width={500} height={355} />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: speedtestTrackerIntegration, items={[
}]} /> {
integration: speedtestTrackerIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={speedtestTrackerWidget.configuration} /> <WidgetConfig configuration={speedtestTrackerWidget.configuration} />
+18 -16
View File
@@ -1,28 +1,26 @@
--- ---
title: 'Stock Price' title: "Stock Price"
description: 'Displays the current stock price of a company.' description: "Displays the current stock price of a company."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { stockPriceWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={stockPriceWidget} categories={["Finance", "Market"]} />
widget={stockPriceWidget}
categories={['Finance', "Market"]}
/>
Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard. Using the Stocks widget, you can show the current stock prices of your favorite companies on your dashboard.
### Screenshots ### Screenshots
import apple from './img/apple.png'; import apple from "./img/apple.png";
import google from './img/google.png'; import google from "./img/google.png";
import ethereum from './img/ethereum.png'; import ethereum from "./img/ethereum.png";
import nvidia from './img/nvidia.png'; import nvidia from "./img/nvidia.png";
<div className="flex gap-1"> <div className="flex gap-1">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
@@ -34,17 +32,21 @@ import nvidia from './img/nvidia.png';
</div> </div>
<img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} /> <img src={nvidia} alt="stock-price nvidia" width={128 * 2 + 2} height={128 * 2 + 4} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={stockPriceWidget.configuration} /> <WidgetConfig configuration={stockPriceWidget.configuration} />
### Examples ### Examples
Here's a small collection of examples, that you can use for testing or actual use (in no particular order): Here's a small collection of examples, that you can use for testing or actual use (in no particular order):
- AAPL - AAPL
- GOOGL - GOOGL
- AMZN - AMZN
@@ -59,4 +61,4 @@ Here's a small collection of examples, that you can use for testing or actual us
### Data source ### Data source
We use the Yahoo Finance API to fetch the stock prices. 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` If you need to whitelist the api, please use the following URL: `https://query1.finance.yahoo.com`
@@ -4,6 +4,8 @@ description: "Disk usage of your system"
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; 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
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+5 -1
View File
@@ -19,7 +19,11 @@ export const systemDisksWidget: WidgetDefinition = {
description: "How disk usage values are shown", description: "How disk usage values are shown",
values: { values: {
type: "select", 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%)", defaultValue: "Percentage (e.g., 76%)",
}, },
@@ -4,6 +4,8 @@ description: "Displays CPU, RAM and network of your host in real time"
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -65,6 +67,8 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+6 -10
View File
@@ -4,6 +4,8 @@ description: "Displays departure times for a station."
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header"; 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"; import streetDepartures from "./img/street-departures.png";
<div className="flex gap-2"> <div className="flex gap-2">
<img <img src={trainDepartures} alt="timetable-widget-train-departures" width={128 * 2} />
src={trainDepartures} <img src={streetDepartures} alt="timetable-widget-bus-and-tram-departures" width={128 * 2} />
alt="timetable-widget-train-departures"
width={128 * 2}
/>
<img
src={streetDepartures}
alt="timetable-widget-bus-and-tram-departures"
width={128 * 2}
/>
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+28 -23
View File
@@ -1,52 +1,57 @@
--- ---
title: 'Tracearr' title: "Tracearr"
description: 'Monitor media server streams, user activity, and policy violations.' description: "Monitor media server streams, user activity, and policy violations."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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 { 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";
<WidgetHeader <WidgetHeader widget={tracearrWidget} categories={["Media Monitoring"]} />
widget={tracearrWidget}
categories={['Media Monitoring']}
/>
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. 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 ### Sections
| Section | Description | | Section | Description |
|---|---| | ------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| **Stats Bar** | Displays Active Streams, Total Users, Transcode Count, and Total Bandwidth at a glance. | | **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). | | **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. | | **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. | | **Violations** | Alerts for policy violations (e.g., concurrent stream limits) with severity levels. |
### Screenshots ### Screenshots
import widgetOverview from './img/widget-overview.png'; import widgetOverview from "./img/widget-overview.png";
import streamsAndStats from './img/streams-and-stats.png'; import streamsAndStats from "./img/streams-and-stats.png";
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<img src={widgetOverview} alt="tracearr-widget-overview" width={128 * 4} /> <img src={widgetOverview} alt="tracearr-widget-overview" width={128 * 4} />
<img src={streamsAndStats} alt="tracearr-streams-and-stats" width={128 * 4} /> <img src={streamsAndStats} alt="tracearr-streams-and-stats" width={128 * 4} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: tracearrIntegration, items={[
}]} /> {
integration: tracearrIntegration,
},
]}
/>
### Configuration ### Configuration
<WidgetConfig configuration={tracearrWidget.configuration} /> <WidgetConfig configuration={tracearrWidget.configuration} />
+4
View File
@@ -4,6 +4,8 @@ description: "Displays Traefik router, service, middleware, and entry point coun
hide_title: true hide_title: true
--- ---
import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetHeader } from "@site/src/components/widgets/header"; import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding"; import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration"; import { WidgetConfig } from "@site/src/components/widgets/configuration";
@@ -26,6 +28,8 @@ This widget displays a compact overview of your Traefik instance, including tota
]} ]}
/> />
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
+26 -21
View File
@@ -1,28 +1,33 @@
--- ---
title: 'Umami Analytics' title: "Umami Analytics"
description: 'Display visitor stats from your Umami analytics instance' description: "Display visitor stats from your Umami analytics instance"
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { umamiWidget } from '.';
import { umamiIntegration } from '@site/docs/integrations/umami';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={umamiWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Analytics']} 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";
<WidgetHeader widget={umamiWidget} categories={["Analytics"]} />
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. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: umamiIntegration, items={[
}]} /> {
integration: umamiIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
@@ -34,12 +39,12 @@ The Umami Analytics widget displays key visitor metrics from a website tracked b
### View modes ### View modes
| View | Description | | 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. | | **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. | | **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 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". | | **Top referrers** | Ranked list of referrer sources by session count. Direct traffic (no referrer) is labeled "Direct". |
### Notes ### Notes
+29 -23
View File
@@ -1,20 +1,19 @@
--- ---
title: 'UPS' title: "UPS"
description: 'Monitor the status of your UPS devices through a NUT server.' description: "Monitor the status of your UPS devices through a NUT server."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { upsWidget } from '.';
import { peaNutIntegration } from '@site/docs/integrations/peanut';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={upsWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['UPS']} 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";
<WidgetHeader widget={upsWidget} categories={["UPS"]} />
This widget displays the status of the UPS devices reported by a Network UPS Tools (NUT) server. Each device shows its 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 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 ### Layouts
import screenshotFull from './img/screenshot.png'; import screenshotFull from "./img/screenshot.png";
import screenshotCompact from './img/screenshot-compact.png'; import screenshotCompact from "./img/screenshot-compact.png";
import screenshotMini from './img/screenshot-mini.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. 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 | | Layout | Size | Preview |
| --- | --- | --- | | ------- | --------------- | --------------------------------------------------------------------------- |
| Full | 3+ columns wide | <img src={screenshotFull} alt="UPS widget full layout" width={239} /> | | Full | 3+ columns wide | <img src={screenshotFull} alt="UPS widget full layout" width={239} /> |
| Compact | ~2 columns | <img src={screenshotCompact} alt="UPS widget compact layout" width={171} /> | | Compact | ~2 columns | <img src={screenshotCompact} alt="UPS widget compact layout" width={171} /> |
| Mini | 1 column | <img src={screenshotMini} alt="UPS widget mini layout" width={90} /> | | Mini | 1 column | <img src={screenshotMini} alt="UPS widget mini layout" width={90} /> |
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: peaNutIntegration, items={[
}]} /> {
integration: peaNutIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={upsWidget.configuration} /> <WidgetConfig configuration={upsWidget.configuration} />
+21 -16
View File
@@ -1,34 +1,39 @@
--- ---
title: 'Uptime Kuma' title: "Uptime Kuma"
description: 'Displays monitor uptime statistics, average uptime percentage, and service status counts.' description: "Displays monitor uptime statistics, average uptime percentage, and service status counts."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; 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 { uptimeKumaWidget } from '.';
import { uptimeKumaIntegration } from '@site/docs/integrations/uptime-kuma';
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";
<WidgetHeader <WidgetHeader widget={uptimeKumaWidget} categories={["Monitoring"]} />
widget={uptimeKumaWidget}
categories={['Monitoring']}
/>
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts. 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 ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: uptimeKumaIntegration, items={[
note: "Displays monitors from the configured status page" {
}]} /> integration: uptimeKumaIntegration,
note: "Displays monitors from the configured status page",
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={uptimeKumaWidget.configuration} /> <WidgetConfig configuration={uptimeKumaWidget.configuration} />
+11 -11
View File
@@ -1,19 +1,17 @@
--- ---
title: 'Video Stream' title: "Video Stream"
description: 'Embed a video stream or video from a camera or a website.' description: "Embed a video stream or video from a camera or a website."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { videoWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={videoWidget} categories={["Video", "Surveillance"]} />
widget={videoWidget}
categories={["Video", "Surveillance"]}
/>
Do you run surveilance cameras? Or maybe you have a favourite video? Do you run surveilance cameras? Or maybe you have a favourite video?
The video widget enables you to integrate both seamlessly into your dashboard. 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: DRM content is not yet supported. More info here:
https://videojs.com/guides/faqs/#q-what-is-videojs https://videojs.com/guides/faqs/#q-what-is-videojs
You can use the following URL to test: You can use the following URL to test:
http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 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) ![video big-buck-bunny](./img/bunny.png)
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={videoWidget.configuration} /> <WidgetConfig configuration={videoWidget.configuration} />
+1 -1
View File
@@ -5,7 +5,7 @@ export const videoWidget: WidgetDefinition = {
icon: IconDeviceCctv, icon: IconDeviceCctv,
name: "Video Stream", name: "Video Stream",
description: "Embed a video stream or video from a camera or a website.", description: "Embed a video stream or video from a camera or a website.",
path: "../../widgets/stock-price", path: "../../widgets/video",
configuration: { configuration: {
items: [ items: [
{ {
+20 -15
View File
@@ -1,19 +1,18 @@
--- ---
title: 'VPN' title: "VPN"
description: 'Monitor the status of your VPN integrations.' description: "Monitor the status of your VPN integrations."
hide_title: true hide_title: true
--- ---
import { WidgetHeader } from '@site/src/components/widgets/header'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
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';
<WidgetHeader import { WidgetHeader } from "@site/src/components/widgets/header";
widget={vpnWidget} import { AddingWidget } from "@site/src/components/widgets/adding";
categories={['Network', 'Security']} import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
/> import { vpnWidget } from ".";
import { gluetunIntegration } from "@site/docs/integrations/gluetun";
<WidgetHeader widget={vpnWidget} categories={["Network", "Security"]} />
The VPN widget provides a compact overview of each configured VPN integration. It shows whether the tunnel and DNS 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. 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 ### Screenshots
import singleIntegrationImage from './img/single.png'; import singleIntegrationImage from "./img/single.png";
<div> <div>
<img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} /> <img src={singleIntegrationImage} alt="VPN widget showing a single integration" width={128 * 3} />
@@ -31,9 +30,15 @@ import singleIntegrationImage from './img/single.png';
### Supported Integrations ### Supported Integrations
<WidgetIntegrations items={[{ <WidgetIntegrations
integration: gluetunIntegration, items={[
}]} /> {
integration: gluetunIntegration,
},
]}
/>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
+14 -13
View File
@@ -1,40 +1,41 @@
--- ---
title: 'Weather' title: "Weather"
description: 'Displays the current weather information of a set location.' description: "Displays the current weather information of a set location."
hide_title: true hide_title: true
--- ---
import { AddingWidget } from '@site/src/components/widgets/adding'; import { WidgetInteractionGuide } from "@site/src/components/widgets/interactions";
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { weatherWidget } from '.';
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 ".";
<WidgetHeader <WidgetHeader widget={weatherWidget} categories={["Weather"]} />
widget={weatherWidget}
categories={["Weather"]}
/>
The Weather Widget uses your location to display the current, highest and lowest temperature. The Weather Widget uses your location to display the current, highest and lowest temperature.
### Screenshots ### Screenshots
import forecastWeather from './img/forecast.png'; import forecastWeather from "./img/forecast.png";
import currentWeather from './img/current.png'; import currentWeather from "./img/current.png";
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<img src={forecastWeather} alt="weather forecast-5-days" width={128 * 4.5} /> <img src={forecastWeather} alt="weather forecast-5-days" width={128 * 4.5} />
<img src={currentWeather} alt="weather current" width={128 * 3} /> <img src={currentWeather} alt="weather current" width={128 * 3} />
</div> </div>
<WidgetInteractionGuide />
### Adding the widget ### Adding the widget
<AddingWidget /> <AddingWidget />
### Configuration ### Configuration
<WidgetConfig configuration={weatherWidget.configuration} /> <WidgetConfig configuration={weatherWidget.configuration} />
### Data source ### Data source
We use the Open Meteo API to fetch the weather and locations. 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` If you need to whitelist the api, please use the following URL: `https://open-meteo.com`
@@ -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 = () => (
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
<span>Power-user controls</span>
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
Hide shortcuts
</span>
</summary>
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-3 dark:border-[#333]">
{interactions.map(({ icon: Icon, title, description }) => (
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
<div className="font-semibold">{title}</div>
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
</div>
))}
</div>
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. Touch devices expose a maximize control; board actions
respect your permissions and right-click preference.
</p>
</details>
);
@@ -6,6 +6,7 @@ import { useCurrentLayout, useInitialViewportWidth, useRequiredBoard } from "@ho
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useScopedI18n } from "@homarr/translation/client"; import { useScopedI18n } from "@homarr/translation/client";
import { BoardAdvancedFocusProvider } from "~/components/board/advanced-focus/context";
import { import {
getBoardLaneColumnCount, getBoardLaneColumnCount,
getInitialBoardLogicalHeight, getInitialBoardLogicalHeight,
@@ -56,66 +57,68 @@ export const ClientBoard = () => {
.join(" "); .join(" ");
return ( return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board> <BoardAdvancedFocusProvider>
<BoardBackgroundVideo /> <Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardSectionCollapseProvider> <BoardBackgroundVideo />
<BoardGridPortalHost> <BoardSectionCollapseProvider>
<ScaledBoardCanvas <BoardGridPortalHost>
logicalWidth={logicalWidth} <ScaledBoardCanvas
initialLogicalHeight={initialLogicalHeight} logicalWidth={logicalWidth}
initialAvailableWidth={initialAvailableWidth} initialLogicalHeight={initialLogicalHeight}
label={board.name} initialAvailableWidth={initialAvailableWidth}
> label={board.name}
<BoardGridEditorBoundary key={currentLayoutId}> >
<BoardGridPortalRenderer /> <BoardGridEditorBoundary key={currentLayoutId}>
<div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}> <BoardGridPortalRenderer />
{leftColumnCount > 0 && leftSection && ( <div className={classes.columns} style={{ gridTemplateColumns, paddingTop: LOGICAL_GRID_GAP }}>
<aside {leftColumnCount > 0 && leftSection && (
className={`${classes.lane} ${classes.gutter}`} <aside
aria-label={t("leftRail")} className={`${classes.lane} ${classes.gutter}`}
data-board-gutter="left" aria-label={t("leftRail")}
data-board-editing={isEditMode ? "true" : undefined} data-board-gutter="left"
> data-board-editing={isEditMode ? "true" : undefined}
<BoardEmptySection >
key={`${currentLayoutId}-${leftSection.id}`} <BoardEmptySection
section={leftSection} key={`${currentLayoutId}-${leftSection.id}`}
columnCount={leftColumnCount} section={leftSection}
requestedRowCount={0} columnCount={leftColumnCount}
railPlacement="left" requestedRowCount={0}
/> railPlacement="left"
</aside> />
)} </aside>
)}
<section className={classes.lane} aria-label={t("canvas")}> <section className={classes.lane} aria-label={t("canvas")}>
<BoardEmptySection
key={`${currentLayoutId}-${mainSection.id}`}
section={mainSection}
columnCount={mainColumnCount}
requestedRowCount={0}
/>
</section>
{rightColumnCount > 0 && rightSection && (
<aside
className={`${classes.lane} ${classes.gutter}`}
aria-label={t("rightRail")}
data-board-gutter="right"
data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection <BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`} key={`${currentLayoutId}-${mainSection.id}`}
section={rightSection} section={mainSection}
columnCount={rightColumnCount} columnCount={mainColumnCount}
requestedRowCount={0} requestedRowCount={0}
railPlacement="right"
/> />
</aside> </section>
)}
</div> {rightColumnCount > 0 && rightSection && (
</BoardGridEditorBoundary> <aside
</ScaledBoardCanvas> className={`${classes.lane} ${classes.gutter}`}
</BoardGridPortalHost> aria-label={t("rightRail")}
</BoardSectionCollapseProvider> data-board-gutter="right"
</Box> data-board-editing={isEditMode ? "true" : undefined}
>
<BoardEmptySection
key={`${currentLayoutId}-${rightSection.id}`}
section={rightSection}
columnCount={rightColumnCount}
requestedRowCount={0}
railPlacement="right"
/>
</aside>
)}
</div>
</BoardGridEditorBoundary>
</ScaledBoardCanvas>
</BoardGridPortalHost>
</BoardSectionCollapseProvider>
</Box>
</BoardAdvancedFocusProvider>
); );
}; };
@@ -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;
}
}
@@ -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<AdvancedFocusContextValue | null>(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<ActiveFocus | null>(null);
const activeRef = useRef<ActiveFocus | null>(null);
const hoveredRef = useRef<{ itemId: string; source: HTMLElement } | null>(null);
const shiftHeldRef = useRef(false);
const hoverTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const closeTimerRef = useRef<ReturnType<typeof setTimeout> | 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 (
<AdvancedFocusContext.Provider value={value}>
<RemoveScroll enabled={active !== null} className={classes.focusScope}>
{children}
</RemoveScroll>
{active && (
<Portal>
<Box
className={`${classes.backdrop} ${active.phase === "closing" ? classes.backdropClosing : ""}`}
onPointerDown={() => close()}
aria-hidden
/>
</Portal>
)}
</AdvancedFocusContext.Provider>
);
};
export const useAdvancedFocus = () => {
const context = useContext(AdvancedFocusContext);
if (!context) throw new Error("useAdvancedFocus must be used within BoardAdvancedFocusProvider");
return context;
};
@@ -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);
});
});
@@ -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;
};
@@ -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);
});
});
@@ -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,
};
};
@@ -1,7 +1,7 @@
import { describe, expect, test } from "vitest"; import { describe, expect, test } from "vitest";
import type { Item } from "~/app/[locale]/boards/_types"; import type { Item } from "~/app/[locale]/boards/_types";
import { getFirstEmptyPosition } from "../empty-position"; import { getFirstEmptyPosition, getNearestEmptyPosition } from "../empty-position";
describe("get first empty position", () => { describe("get first empty position", () => {
test.each([ test.each([
@@ -106,6 +106,37 @@ describe("get first empty position", () => {
expect(result).toEqual(expectedX !== undefined ? { xOffset: expectedX, yOffset: expectedY } : undefined); 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[][]) => { const createElementsFromLayout = (layout: string[][]) => {
@@ -3,6 +3,7 @@ import { useCallback } from "react";
import { useUpdateBoard } from "@homarr/boards/updater"; import { useUpdateBoard } from "@homarr/boards/updater";
import type { BoardItemAdvancedOptions } from "@homarr/validation/shared"; import type { BoardItemAdvancedOptions } from "@homarr/validation/shared";
import type { Board } from "~/app/[locale]/boards/_types";
import type { CreateItemInput } from "./actions/create-item"; import type { CreateItemInput } from "./actions/create-item";
import { createItemCallback } from "./actions/create-item"; import { createItemCallback } from "./actions/create-item";
import type { DuplicateItemInput } from "./actions/duplicate-item"; import type { DuplicateItemInput } from "./actions/duplicate-item";
@@ -34,7 +35,12 @@ export const useItemActions = () => {
const createItem = useCallback( const createItem = useCallback(
(input: CreateItemInput) => { (input: CreateItemInput) => {
updateBoard(createItemCallback(input)); let nextBoard: Board | undefined;
updateBoard((previous) => {
nextBoard = createItemCallback(input)(previous);
return nextBoard;
});
return nextBoard;
}, },
[updateBoard], [updateBoard],
); );
@@ -1,9 +1,10 @@
import type { ComponentType, MutableRefObject } from "react"; import type { ComponentType, CSSProperties, KeyboardEvent, MutableRefObject } from "react";
import { Suspense, use, useRef } from "react"; import { memo, Suspense, use, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import type { CardProps } from "@mantine/core"; import type { CardProps } from "@mantine/core";
import { Badge, Card, Center, Loader } from "@mantine/core"; import { Badge, Box, Card, Center, Loader, Portal } from "@mantine/core";
import { useElementSize } from "@mantine/hooks"; import { useElementSize, useIsomorphicEffect, useViewportSize } from "@mantine/hooks";
import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query"; import { QueryErrorResetBoundary, useQueryClient } from "@tanstack/react-query";
import combineClasses from "clsx"; import combineClasses from "clsx";
import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes"; import { NoIntegrationSelectedError } from "@homarr/widgets/errors/classes";
@@ -13,12 +14,23 @@ import { ErrorBoundary } from "react-error-boundary";
import { useRequiredBoard } from "@homarr/boards/context"; import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { useI18n } from "@homarr/translation/client";
import { WidgetError } from "@homarr/widgets/errors"; import { WidgetError } from "@homarr/widgets/errors";
import { getWidgetQueryKeys } from "@homarr/widgets/definition"; import {
import type { WidgetComponentProps, WidgetDefinition } from "@homarr/widgets/definition"; createWidgetRuntimeState,
getWidgetQueryKeys,
supportsAdvancedFocus as definitionSupportsAdvancedFocus,
} from "@homarr/widgets/definition";
import type { WidgetComponentProps, WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { loadWidgetResources, reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import advancedFocusClasses from "../advanced-focus/advanced-focus.module.css";
import { useAdvancedFocus } from "../advanced-focus/context";
import { startAdvancedFocusEntrance } from "../advanced-focus/entrance";
import { getAdvancedFocusClosePosition, getAdvancedFocusRect } from "../advanced-focus/geometry";
import { AdvancedFocusManualSurface } from "../advanced-focus/manual-surface";
import { redirectShiftWheel } from "../advanced-focus/wheel";
import classes from "../sections/item.module.css"; import classes from "../sections/item.module.css";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import itemContentClasses from "./item-content.module.css"; import itemContentClasses from "./item-content.module.css";
@@ -92,38 +104,305 @@ const WidgetDefinitionLoadError = ({
export const BoardItemContent = ({ item }: BoardItemContentProps) => { export const BoardItemContent = ({ item }: BoardItemContentProps) => {
const { ref, width, height } = useElementSize<HTMLDivElement>(); const { ref, width, height } = useElementSize<HTMLDivElement>();
const board = useRequiredBoard();
const widgetStateRef = useRef<Record<string, unknown> | null>(null); const widgetStateRef = useRef<Record<string, unknown> | null>(null);
const widgetRuntimeRef = useRef(createWidgetRuntimeState());
return ( return (
<> <ErrorBoundary
<ErrorBoundary resetKeys={[item.kind]}
resetKeys={[item.kind]} fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />}
fallbackRender={(fallbackProps) => <WidgetDefinitionLoadError {...fallbackProps} item={item} innerRef={ref} />} >
<Suspense
fallback={
<BoardItemCard item={item} innerRef={ref}>
<Center h="100%">
<Loader size="sm" />
</Center>
</BoardItemCard>
}
> >
<Suspense <LoadedBoardItemContent
fallback={ item={item}
<BoardItemCard item={item} innerRef={ref}> width={width}
<Center h="100%"> height={height}
<Loader size="sm" /> widgetStateRef={widgetStateRef}
</Center> widgetRuntimeRef={widgetRuntimeRef}
</BoardItemCard> contentRef={ref}
} />
</Suspense>
</ErrorBoundary>
);
};
interface LoadedBoardItemContentProps {
item: SectionItem;
width: number;
height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
contentRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({
item,
width,
height,
widgetStateRef,
widgetRuntimeRef,
contentRef,
}: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const sourceRef = useRef<HTMLDivElement>(null);
const cardRef = useRef<HTMLDivElement>(null);
const previewEntranceFrameRef = useRef<number | null>(null);
const { width: viewportWidth, height: viewportHeight } = useViewportSize();
const board = useRequiredBoard();
const t = useI18n();
const [isEditMode] = useEditMode();
const [manualSurface, setManualSurface] = useState<HTMLDivElement | null>(null);
const [surfacePortalTarget, setSurfacePortalTarget] = useState<HTMLDivElement | null>(null);
const { active, open, close, dismiss, hover, leave } = useAdvancedFocus();
const supportsAdvancedFocus = definitionSupportsAdvancedFocus(definition);
const widgetName = t(`widget.${item.kind}.name`);
const advancedViewLabel = t("item.advancedFocus.label", { widget: widgetName });
const advancedViewId = `advanced-focus-${item.id}`;
const activeFocus = supportsAdvancedFocus && active?.itemId === item.id ? active : null;
const isAdvanced = activeFocus !== null;
const advancedRect = activeFocus
? getAdvancedFocusRect(activeFocus.sourceRect, { width: viewportWidth, height: viewportHeight })
: null;
const closePosition = advancedRect ? getAdvancedFocusClosePosition(advancedRect, viewportWidth) : null;
const isPreview = activeFocus?.activation === "preview";
const isManual = activeFocus?.activation === "manual";
useIsomorphicEffect(() => {
if (!supportsAdvancedFocus) return;
const target = document.createElement("div");
target.className = advancedFocusClasses.surfacePortalTarget ?? "";
setSurfacePortalTarget(target);
return () => target.remove();
}, [supportsAdvancedFocus]);
useEffect(() => () => dismiss(item.id), [dismiss, item.id]);
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (
event.currentTarget !== event.target ||
event.key !== "Enter" ||
!event.shiftKey ||
isEditMode ||
!supportsAdvancedFocus
)
return;
event.preventDefault();
if (sourceRef.current) open(item.id, sourceRef.current, { activation: "manual", autofocusClose: true });
};
useEffect(() => {
const surface = isManual ? manualSurface : cardRef.current;
if (!isAdvanced || !surface) return;
const handleWheel = (event: WheelEvent) => {
if (!event.shiftKey) return;
const multiplier = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? surface.clientHeight : 1;
const delta = (event.deltaY || event.deltaX) * multiplier;
if (!redirectShiftWheel(surface, event.target, delta)) return;
event.preventDefault();
event.stopPropagation();
};
surface.addEventListener("wheel", handleWheel, { capture: true, passive: false });
return () => surface.removeEventListener("wheel", handleWheel, { capture: true });
}, [isAdvanced, isManual, manualSurface]);
const cancelPreviewEntrance = useCallback(() => {
if (previewEntranceFrameRef.current === null) return;
cancelAnimationFrame(previewEntranceFrameRef.current);
previewEntranceFrameRef.current = null;
}, []);
const mountCompactHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || isAdvanced) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isAdvanced, surfacePortalTarget],
);
const mountPreviewHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isPreview) {
if (!host) cancelPreviewEntrance();
return;
}
if (activeFocus?.phase !== "visible") {
host.append(surfacePortalTarget);
return;
}
const card = cardRef.current;
const readyClass = advancedFocusClasses.surfaceReady;
if (!card || !readyClass) return;
cancelPreviewEntrance();
previewEntranceFrameRef.current = startAdvancedFocusEntrance(surfacePortalTarget, host, card, readyClass);
},
[activeFocus?.phase, cancelPreviewEntrance, isPreview, surfacePortalTarget],
);
const mountManualHost = useCallback(
(host: HTMLDivElement | null) => {
if (!host || !surfacePortalTarget || !isManual) return;
cancelPreviewEntrance();
host.append(surfacePortalTarget);
},
[cancelPreviewEntrance, isManual, surfacePortalTarget],
);
useEffect(() => cancelPreviewEntrance, [cancelPreviewEntrance]);
const previewStyle =
isPreview && advancedRect && activeFocus
? ({
left: advancedRect.left,
top: advancedRect.top,
width: advancedRect.width,
height: advancedRect.height,
"--focus-translate-x": `${activeFocus.sourceRect.left - advancedRect.left}px`,
"--focus-translate-y": `${activeFocus.sourceRect.top - advancedRect.top}px`,
"--focus-scale-x": advancedRect.width > 0 ? activeFocus.sourceRect.width / advancedRect.width : 1,
"--focus-scale-y": advancedRect.height > 0 ? activeFocus.sourceRect.height / advancedRect.height : 1,
} as CSSProperties)
: undefined;
const widgetCard = (
<Card
ref={cardRef}
id={isPreview ? advancedViewId : undefined}
role={isPreview ? "region" : undefined}
aria-label={isPreview ? advancedViewLabel : undefined}
data-advanced-focus-surface={isPreview || undefined}
data-grid-item-content
radius={board.itemRadius}
p={isAdvanced ? undefined : 0}
h={isManual ? "100%" : isAdvanced ? undefined : "100%"}
w={isManual ? "100%" : isAdvanced ? undefined : "100%"}
className={combineClasses(
classes.itemCard,
`${item.kind}-wrapper`,
"board-grid-item-content",
isPreview && advancedFocusClasses.surface,
activeFocus?.phase === "closing" && isPreview && advancedFocusClasses.surfaceClosing,
item.advancedOptions.customCssClasses.join(" "),
)}
styles={{
root: {
"--opacity": isAdvanced ? 0.98 : board.opacity / 100,
containerType: "size",
...getOverflowFromKind(item.kind, item.advancedOptions.customCssClasses.length > 0),
"--border-color": item.advancedOptions.borderColor !== "" ? item.advancedOptions.borderColor : undefined,
},
}}
style={previewStyle}
>
<Box ref={contentRef} w="100%" h="100%" mih={0}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
{isEditMode && (
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
)}
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</>
)}
> >
<LoadedBoardItemContent <InnerContent
item={item} item={item}
width={width} width={width}
height={height} height={height}
widgetStateRef={widgetStateRef} widgetStateRef={widgetStateRef}
innerRef={ref} widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
definition={definition}
Component={Component}
/> />
</Suspense> </ErrorBoundary>
</ErrorBoundary> </Box>
{item.advancedOptions.title?.trim() && ( </Card>
);
return (
<>
<WidgetContextMenu
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
sourceRef={sourceRef}
>
<Box
ref={sourceRef}
w="100%"
h="100%"
data-advanced-focus-source
data-item-id={item.id}
tabIndex={!isEditMode && supportsAdvancedFocus ? 0 : undefined}
role={!isEditMode && supportsAdvancedFocus ? "group" : undefined}
aria-label={
!isEditMode && supportsAdvancedFocus ? `${widgetName}: ${t("item.advancedFocus.open")}` : undefined
}
aria-expanded={supportsAdvancedFocus ? isAdvanced : undefined}
aria-controls={isAdvanced ? advancedViewId : undefined}
aria-keyshortcuts={supportsAdvancedFocus ? "Shift+Enter" : undefined}
onKeyDown={supportsAdvancedFocus ? handleKeyDown : undefined}
onPointerEnter={() => {
if (supportsAdvancedFocus && sourceRef.current) hover(item.id, sourceRef.current);
}}
onPointerLeave={() => {
if (supportsAdvancedFocus) leave(item.id);
}}
className={combineClasses(isAdvanced && advancedFocusClasses.sourcePlaceholder)}
>
<div ref={mountCompactHost} className={advancedFocusClasses.surfaceHost}>
{!supportsAdvancedFocus && widgetCard}
</div>
{surfacePortalTarget && createPortal(widgetCard, surfacePortalTarget)}
{isPreview && (
<Portal reuseTargetNode={false}>
<div ref={mountPreviewHost} className={advancedFocusClasses.surfaceHostAdvanced} />
</Portal>
)}
{isManual && activeFocus && advancedRect && closePosition && (
<AdvancedFocusManualSurface
opened={activeFocus.phase === "visible"}
phase={activeFocus.phase}
id={advancedViewId}
label={advancedViewLabel}
closeLabel={t("item.advancedFocus.close")}
rect={advancedRect}
closePosition={closePosition}
sourceRect={activeFocus.sourceRect}
radius={board.itemRadius}
contentRef={setManualSurface}
onClose={() => close()}
>
<div ref={mountManualHost} className={advancedFocusClasses.manualSurfaceMount} />
</AdvancedFocusManualSurface>
)}
</Box>
</WidgetContextMenu>
{!isAdvanced && item.advancedOptions.title?.trim() && (
<Badge <Badge
pos="absolute" pos="absolute"
// It's 4 because of the mantine-react-table that has z-index 3 style={{ zIndex: "var(--mantine-z-index-app)" }}
style={{ zIndex: 4 }}
top={2} top={2}
left={16} left={16}
size="xs" size="xs"
@@ -144,49 +423,18 @@ export const BoardItemContent = ({ item }: BoardItemContentProps) => {
); );
}; };
interface LoadedBoardItemContentProps extends Omit<InnerContentProps, "definition" | "Component"> {
innerRef: (element: HTMLDivElement | null) => void;
}
const LoadedBoardItemContent = ({ item, innerRef, ...innerContentProps }: LoadedBoardItemContentProps) => {
const { definition, Component } = use(loadWidgetResources(item.kind));
const [isEditMode] = useEditMode();
return (
<WidgetContextMenu item={item} definition={definition} widgetStateRef={innerContentProps.widgetStateRef}>
<BoardItemCard item={item} innerRef={innerRef}>
<ErrorBoundary
resetKeys={[item.kind]}
fallbackRender={({ resetErrorBoundary, error }) => (
<>
<BoardItemMenu item={item} definition={definition} resetErrorBoundary={resetErrorBoundary} />
<div
className={itemContentClasses.editModeInertContent}
data-board-grid-inert-content={isEditMode ? "true" : undefined}
inert={isEditMode}
>
<WidgetError definition={definition} error={error} resetErrorBoundary={resetErrorBoundary} />
</div>
</>
)}
>
<InnerContent item={item} definition={definition} Component={Component} {...innerContentProps} />
</ErrorBoundary>
</BoardItemCard>
</WidgetContextMenu>
);
};
interface InnerContentProps { interface InnerContentProps {
item: SectionItem; item: SectionItem;
width: number; width: number;
height: number; height: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced";
definition: WidgetDefinition; definition: WidgetDefinition;
Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>; Component: ComponentType<WidgetComponentProps<SectionItem["kind"]>>;
} }
const InnerContent = ({ item, definition, Component, ...dimensions }: InnerContentProps) => { const InnerContent = memo(function InnerContent({ item, definition, Component, ...dimensions }: InnerContentProps) {
const settings = useSettings(); const settings = useSettings();
const board = useRequiredBoard(); const board = useRequiredBoard();
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
@@ -260,7 +508,7 @@ const InnerContent = ({ item, definition, Component, ...dimensions }: InnerConte
)} )}
</QueryErrorResetBoundary> </QueryErrorResetBoundary>
); );
}; });
const Throw = ({ when, error }: { when: boolean; error: Error }) => { const Throw = ({ when, error }: { when: boolean; error: Error }) => {
if (when) throw error; if (when) throw error;
@@ -2,7 +2,6 @@
opacity: 0; opacity: 0;
transition: opacity 150ms ease; transition: opacity 150ms ease;
} }
.card:hover .action, .card:hover .action,
.card:focus-within .action { .card:focus-within .action {
opacity: 1; opacity: 1;
@@ -290,7 +290,6 @@ const ItemSelectModalContent = ({
</SelectGridLayout> </SelectGridLayout>
); );
}; };
const ItemSelectModalFrame = ({ const ItemSelectModalFrame = ({
actions, actions,
innerProps, innerProps,
@@ -1,149 +1,154 @@
"use client"; "use client";
import type { MutableRefObject, ReactNode } from "react"; import type { MutableRefObject, ReactNode, RefObject } from "react";
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core"; import { Group, Loader, Menu, Text, Tooltip } from "@mantine/core";
import { IconAlertTriangle, IconCircleCheck, IconRefresh, IconSettings } from "@tabler/icons-react"; import { IconAlertTriangle, IconCircleCheck, IconMaximize, IconRefresh, IconSettings } from "@tabler/icons-react";
import type { QueryClient, QueryKey } from "@tanstack/react-query"; import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; import { useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client"; 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 { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode"; import { useEditMode } from "@homarr/boards/edit-mode";
import { usePersistBoard } from "@homarr/boards/updater";
import { useTimeAgo } from "@homarr/common"; import { useTimeAgo } from "@homarr/common";
import { useModalAction } from "@homarr/modals"; import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useSettings } from "@homarr/settings"; import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation"; import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation"; import type { TranslationFunction } from "@homarr/translation";
import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets/definition"; import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { getWidgetQueryKeys } from "@homarr/widgets/definition"; import type { WidgetDefinition, WidgetRuntimeRef } from "@homarr/widgets/definition";
import { getWidgetQueryKeys, getWidgetRuntimeQueries, supportsAdvancedFocus } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest"; import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types"; import type { SectionItem } from "~/app/[locale]/boards/_types";
import { useAdvancedFocus } from "../advanced-focus/context";
import { useBoardPermissions } from "../permissions/client";
import { useItemActions } from "./item-actions"; import { useItemActions } from "./item-actions";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal"; import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
import { matchesWidgetItemQuery } from "./widget-query-scope";
interface WidgetContextMenuProps { interface WidgetContextMenuProps {
item: SectionItem; item: SectionItem;
definition: WidgetDefinition; definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>; widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
sourceRef: RefObject<HTMLElement | null>;
children: ReactNode; children: ReactNode;
} }
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => { export const WidgetContextMenu = ({
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
item, item,
definition, definition,
widgetStateRef, widgetRuntimeRef,
sourceRef,
children, children,
settings, }: WidgetContextMenuProps) => {
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => { const { data: session } = useSession();
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={isMenuOpen}
onChange={setIsMenuOpen}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{isMenuOpen && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
settings={settings}
/>
)}
</Menu.Dropdown>
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
settings: ReturnType<typeof useSettings>;
};
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode(); const [isEditMode] = useEditMode();
const board = useRequiredBoard(); const board = useRequiredBoard();
const { hasChangeAccess } = useBoardPermissions(board);
const { updateAndPersistBoard } = usePersistBoard(board);
const tMenu = useScopedI18n("item.menu.label"); const tMenu = useScopedI18n("item.menu.label");
const t = useI18n(); const t = useI18n();
const { data: session } = useSession(); const settings = useSettings();
const canConfigureWidget = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
const hasSupportedIntegrations =
"supportedIntegrations" in definition && (definition.supportedIntegrations?.length ?? 0) > 0;
const { openModal } = useModalAction(LazyWidgetEditModal); const { openModal } = useModalAction(LazyWidgetEditModal);
const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions(); const { updateItemOptions, updateItemAdvancedOptions, updateItemIntegrations } = useItemActions();
const hasSupportedIntegrations = (definition.supportedIntegrations?.length ?? 0) > 0;
const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, { const { data: integrationData = [], isPending } = clientApi.integration.all.useQuery(undefined, {
enabled: hasSupportedIntegrations, enabled: hasSupportedIntegrations,
}); });
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation(); const canConfigureWidget =
hasChangeAccess && (item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false));
const canOpenAdvancedFocus = supportsAdvancedFocus(definition);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { open: openAdvancedFocus } = useAdvancedFocus();
const integrationsWithInteractAccess = useIntegrationsWithInteractAccess();
const [menuOpened, setMenuOpened] = useState(false);
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]); const persistBoard = useCallback(
const isWidgetFetching = (updater: (previous: typeof board) => typeof board) => {
useIsFetching({ updateAndPersistBoard(updater, {
predicate: (query) => widgetQueryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), onError: () => {
}) > 0; showErrorNotification({
const handleRefetch = useCallback(() => { title: t("item.menu.notification.saveError.title"),
void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "all" }))); message: t("item.menu.notification.saveError.message"),
}, [queryClient, widgetQueryKeys]); });
},
});
},
[t, updateAndPersistBoard],
);
const options = useMemo( const options = useMemo(
() => reduceWidgetOptionsWithDefinition(definition, settings, item.options), () => reduceWidgetOptionsWithDefinition(definition, settings, item.options),
[definition, settings, item.options], [definition, settings, item.options],
); );
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
const matchesWidgetQuery = useCallback(
(queryKey: QueryKey) =>
matchesWidgetItemQuery(
queryKey,
widgetQueryKeys,
{
itemId: item.id,
boardId: board.id,
integrationIds: item.integrationIds,
options,
runtimeQueries: getWidgetRuntimeQueries(widgetRuntimeRef),
},
definition.queryMatcher,
),
[board.id, definition.queryMatcher, item.id, item.integrationIds, options, widgetQueryKeys, widgetRuntimeRef],
);
const isWidgetFetching =
useIsFetching({
type: "active",
predicate: (query) => matchesWidgetQuery(query.queryKey),
}) > 0;
const handleRefetch = useCallback(() => {
void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) });
}, [matchesWidgetQuery, queryClient]);
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 setItemOptions = useCallback(
(partial: Record<string, unknown>) => {
persistBoard((previous) => ({
...previous,
items: previous.items.map((boardItem) =>
boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem,
),
}));
},
[item.id, persistBoard],
);
type OptionDef = { type: string; skipContextMenu?: boolean }; type OptionDef = { type: string; skipContextMenu?: boolean };
const toggleOptions = useMemo(() => { const toggleOptions = useMemo(() => {
if (!canConfigureWidget) return []; if (!canConfigureWidget) return [];
const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>; const rawOptions = definition.createOptions(settings) as unknown as Record<string, OptionDef>;
return Object.entries(rawOptions).filter(([, def]) => def.type === "switch" && !def.skipContextMenu); return Object.entries(rawOptions).filter(([, option]) => option.type === "switch" && !option.skipContextMenu);
}, [canConfigureWidget, definition, settings]); }, [canConfigureWidget, definition, settings]);
const widgetContextActions = useMemo(() => { const widgetContextActions =
const def = definition as unknown as Record<string, unknown>; definition.contextActions?.({
if (typeof def.contextActions !== "function") return [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actions = (def.contextActions as any)({
options, options,
setOptions: (partial: Record<string, unknown>) => { setOptions: setItemOptions,
updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } });
},
integrationIds: item.integrationIds, integrationIds: item.integrationIds,
context: { isEditMode, boardId: board.id, itemId: item.id }, context: {
widgetStateRef, isEditMode,
}); boardId: board.id,
return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; itemId: item.id,
}, [definition, options, item, updateItemOptions, isEditMode, board.id, widgetStateRef]); canInteractWithSelectedIntegrations,
},
const persistBoard = useCallback( widgetRuntimeRef,
(updatedItems: typeof board.items) => { }) ?? [];
saveBoard({ ...board, items: updatedItems });
},
[board, saveBoard],
);
const openEditModal = useCallback(() => { const openEditModal = useCallback(() => {
openModal( openModal(
@@ -159,27 +164,24 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
updateItemOptions({ itemId: item.id, newOptions: editResult.options }); updateItemOptions({ itemId: item.id, newOptions: editResult.options });
updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions }); updateItemAdvancedOptions({ itemId: item.id, newAdvancedOptions: editResult.advancedOptions });
updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds }); updateItemIntegrations({ itemId: item.id, newIntegrations: editResult.integrationIds });
if (!isEditMode) { persistBoard((previous) => ({
persistBoard( ...previous,
board.items.map((boardItem) => items: previous.items.map((boardItem) =>
boardItem.id !== item.id boardItem.id !== item.id
? boardItem ? boardItem
: { : {
...boardItem, ...boardItem,
options: editResult.options, options: editResult.options,
advancedOptions: editResult.advancedOptions, advancedOptions: editResult.advancedOptions,
integrationIds: editResult.integrationIds, integrationIds: editResult.integrationIds,
}, },
), ),
); }));
}
}, },
integrationData: integrationData.filter( integrationData: integrationData.filter((integration) =>
(integration) => definition.supportedIntegrations?.some((kind) => kind === integration.kind),
"supportedIntegrations" in definition &&
(definition.supportedIntegrations as string[]).some((kind) => kind === integration.kind),
), ),
integrationSupport: "supportedIntegrations" in definition, integrationSupport: definition.supportedIntegrations !== undefined,
settings, settings,
appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined, appId: item.kind === "app" ? (item.options.appId as string | undefined) : undefined,
}, },
@@ -187,72 +189,87 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
{ title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` }, { title: (fn: any) => `${fn("item.edit.title")} - ${fn(`widget.${item.kind}.name`)}` },
); );
}, [ }, [
openModal, definition,
integrationData,
item, item,
updateItemOptions, openModal,
persistBoard,
settings,
updateItemAdvancedOptions, updateItemAdvancedOptions,
updateItemIntegrations, updateItemIntegrations,
integrationData, updateItemOptions,
definition,
settings,
isEditMode,
persistBoard,
board,
]); ]);
const handleToggle = useCallback( const handleToggle = useCallback(
(key: string) => (checked: boolean) => { (key: string) => (checked: boolean) => setItemOptions({ [key]: checked }),
const newOptions = { ...options, [key]: checked }; [setItemOptions],
updateItemOptions({ itemId: item.id, newOptions });
persistBoard(
board.items.map((boardItem) => (boardItem.id !== item.id ? boardItem : { ...boardItem, options: newOptions })),
);
},
[options, item.id, updateItemOptions, persistBoard, board],
); );
const visibleWidgetActions = widgetContextActions.filter((a) => !a.hidden); if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") return <>{children}</>;
const visibleWidgetActions = widgetContextActions.filter((action) => !action.hidden);
return ( return (
<> <Menu
{toggleOptions.length > 0 && ( shadow="md"
<> width={300}
<Menu.Label>{tMenu("options")}</Menu.Label> closeOnItemClick={false}
{toggleOptions.map(([key]) => ( position="right-start"
<Menu.Item key={key} onClick={() => handleToggle(key)(!options[key])}> offset={4}
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} opened={menuOpened}
<Group wrap="nowrap"> onChange={setMenuOpened}
{String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))} >
<Switch size="xs" checked={Boolean(options[key])} readOnly tabIndex={-1} ml="auto" /> <Menu.ContextMenu>{children}</Menu.ContextMenu>
</Group> <Menu.Dropdown>
{canOpenAdvancedFocus && (
<>
<Menu.Item
closeMenuOnClick
leftSection={<IconMaximize size={16} />}
onClick={() => {
if (sourceRef.current) openAdvancedFocus(item.id, sourceRef.current);
}}
>
{t("item.advancedFocus.open")}
</Menu.Item> </Menu.Item>
))} <Menu.Divider />
</> </>
)} )}
{visibleWidgetActions.length > 0 && ( {toggleOptions.length > 0 && (
<> <>
{toggleOptions.length > 0 && <Menu.Divider />} <Menu.Label>{tMenu("options")}</Menu.Label>
<Menu.Label>{tMenu("actions")}</Menu.Label> {toggleOptions.map(([key]) => (
{visibleWidgetActions.map((action) => { <Menu.CheckboxItem key={key} checked={Boolean(options[key])} onChange={handleToggle(key)}>
const Icon = action.icon; {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
return ( {String(translateIfNecessary(t, ((fn: any) => fn(`widget.${item.kind}.option.${key}.label`)) as any))}
<Menu.Item </Menu.CheckboxItem>
key={action.key} ))}
closeMenuOnClick </>
leftSection={Icon ? <Icon size={16} /> : undefined} )}
onClick={action.onClick}
disabled={action.disabled} {visibleWidgetActions.length > 0 && (
color={action.color} <>
> {toggleOptions.length > 0 && <Menu.Divider />}
{String(translateIfNecessary(t, action.label))} <Menu.Label>{tMenu("actions")}</Menu.Label>
</Menu.Item> {visibleWidgetActions.map((action) => {
); const Icon = action.icon;
})} return (
</> <Menu.Item
)} key={action.key}
closeMenuOnClick
leftSection={Icon ? <Icon size={16} /> : undefined}
onClick={action.onClick}
disabled={action.disabled}
color={action.color}
>
{translateIfNecessary(t, action.label)}
</Menu.Item>
);
})}
</>
)}
<>
{(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />} {(toggleOptions.length > 0 || visibleWidgetActions.length > 0) && <Menu.Divider />}
<Menu.Item <Menu.Item
leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />} leftSection={isWidgetFetching ? <Loader size={16} /> : <IconRefresh size={16} />}
@@ -263,7 +280,7 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
{tMenu("refresh")} {tMenu("refresh")}
<WidgetQueryStatus <WidgetQueryStatus
queryClient={queryClient} queryClient={queryClient}
queryKeys={widgetQueryKeys} matchesQuery={matchesWidgetQuery}
isFetching={isWidgetFetching} isFetching={isWidgetFetching}
t={t} t={t}
/> />
@@ -279,23 +296,27 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
> >
{tMenu("settings")} {tMenu("settings")}
</Menu.Item> </Menu.Item>
</> </Menu.Dropdown>
</> </Menu>
); );
}; };
interface WidgetQueryStatusProps { interface WidgetQueryStatusProps {
queryClient: QueryClient; queryClient: QueryClient;
queryKeys: QueryKey[]; matchesQuery: (queryKey: QueryKey) => boolean;
isFetching: boolean; isFetching: boolean;
t: TranslationFunction; t: TranslationFunction;
} }
const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => {
const queries = [...new Set(queryKeys.flatMap((queryKey) => queryClient.getQueryCache().findAll({ queryKey })))]; const queries = queryClient.getQueryCache().findAll({
type: "active",
predicate: (query) => matchesQuery(query.queryKey),
});
const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean);
const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; 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) { if (isFetching) {
return ( return (
@@ -316,22 +337,15 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
); );
} }
const hasError = queries.some((q) => q.state.status === "error"); const hasError = queries.some((query) => query.state.status === "error");
if (hasError) { if (hasError) {
const errorQuery = queries.find((q) => q.state.status === "error");
const errorMessage =
errorQuery?.state.error instanceof Error
? errorQuery.state.error.message
: String(errorQuery?.state.error ?? "Unknown error");
return ( return (
<Tooltip label={errorMessage} multiline position="left" w={250}> <Tooltip label={t("item.menu.status.error")} position="left">
<Group gap={4} wrap="nowrap"> <Group gap={4} wrap="nowrap">
<IconAlertTriangle size={12} color="var(--mantine-color-red-6)" /> <IconAlertTriangle size={12} color="var(--mantine-color-red-6)" />
<Text size="xs" c="red.6"> <Text size="xs" c="red.6">
{t("item.menu.status.error")} {t("item.menu.status.error")}
{latest > 0 && ` · ${ageLabel}`} {ageSuffix}
</Text> </Text>
</Group> </Group>
</Tooltip> </Tooltip>
@@ -343,7 +357,7 @@ const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQuer
<IconCircleCheck size={12} color="var(--mantine-color-green-6)" /> <IconCircleCheck size={12} color="var(--mantine-color-green-6)" />
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t("item.menu.status.success")} {t("item.menu.status.success")}
{latest > 0 && ` · ${ageLabel}`} {ageSuffix}
</Text> </Text>
</Group> </Group>
); );
@@ -105,6 +105,15 @@
flex-shrink: 0; flex-shrink: 0;
} }
@media (forced-colors: active) {
.panel {
border-color: CanvasText;
background: Canvas;
box-shadow: none;
backdrop-filter: none;
}
}
.metadataValue { .metadataValue {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -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<Section, { kind: "dynamic" }> | DynamicSectionItem;
refs: UseGridstackRefs;
}
export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProps }: PropsWithChildren<Props>) => {
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<MenuState | null>(null);
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const returnFocusRef = useRef<HTMLElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<Box
{...boxProps}
ref={refs.wrapper}
tabIndex={enabled ? 0 : undefined}
aria-label={enabled ? t("item.create.addAtPosition") : undefined}
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
onPointerMove={clearLongPress}
onPointerUp={clearLongPress}
onPointerCancel={clearLongPress}
onPointerLeave={clearLongPress}
>
{children}
</Box>
<Menu
opened={menu !== null}
onChange={(opened) => {
if (!opened) {
setMenu(null);
const returnFocus = returnFocusRef.current;
returnFocusRef.current = null;
requestAnimationFrame(() => returnFocus?.focus());
}
}}
withinPortal
position="bottom-start"
>
<Menu.Target>
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
</Menu.Target>
<Menu.Dropdown>
<Menu.Item
leftSection={<IconPlus size={18} />}
onClick={() => {
returnFocusRef.current = null;
if (menu) {
openModal({ placement: menu.placement, board, persistImmediately: !isEditMode });
}
setMenu(null);
}}
>
{t("item.create.addAtPosition")}
</Menu.Item>
</Menu.Dropdown>
</Menu>
</>
);
};
@@ -0,0 +1 @@
export const appShellHeaderHeight = 60;
+2 -1
View File
@@ -5,6 +5,7 @@ import { AppShell } from "@mantine/core";
import { useAtomValue } from "jotai"; import { useAtomValue } from "jotai";
import { useOptionalBackgroundProps } from "./background"; import { useOptionalBackgroundProps } from "./background";
import { appShellHeaderHeight } from "./constants";
import { navigationCollapsedAtom } from "./header/burger"; import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps { interface ClientShellProps {
@@ -23,7 +24,7 @@ export const ClientShell = ({
return ( return (
<AppShell <AppShell
{...backgroundProps} {...backgroundProps}
header={hasHeader ? { height: 60 } : undefined} header={hasHeader ? { height: appShellHeaderHeight } : undefined}
navbar={ navbar={
hasNavigation hasNavigation
? { ? {
+364
View File
@@ -0,0 +1,364 @@
import type { Browser, BrowserContext, Locator, Page } from "@playwright/test";
import { chromium, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { stringify } from "superjson";
import { afterAll, beforeAll, describe, test } from "vitest";
import { hashPasswordAsync } from "../packages/auth/security";
import {
boardUserPermissions,
boards,
itemLayouts,
items,
layouts,
sections,
users,
} from "../packages/db/schema/sqlite";
import type { SqliteDatabase } from "./shared/e2e-db";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
const boardName = "advanced-interactions-e2e";
describe("Board advanced interactions", () => {
let browser: Browser;
let baseUrl: string;
let stopContainerAsync: (() => Promise<void>) | 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 = '<label>State <input id="state" value="initial"></label>';
});
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,
});
};
@@ -101,9 +101,7 @@ describe("patchmonRouter.getStats access control", () => {
}); });
const caller = createCaller(db, null); const caller = createCaller(db, null);
await expect(caller.getStats({ integrationId })).rejects.toThrow( await expect(caller.getStats({ integrationId })).rejects.toThrow(new TRPCError({ code: "UNAUTHORIZED" }));
new TRPCError({ code: "UNAUTHORIZED" }),
);
}); });
test("should throw FORBIDDEN for authenticated users without board or integration access", async () => { test("should throw FORBIDDEN for authenticated users without board or integration access", async () => {
@@ -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",
});
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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,
);
+17
View File
@@ -13,6 +13,7 @@ import {
import { settleIntegrationQueries } from "../../settle-integrations"; import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
import { everyBeszelIntegrationFailed } from "./beszel-failures";
import { BoundedAsyncQueue } from "./bounded-async-queue"; import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" }); 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)); 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({ export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure getSystems: publicProcedure
.meta({ .meta({
@@ -63,6 +78,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length, resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length, errorCount: results.filter((r) => "error" in r).length,
}); });
throwIfEveryBeszelIntegrationFailed(results, "systems");
return results; return results;
}), }),
@@ -136,6 +152,7 @@ export const beszelRouter = createTRPCRouter({
resultCount: results.length, resultCount: results.length,
errorCount: results.filter((r) => "error" in r).length, errorCount: results.filter((r) => "error" in r).length,
}); });
throwIfEveryBeszelIntegrationFailed(results, "alerts");
return results; return results;
}), }),
+13 -36
View File
@@ -1,16 +1,13 @@
import { z } from "zod/v4"; 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 { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types"; import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar"; import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "calendarRouter" });
export const calendarRouter = createTRPCRouter({ export const calendarRouter = createTRPCRouter({
findAllEvents: publicProcedure findAllEvents: publicProcedure
.meta({ .meta({
@@ -30,39 +27,19 @@ export const calendarRouter = createTRPCRouter({
) )
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar")))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const settled = await Promise.allSettled( return await settleIntegrationQueries(ctx.integrations, async (integration) => {
ctx.integrations.map(async (integration) => { const { integrationIds: _integrationIds, ...handlerInput } = input;
const { integrationIds: _integrationIds, ...handlerInput } = input; const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput);
const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); const { data } = await innerHandler.getDataAsync();
const { data } = await innerHandler.getDataAsync();
return { return {
events: data, events: data,
integration: { integration: {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, 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 [];
}); });
}), }),
}); });
@@ -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<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
@@ -33,7 +33,6 @@ const namedRequestInputSchema = itemInputSchema.extend({
requestId: z.string().min(1).max(64), requestId: z.string().min(1).max(64),
params: runtimeParamsSchema.default({}), params: runtimeParamsSchema.default({}),
}); });
interface CustomWidgetItemOptions { interface CustomWidgetItemOptions {
definitionId: string; definitionId: string;
configuration: Record<string, unknown>; configuration: Record<string, unknown>;
+24 -11
View File
@@ -18,20 +18,33 @@ const queryFirewall = <
}, },
>( >(
handler: THandler, handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) => ) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) => publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(ctx.integrations, async (integration) => { settleIntegrationQueries(
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync(); ctx.integrations,
return { async (integration) => {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"], return {
}; integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
}), summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["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({ export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler), getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler), getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler), getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler), getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
}); });
@@ -16,15 +16,29 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query")) .concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => { return await settleIntegrationQueries(
const innerHandler = indexerManagerRequestHandler.handler(integration, {}); ctx.integrations,
const { data: indexers } = await innerHandler.getDataAsync(); async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return { return {
integrationId: integration.id, integrationId: integration.id,
indexers, 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 testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact")) .concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer"; import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc"; import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({ export const mediaOrganizerRouter = createTRPCRouter({
@@ -18,16 +19,30 @@ export const mediaOrganizerRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer"))) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaOrganizer")))
.input(z.object({ pageSize: z.number().min(1).max(50).default(10) })) .input(z.object({ pageSize: z.number().min(1).max(50).default(10) }))
.query(async ({ ctx, input }) => { .query(async ({ ctx, input }) => {
const results = await Promise.all( return await settleIntegrationQueries(
ctx.integrations.map(async (integration) => { ctx.integrations,
async (integration) => {
const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize }); const innerHandler = mediaOrganizerRequestHandler.handler(integration, { pageSize: input.pageSize });
const { data } = await innerHandler.getDataAsync(); const { data } = await innerHandler.getDataAsync();
return { return {
integrationId: integration.id, integrationId: integration.id,
integrationName: integration.name,
...data, ...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;
}), }),
}); });
@@ -34,6 +34,7 @@ export const mediaRequestsRouter = createTRPCRouter({
data.map((request) => ({ data.map((request) => ({
...request, ...request,
integrationId: integration.id, integrationId: integration.id,
integration,
})), })),
) )
.toSorted((dataA, dataB) => { .toSorted((dataA, dataB) => {
@@ -99,5 +100,6 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const; } as const;
await answerActions[input.answer](input.requestId); await answerActions[input.answer](input.requestId);
return { success: true };
}), }),
}); });
@@ -27,7 +27,12 @@ export const mediaServerRouter = createTRPCRouter({
const { data } = await mediaServerRequestHandler const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) .handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync(); .getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data }; return {
integrationId: integration.id,
integrationName: integration.name,
integrationKind: integration.kind,
sessions: data,
};
}); });
}), }),
}); });
@@ -13,19 +13,36 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query")) .unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => { return await settleIntegrationQueries(
const innerHandler = notificationsRequestHandler.handler(integration, {}); ctx.integrations,
const { data, timestamp } = await innerHandler.getDataAsync(); async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return { return {
integration: { integration: {
id: integration.id, id: integration.id,
name: integration.name, name: integration.name,
kind: integration.kind, kind: integration.kind,
updatedAt: timestamp, updatedAt: timestamp,
}, },
data, 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,
},
);
}), }),
}); });
@@ -37,6 +37,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 return settled
.flatMap((result, index) => { .flatMap((result, index) => {
if (result.status === "fulfilled") return result.value.data.entries; if (result.status === "fulfilled") return result.value.data.entries;
+30 -5
View File
@@ -1,8 +1,12 @@
import { z } from "zod/v4"; import { z } from "zod/v4";
import { TRPCError } from "@trpc/server";
import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createIntegrationAsync } from "@homarr/integrations"; 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 type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration"; import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -25,7 +29,22 @@ export const smartHomeRouter = createTRPCRouter({
.query(async ({ ctx: { integration }, input }) => { .query(async ({ ctx: { integration }, input }) => {
const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId }); const innerHandler = smartHomeEntityStateRequestHandler.handler(integration, { entityId: input.entityId });
const { data } = await innerHandler.getDataAsync(); 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 switchEntity: protectedProcedure
.meta({ .meta({
@@ -40,8 +59,10 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId); const success = await client.triggerToggleAsync(input.entityId);
if (!success) {
return success; throw new TRPCError({ code: "BAD_GATEWAY", message: "Home Assistant did not toggle the entity" });
}
return true;
}), }),
executeAutomation: protectedProcedure executeAutomation: protectedProcedure
.meta({ .meta({
@@ -55,6 +76,10 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() })) .input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => { .mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration); 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;
}), }),
}); });
+65 -11
View File
@@ -1,36 +1,90 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod"; 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 { import {
timetableGetTimetableRequestHandler, timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler, timetableSearchStationsRequestHandler,
} from "@homarr/request-handler/timetable"; } from "@homarr/request-handler/timetable";
import { DEFAULT_TIMETABLE_BASE_URL, normalizeTimetableBaseUrl } from "@homarr/request-handler/timetable-url";
import { createTRPCRouter, publicProcedure } from "../../trpc"; 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<typeof timetableSourceSchema>,
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<Record<string, unknown>>(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({ export const timetableRouter = createTRPCRouter({
getTimetable: publicProcedure getTimetable: publicProcedure
.input( .input(
z.object({ timetableSourceSchema.extend({
baseUrl: baseUrlSchema, stationId: z.string().min(1).max(200),
stationId: z.string(),
limit: z.number().int().min(1).max(100), limit: z.number().int().min(1).max(100),
}), }),
) )
.query(async ({ input }) => { .query(async ({ ctx, input }) => {
const { data } = await timetableGetTimetableRequestHandler.handler(input).getDataAsync(); const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data; return data;
}), }),
searchStations: publicProcedure searchStations: publicProcedure
.input( .input(
z.object({ timetableSourceSchema.extend({
baseUrl: baseUrlSchema, query: z.string().max(200),
query: z.string(),
}), }),
) )
.query(async ({ input }) => { .query(async ({ ctx, input }) => {
const { data } = await timetableSearchStationsRequestHandler.handler(input).getDataAsync(); const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data; return data;
}), }),
}); });
+29 -12
View File
@@ -9,17 +9,34 @@ export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"))) .unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => { .query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => { return await settleIntegrationQueries(
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync(); ctx.integrations,
return { async (integration) => {
integration: { const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
id: integration.id, return {
name: integration.name, integration: {
kind: integration.kind, id: integration.id,
updatedAt: timestamp, name: integration.name,
}, kind: integration.kind,
summary: data, 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,
},
);
}), }),
}); });
+7 -3
View File
@@ -11,6 +11,7 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> { interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult; fallback?: (integration: TIntegration, error: unknown) => TResult;
throwOnAllFailures?: boolean;
} }
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>( export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
@@ -37,15 +38,18 @@ export async function settleIntegrationQueries<TIntegration extends IntegrationL
), ),
); );
errors.push(result.reason);
if (options?.fallback && integration) { if (options?.fallback && integration) {
results.push(options.fallback(integration, result.reason)); results.push(options.fallback(integration, result.reason));
return; return;
} }
errors.push(result.reason);
}); });
if (results.length === 0 && errors.length > 0) { if (
errors.length > 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
throw errors[0]; throw errors[0];
} }
Loaded 100 of 270 files, more files were not shown because too many files have changed in this diff. Show more