Compare commits

..
Author SHA1 Message Date
ajnart 97fcb518d8 docs: update header preferences schema 2026-08-25 09:54:56 +02:00
ajnart 90859dcde6 fix: satisfy header composer lint 2026-08-25 09:48:53 +02:00
ajnart a7630b7d6d feat: add configurable header logo display 2026-08-24 18:39:32 +02:00
ajnart 2dbeb792a7 fix: refine header layout editor 2026-08-24 18:23:50 +02:00
ajnart e68a1491ae fix: clarify header zones 2026-08-24 17:48:03 +02:00
ajnart 29c827f1be fix: simplify header reordering 2026-08-24 17:40:26 +02:00
ajnart d0fe65a1d6 fix: simplify header reordering 2026-08-24 16:55:25 +02:00
ajnart 3c83e646ca Merge remote-tracking branch 'origin/release/v2' into feat/configurable-header-branding
# Conflicts:
#	apps/nextjs/src/components/layout/header/search.tsx
#	packages/translation/src/lang/en.json
2026-08-24 16:15:11 +02:00
ajnart 27decf209a Merge remote-tracking branch 'origin/release/v2' into feat/configurable-header-branding
# Conflicts:
#	packages/onboarding/src/onboarding-wordmark.tsx
2026-08-24 12:34:16 +02:00
ajnart 75bf432859 refactor: consolidate header branding migrations 2026-08-24 09:39:54 +02:00
ajnart 12cad9d3fb fix: preserve default header and login branding 2026-08-24 09:18:45 +02:00
ajnart d39d6cf888 fix(ci): update MCP tool inventory 2026-08-24 08:09:48 +02:00
ajnart 2613a556b1 fix: address branding review feedback 2026-08-24 07:58:48 +02:00
ajnart 346bead673 fix(ci): align focused test expectations 2026-08-24 01:29:35 +02:00
ajnart 3a16f63e26 fix(ci): refresh OpenAPI schema 2026-08-24 01:15:28 +02:00
ajnart 54afb14030 fix: simplify header reordering 2026-08-24 01:07:04 +02:00
ajnart af394577b8 feat: reveal floating header from corner 2026-08-24 00:55:34 +02:00
ajnart f8d3d5893a feat: refine configurable header and branding 2026-08-24 00:35:53 +02:00
ajnart 803d590a16 fix: remove redundant logo tooltip 2026-08-23 23:17:41 +02:00
ajnart aa891d61ed fix: enlarge branded management banner logo 2026-08-23 23:16:39 +02:00
ajnart f52a391157 fix: keep About icon tied to Homarr 2026-08-23 23:15:24 +02:00
ajnart d4b075ba55 chore: simplify assistant branding 2026-08-23 23:11:16 +02:00
ajnart 86550aadc9 feat: add configurable headers and instance branding 2026-08-23 22:28:13 +02:00
217 changed files with 1746 additions and 3408 deletions

No files matched your search

@@ -36,8 +36,6 @@ pnpm cli harness setup feature-board
The command builds `homarr:feature-board`, starts a container with a persistent data volume, enables demo mode, and keeps demo mutations writable (`DEMO_READ_ONLY=false`). Docker assigns a free host port by default; use the URL printed by the command or query it later:
The demo board includes every widget kind. Integration-backed widgets use the bundled mock integration; media widgets use public stock assets.
```sh
pnpm cli harness port feature-board
```
+1 -1
View File
@@ -11,7 +11,7 @@ import { appWidget } from ".";
<WidgetHeader 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. Compact tiles retain the familiar title, optional description, and centered app icon proportions.
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
It also supports a status check to indicate whether Homarr can reach the application or website.
### Screenshots
@@ -23,8 +23,6 @@ This widget adapts its display based on the linked integration:
Only options relevant to your linked integration are shown in the configuration panel.
Compact mode keeps every configured statistic visible in the familiar filled tile layout, including a vertical stack for narrow Navidrome cards. Advanced mode retains the complete expanded statistics and stream details.
### Supported Integrations
<WidgetIntegrations
@@ -17,7 +17,7 @@ import beszelAlerts from "./img/beszel-alerts.png";
<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. Compact mode keeps configured OK alerts and descriptions visible alongside triggered alerts, and keeps history visible when the history option is enabled. 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.
### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ 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. Compact resource badges and footer details remain visible without height-based hiding.
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
@@ -22,7 +22,7 @@ import { technitiumDnsIntegration } from "@site/docs/integrations/technitium-dns
When several DNS integrations are selected, Homarr keeps healthy controls available if one source fails and shows a warning identifying the unavailable integration.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, with its familiar textual status control retained at compact heights. Bulk actions keep successful results visible and report the number of failures.
This widget lets permitted users enable, disable, or temporarily pause one or more DNS services. It shares the summary query and cache with DNS Hole Summary. Every selected source stays available in the normal scrollable view, while cards and controls adapt to the widget&apos;s width and height. Bulk actions keep successful results visible and report the number of failures.
### Screenshots
@@ -12,7 +12,7 @@ import { dockerContainersWidget } from ".";
<WidgetHeader widget={dockerContainersWidget} categories={["Containers"]} />
This widget displays the resource usage of your Docker containers and allows you to manage them. Compact mode keeps every configured table column and shows the complete CPU and memory totals whenever the footer is available.
This widget displays the resource usage of your Docker containers and allows you to manage them.
:::info
This widget reads the Docker environments configured through environment variables. You do not need to create a Docker integration under **Management → Integrations**. The widget can only be used with administrator privileges.
+6 -5
View File
@@ -58,7 +58,7 @@ import filterPanelImg from "./img/filter-panel.png";
### Features
- **Responsive compact table** — in compact mode, selected columns use the established width breakpoints; widget height does not remove configured columns or the footer
- **Responsive compact table** — in compact mode, selected columns adapt to both widget width and height; narrow or short tiles hide speed/state columns, and the shortest tiles also hide the ETA column
- **Sortable columns** — click any sortable column header to sort the current view; the configured default controls the initial column and direction
- **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
- **Download-row context menu** — copy a download name or ID from any row; pause, resume, and delete actions appear when you have Interact access to that integration
@@ -72,10 +72,11 @@ import filterPanelImg from "./img/filter-panel.png";
:::note
Compact layouts use their established width breakpoints: below 500px wide, speed columns are hidden; below 300px wide,
the ETA and state columns are hidden too. Height does not remove configured columns or the footer. Advanced view bypasses
the compact width reductions, keeps the selected column set, and always includes the stats bar and footer controls, with
the filter panel available from the footer.
Compact layouts use both dimensions: below 500px wide, speed columns are hidden; below 300px wide, the ETA and state
columns are hidden too. A tile shorter than 180px hides speed and state columns, and a tile shorter than 120px also
hides ETA. The footer is hidden below 96px in compact mode. Advanced view bypasses these compact size reductions,
keeps the selected column set, and always includes the stats bar and footer controls, with the filter panel available
from the footer.
The filter panel changes the visible rows. Its client/status filters also change the stats bar's progress, size, and
state counts, while transfer-speed totals summarize the selected clients and the global torrent ratio summarizes the
@@ -16,8 +16,6 @@ import { opnsenseIntegration } from "../../integrations/opnsense";
<WidgetHeader widget={firewallWidget} categories={["Firewall"]} />
Compact mode keeps the configured firewall summary and interface section visible without dropping interfaces based on widget height.
### Screenshots
![Firewall Widget](img/firewall_widget.png)
@@ -15,8 +15,6 @@ import { immichServerStatsWidget } from ".";
<WidgetHeader widget={immichServerStatsWidget} categories={["Photos server"]} />
Compact mode presents every enabled statistic as a vertical icon, label, and value card.
### Screenshots
import screen from "./img/screen.png";
@@ -14,8 +14,6 @@ import { indexerManagerWidget } from ".";
<WidgetHeader widget={indexerManagerWidget} categories={["Indexer"]} />
Compact mode keeps the titled indexer list, per-indexer status indicators, and full-width **Test all** action together.
### Screenshots
import bigItem from "./img/big.png";
@@ -17,7 +17,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaReleasesWidget} categories={["Releases", "Media"]} />
Provides a list of newly added media or upcoming releases from your media server. Compact rows retain their poster, full release date and time, and configured metadata.
Provides a list of newly added media or upcoming releases from your media server.
Releases retain the name and icon of the integration that supplied them. If one integration is unavailable, releases from healthy integrations remain visible and a warning identifies the failed source without exposing connection details. The widget reports an error only when every selected integration fails.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestListWidget} categories={["Media requests"]} />
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests. Compact request cards retain their full artwork, request metadata, title, status, and permitted actions.
Provides a list of all open and recently closed requests. It also provides the functionality to approve or reject requests.
You can limit the list to specific request statuses (for example, only show `pending`) and restrict it to requests created within the last N days.
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
<WidgetHeader widget={mediaRequestStatsWidget} categories={["Media requests"]} />
Provides an overview of media requests, including totals, availability, approvals, and rejections. Compact mode keeps every configured stat and section title visible; the user ranking remains scrollable instead of being truncated.
Provides an overview of media requests, including totals, availability, approvals, and rejections. Resize the widget to reveal denser stat tiles; the user ranking remains scrollable instead of being truncated.
### Supported Integrations
@@ -22,7 +22,7 @@ import { plexIntegration } from "@site/docs/integrations/plex";
<WidgetHeader widget={mediaServerWidget} categories={["Media server"]} />
The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently. Compact mode keeps the user, currently playing, and status columns together with the stream summary and integration names.
It shows playback progress, pause state, time remaining, the streaming resolution (e.g. `1080p`), and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
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.
@@ -17,7 +17,7 @@ import { minecraftServerStatusWidget } from ".";
<WidgetHeader widget={minecraftServerStatusWidget} categories={["Gaming", "Minecraft"]} />
Shows the status and amount of players on a Minecraft server. Compact mode keeps the server icon and player count visible instead of hiding them based on tile size. It supports both Java and Bedrock editions, but only public servers accessible through the [mcsrvstat.us API](https://api.mcsrvstat.us) are supported.
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
@@ -20,7 +20,7 @@ import { networkControllerStatusWidget } from ".";
Shows connected users and connected guest devices on wired or wireless networks.
Compact view aggregates the configured controllers into one familiar card for the selected Wi-Fi or wired total. A failed refresh is shown as a small warning instead of replacing the totals card. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
Compact view aggregates the configured controllers into the selected Wi-Fi or wired total. Advanced view shows both totals separately for every controller, preserving each integration's name, type, and exact update time while keeping partial-source and stale-data warnings visible.
### Screenshots
@@ -18,7 +18,7 @@ import { networkControllerSummaryWidget } from ".";
<WidgetHeader widget={networkControllerSummaryWidget} categories={["Network controller"]} />
Shows whether your WAN, internet, Wi-Fi, and VPN are available. The compact board view keeps the familiar status list, including internet latency and the connected VPN-user count.
Shows whether your WAN, internet, Wi-Fi, LAN, and VPN are available. Wider or taller layouts progressively reveal latency, ping, and connected-client counts without a separate view.
### Screenshots
@@ -36,7 +36,7 @@ import summaryCard from "./img/summary.png";
]}
/>
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users.
In advanced view, each controller gets its own information matrix showing source, last update, WAN and web status, latency, ping, web uptime, separate Wi-Fi and LAN user and guest counts, and connected VPN users. The compact board view keeps its progressive aggregate layout.
<WidgetInteractionGuide />
@@ -20,7 +20,7 @@ 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. Compact mode presents them as a vertical list of padded notification cards.
The notifications widget allows you to display notifications from various integrations in a unified view.
### Screenshots
@@ -15,7 +15,7 @@ import { paperlessNgxIntegration } from "@site/docs/integrations/paperless-ngx";
<WidgetHeader 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. Compact mode keeps every configured statistic in the familiar filled tile layout and uses a vertical stack on narrow cards.
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
+2 -2
View File
@@ -13,7 +13,7 @@ import { releasesWidget } from ".";
<WidgetHeader widget={releasesWidget} categories={["Releases", "Software"]} />
The releases widget enables you to track a list of repositories and see their respective latest release. Compact mode keeps repositories in the familiar full-width stack.
The releases widget enables you to track a list of repositories and see their respective latest release.
It also supports importing releases from your local docker containers.
### Screenshots
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
Advanced view shows every configured repository in a roomier responsive layout. Repository names and details remain visible, and selecting a repository still expands its description and links.
Advanced view shows every configured repository even when compact view is limited to highlighted or top releases. Repository names and details remain visible, and selecting a repository still expands its description and links.
<WidgetInteractionGuide />
+1 -1
View File
@@ -13,7 +13,7 @@ import { rssFeedWidget } from ".";
<WidgetHeader widget={rssFeedWidget} categories={["Feeds"]} />
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard. Compact mode uses a vertical card list and keeps enclosure images as the familiar poster and blurred backdrop.
Using the RSS standard, you can integrate most News websites, applications and more on your dashboard.
### Screenshots
@@ -18,7 +18,7 @@ import { homeAssistantIntegration } from "@site/docs/integrations/home-assistant
<WidgetHeader widget={smartHomeEntityStateWidget} categories={["Smart home"]} />
The entity state enables you to display the current state of an entity. Compact mode presents the friendly name first and its state beneath it.
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.
It is recommended that you transform data on the integration as much as possible since Homarr's capabilities in transformation are very limited.
@@ -18,7 +18,7 @@ import { speedtestTrackerIntegration } from "@site/docs/integrations/speedtest-t
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.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps every configured overview section in its familiar card layout and names its healthy sources when multiple integrations are selected.
Advanced view renders a source-labeled panel for every healthy integration. Each panel exposes that source's latest test, statistics, speed and ping charts, plus 12-hour, 24-hour, and truthful **Last 30** result ranges regardless of compact visibility switches. Compact mode keeps the responsive aggregate overview and names its healthy sources when multiple integrations are selected.
When multiple Speedtest Tracker integrations are selected, results from healthy sources remain visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
@@ -19,7 +19,7 @@ import { synologyIntegration } from "@site/docs/integrations/synology";
<WidgetHeader widget={systemDisksWidget} categories={["Storage", "Disks"]} />
This widget displays filesystem and SMART disk usage for supported server and NAS integrations. Compact mode uses a one-column card list with the configured primary value, available temperature, and solid health bar.
This widget displays filesystem and SMART disk usage for supported server and NAS integrations.
### Supported Integrations
@@ -18,7 +18,7 @@ import { proxmoxIntegration } from "@site/docs/integrations/proxmox";
<WidgetHeader widget={systemResourcesWidget} categories={["CPU", "RAM", "Memory", "Network", "Interface"]} />
Displays CPU, RAM and network of your host in real time. Compact view keeps every configured chart in its familiar stacked layout, while advanced view adds the expanded per-source presentation.
Displays CPU, RAM and network of your host in real time
### Screenshots
+1 -1
View File
@@ -13,7 +13,7 @@ import { timetableWidget } from ".";
<WidgetHeader widget={timetableWidget} categories={["Timetable"]} />
The Timetable widget provides real-time departure times for a selected station. Compact mode keeps configured titles, line names, and platform fields visible while adapting dense rows to the available space.
The Timetable widget provides real-time departure times for a selected station.
### Screenshots
+1 -1
View File
@@ -15,7 +15,7 @@ import { tracearrIntegration } from "@site/docs/integrations/tracearr";
<WidgetHeader 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. Compact mode renders every enabled section in that familiar order without hiding or truncating sections based on widget height.
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.
When multiple Tracearr integrations are selected, every stream, violation, and activity row keeps its source identity and an integration-scoped key. Data from healthy sources remains visible if another source fails, and a warning identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -17,7 +17,7 @@ import { traefikIntegration } from "@site/docs/integrations/traefik";
This widget displays a compact overview of your Traefik instance, including total routers, entry points, protocol-specific router/service/middleware counts, and warning or error totals when Traefik reports them.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps every configured protocol card and entry-point label visible and names each healthy source when multiple integrations are selected.
Advanced view shows every protocol and entry point, identifies every healthy Traefik source and version, and labels each resource or failed endpoint with its owning integration. Compact mode keeps the responsive aggregate overview and names each healthy source when multiple integrations are selected.
When multiple Traefik integrations are selected, data from healthy sources remains visible if another source fails. A warning indicator identifies each unavailable integration without exposing private request details. If every selected integration fails, the widget reports an error; if cached data remains visible after a later total failure, a stale-data warning is shown.
+1 -1
View File
@@ -15,7 +15,7 @@ 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. Compact mode keeps configured axes and secondary statistics visible regardless of widget height.
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
+1 -1
View File
@@ -15,7 +15,7 @@ import { uptimeKumaIntegration } from "@site/docs/integrations/uptime-kuma";
<WidgetHeader 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. Compact mode shows every configured ring and statistic without dropping them based on widget height.
This widget displays uptime statistics from your Uptime Kuma status page, including a visual uptime ring, average uptime percentage, and per-status monitor counts.
Advanced view always shows the uptime ring, all status totals, and the complete monitor list with source identity, regardless of compact visibility switches.
+2 -4
View File
@@ -38,10 +38,8 @@ its server; changing the browser clock cannot bypass them.
Workshop keeps the OpenRouter API key on its server. Homarr and the browser never receive it.
Prompts, attachments, tools, and tool results pass through Workshop and OpenRouter. Workshop does not retain this
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. Each active day
has a separate quota record so Workshop operators can inspect per-user daily usage without seeing request content.
OpenRouter is configured for zero data retention with data collection disabled. Homarr does not use this content to
train models.
content or the responses. It stores only the user, UTC day, and counters needed to enforce allowances. OpenRouter is
configured for zero data retention with data collection disabled. Homarr does not use this content to train models.
We would rather become millionaires in a more ethical manner.
-3
View File
@@ -72,9 +72,6 @@ per-request telemetry. It accepts only uploaded image data, conservatively bound
allows at most two concurrent requests per user and 100 across the service. Keep an OpenRouter account-level credit
limit as the final monetary backstop because request quotas are not dollar accounting.
PocketBase stores one `assistant_quotas` record per user and active UTC day. Workshop superusers can use this private
collection for a daily usage breakdown; regular users cannot list or view its records.
Run `pnpm test:workshop` to test authentication, streaming, UTC reset, atomic per-user and global quota enforcement,
failed-request accounting, identity attribution, server-selected model routing, function tools, input and output
controls, privacy routing, spoofing resistance, absence of request logs, and migration rollback.
+1 -1
View File
@@ -4387,7 +4387,7 @@
"enum": ["input", "icon"]
},
"logoDisplay": {
"default": "logoAndText",
"default": "logo",
"type": "string",
"enum": ["logo", "logoAndText"]
},
@@ -89,9 +89,8 @@ const repairIconSearchInput = <T extends AssistantToolCallInput>(toolCall: T): T
};
};
const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
const isCustomWidgetTool = toolCall.toolName.startsWith("customWidget_");
if (!isCustomWidgetTool && toolCall.toolName !== "configure_board_settings") return null;
const repairCustomWidgetToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null => {
if (!toolCall.toolName.startsWith("customWidget_")) return null;
if (customWidgetNoInputToolNames.has(toolCall.toolName)) {
try {
const input = JSON.parse(toolCall.input) as unknown;
@@ -115,4 +114,4 @@ const repairMultilineToolInput = <T extends AssistantToolCallInput>(toolCall: T)
};
export const repairAssistantToolInput = <T extends AssistantToolCallInput>(toolCall: T): T | null =>
repairIconSearchInput(toolCall) ?? repairMultilineToolInput(toolCall);
repairIconSearchInput(toolCall) ?? repairCustomWidgetToolInput(toolCall);
@@ -151,12 +151,12 @@ Homarr concepts:
Action rules:
- Prefer read-only tools before actions.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never use ask_user to confirm a board settings or custom CSS change before configure_board_settings, or to confirm another change whose inputs are already sufficient for its native review form or mutating tool. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Whenever you need an answer from the user before choosing the next action, call ask_user. Never end a prose response with a question that expects the user to type a reply. Offer distinct choices, categorize agreement or proceeding as affirmative, refusal as negative, and unrelated selections as alternative, then leave the freeform Other answer enabled. A confirmation question must have exactly one affirmative option. Purely rhetorical questions and open-ended conversation that does not block an action may remain prose, but optional next steps should be stated declaratively instead of phrased as a question.
- Mutating Homarr tools already pause for native user approval. Calling a mutation only proposes the action; it cannot execute until the user selects Approve and run. Once the requested change is sufficiently specified, call the mutation immediately so the approval UI appears.
- A prose response such as "Please confirm if you would like me to proceed", "Would you like me to proceed?", or a parameter summary that asks for confirmation is incorrect. Never ask for a second textual confirmation before an approval-gated tool call.
- Do not retry a denied action.
- Before creating an app, call configure_app with the app name and every useful default you can infer, including its description, href, pingUrl, and an icon URL returned by icon_findIcons. The user reviews Homarr's native app form. Its icon picker searches Homarr's local icon repository. Use the returned values for app_create. If the user asked to place it on a board, call configure_widget afterward with kind "app" and options { appId: the returned appId }.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then immediately call configure_board_settings with only the requested proposed changes. For a custom CSS request, include the complete resulting stylesheet in changes.customCss so the native form is prefilled and previews the proposed CSS. The native form is the user's review and confirmation step, so never insert ask_user between these calls when the requested change is known. Use the form's returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before changing a board's custom CSS or visual and behavior settings, call board_getBoardSettings to read the current values, then call configure_board_settings with only the requested proposed changes. The user can edit the complete CSS and settings in Homarr's native form. Use its returned flat object for board_savePartialBoardSettings, which then shows the mutation approval. Never overwrite existing CSS rules unless the user explicitly requests replacement.
- Before adding any widget to a board, call configure_widget with the target board, widget kind, proposed options, and any integration IDs selected from integration_all. Homarr's native widget editor applies defaults, validates the options, and restricts the picker to compatible integrations the user can access. Use the returned boardId, kind, options, and integrationIds exactly as the input to board_addItem. If configure_widget returns cancelled, do not add the widget.
- To create a formatted note on a dashboard, call configure_widget with kind "notebook". Put valid Tiptap-compatible HTML in options.content and set options.showToolbar and options.allowReadOnlyCheck when useful. Use semantic paragraphs, headings, lists, task lists, blockquotes, tables, links, emphasis, and images as appropriate. Never include scripts, styles, iframes, event handlers, or unsafe URL protocols.
- When choosing an app icon without configure_app, call the Homarr icon findIcons tool first and use one of its returned local icon URLs. Never invent a third-party icon CDN URL.
@@ -54,7 +54,7 @@ export const assistantBoardSettingsChangesSchema = z
export const browserToolContracts = {
ask_user: {
description:
"Pause and ask the user one concise structured question. Use this only for missing information or a meaningful choice, never to confirm details that are already sufficient for a native review form or mutating tool. In particular, do not use ask_user before configure_app, configure_board_settings, or configure_widget when their inputs are known. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
"Pause and ask the user one concise structured question. Use this for missing information or a meaningful choice, never as a second confirmation before a mutating tool. Provide 2-4 distinct options and classify every option: agreement, approval, or proceeding is affirmative; refusal or stopping is negative; unrelated selections are alternative. A confirmation question must have exactly one affirmative option. The UI adds a freeform Other choice when allowOther is not false.",
parameters: z.object({
question: z.string().trim().min(1).max(240),
description: z.string().trim().max(400).optional(),
@@ -82,7 +82,7 @@ export const browserToolContracts = {
},
configure_board_settings: {
description:
"Open Homarr's native board settings and custom CSS review form. This form is the user's confirmation step. Always call board_getBoardSettings first, then call this tool directly with only the requested proposed changes, including the complete resulting stylesheet in changes.customCss for a CSS request. Do not call ask_user between those tools when the requested changes are known. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings.",
"Open Homarr's native board settings and custom CSS review form. Always call board_getBoardSettings first, then pass only the requested proposed changes here. The user can edit the complete stylesheet and all supported settings. Use the returned flat object directly with board_savePartialBoardSettings; do not ask for confirmation in prose.",
parameters: z.object({
boardId: z.string().trim().min(1).max(64),
boardName: z.string().trim().min(1).max(255),
@@ -334,7 +334,6 @@ const LoadedBoardItemContent = ({
item={item}
width={width}
height={height}
displayScale={boardCanvasScale}
widgetStateRef={widgetStateRef}
widgetRuntimeRef={widgetRuntimeRef}
displayMode={isAdvanced ? "advanced" : "compact"}
@@ -437,7 +436,6 @@ interface InnerContentProps {
item: SectionItem;
width: number;
height: number;
displayScale: number;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
widgetRuntimeRef: WidgetRuntimeRef;
displayMode: "compact" | "advanced";
@@ -30,15 +30,11 @@
--mantine-spacing-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-lg: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-spacing-xl: calc(2rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xxs: calc(0.6875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xs: calc(0.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-sm: calc(0.875rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-md: calc(1rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-lg: calc(1.125rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-xl: calc(1.25rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-2xl: calc(1.5rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-3xl: calc(1.75rem * var(--board-canvas-ui-scale, 1));
--mantine-font-size-4xl: calc(2.25rem * var(--board-canvas-ui-scale, 1));
--mantine-h1-font-size: calc(2.125rem * var(--board-canvas-ui-scale, 1));
--mantine-h2-font-size: calc(1.625rem * var(--board-canvas-ui-scale, 1));
--mantine-h3-font-size: calc(1.375rem * var(--board-canvas-ui-scale, 1));
@@ -67,7 +67,7 @@ export const BoardContainerSection = ({ section }: Props) => {
<Button
className={classes.containerToggle}
pos="absolute"
top={0}
top={isVisuallyCollapsed ? 0 : "calc(var(--mantine-spacing-xs) * -1)"}
left={0}
w="100%"
h={isVisuallyCollapsed ? "100%" : 24}
@@ -18,7 +18,7 @@ import {
getReadonlyCanvasAttributes,
normalizeGridPlacement,
} from "~/components/board/layout";
import { calculateBoardUiScale, useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useBoardCanvasScale } from "~/components/board/layout/scaled-board-canvas";
import { useGridEditorRuntimeStatus } from "./grid-editor-runtime";
import { createGridEntryElementStore, useGridEditorRegistry } from "./grid-editor-registry";
import type { SectionGridPlacement } from "./use-grid-layout-actions";
@@ -138,56 +138,9 @@ export const SectionGrid = ({
// of expanding to fit every widget, so its viewport height is capped independently of rowCount.
const isScrollableContainer = section.kind === "container" && section.options.scrollable;
const viewportRowCount = isScrollableContainer ? Math.max(requestedRowCount, 1) : rowCount;
// A container's own visible card is inset from its allocated board cell by the board's
// standard per-item gap (see the base, non-container `.staticItem[data-type="item"] >
// .contentMount` rule in section-grid.module.css - a container is placed as an "item" like
// any widget, so it gets that same gap). A widget's content just fills whatever size its
// card ends up being, but this SectionGrid instead computes its own fixed pixel size from
// the same column/row counts used to allocate the *outer*, uninset cell - so without
// subtracting that gap back out here, a container's inner grid renders larger than its own
// card and visually spills past its right/bottom edges. 10 must match that CSS rule's inset.
const effectiveCanvasScale = Number.isFinite(canvasScale) && canvasScale > 0 ? canvasScale : 1;
const outerCardInset = section.kind === "container" ? (2 * 10) / effectiveCanvasScale : 0;
const logicalWidth = getLogicalGridSize(columnCount) - outerCardInset;
const viewportHeight = getLogicalGridSize(viewportRowCount) - outerCardInset;
// Items are positioned in a coordinate space sized to the *un-inset* column/row count
// (fullGridWidth/Height below - see getLogicalItemStyle), but logicalWidth/Height above are
// deliberately smaller by outerCardInset to match the container's actual visible card size.
// Left alone, that mismatch means the items - not just the grid's own box - spill past the
// card's right/bottom edge by exactly that inset. Scale the grid's content down by the same
// ratio the board's own canvas uses for its whole-board zoom (see ScaledBoardCanvas), just one
// level deeper, so it fits the actual card instead of clipping. --board-canvas-ui-scale is
// corrected to compensate so icon/text sizing inside the container isn't affected.
const fullGridWidth = getLogicalGridSize(columnCount);
const fullGridHeight = getLogicalGridSize(rowCount);
// For a scrollable container, rowCount covers *all* content rows, not just the visible card -
// fullGridHeight grows right along with it, so a ratio built from it drifts toward 1 as content
// grows regardless of the (fixed) inset, under-scaling relative to what the actually-visible
// viewport needs and clipping the bottom of the visible rows. viewportHeight/viewportRowCount
// describe the real visible card size in both cases (they equal the non-scrollable values when
// rowCount and viewportRowCount are the same), so use those instead.
const fullViewportHeight = getLogicalGridSize(viewportRowCount);
// A uniform zoom is required (not a non-uniform transform scale(x, y)) - --board-canvas-ui-scale
// is a single scalar that every icon/text/custom-CSS size compensation in the app multiplies
// by, so a non-uniform stretch can never be captured by it correctly. That means one axis can
// be left with a small amount of unfilled slack when width/height need different ratios to
// exactly fit - centered below so it's even on both sides rather than left-aligned.
// logicalWidth/viewportHeight can go non-positive for a narrow (e.g. single-column) container
// on a heavily zoomed-out board, where outerCardInset (which grows as canvasScale shrinks) can
// exceed the container's own un-inset size. Floor the scale well above 0 so it can never reach
// zero or negative - combinedUiScale divides by this value, and this value is also used as
// zoom directly, both of which break (Infinity/NaN, or invalid/collapsed content) otherwise.
const containerContentScale =
section.kind === "container" && fullGridWidth > 0 && fullViewportHeight > 0
? Math.max(0.01, Math.min(logicalWidth / fullGridWidth, viewportHeight / fullViewportHeight, 1))
: 1;
// Compute the combined value in JS rather than a CSS calc() referencing the existing
// --board-canvas-ui-scale: custom properties declared on the *same* element don't have a
// sequential/temporal order the way normal variables do, so any calc() on this element that
// both reads and writes --board-canvas-ui-scale (even indirectly, through another property)
// is a circular reference - CSS invalidates the whole group rather than using "the old value",
// silently breaking every icon/text/custom-CSS size that compensates off it for descendants.
const combinedUiScale = calculateBoardUiScale(canvasScale) / containerContentScale;
const logicalWidth = getLogicalGridSize(columnCount);
const logicalHeight = getLogicalGridSize(rowCount);
const viewportHeight = getLogicalGridSize(viewportRowCount);
// A collapsed container's compact coordinates are display-only. Its own
// nested grid stays inactive until an explicit edit interaction expands it.
const isInteractionDisabled = section.kind === "container" && collapsedSectionIds.has(section.id);
@@ -296,15 +249,7 @@ export const SectionGrid = ({
>
<Box
className={classes.staticGrid}
style={
{
width: fullGridWidth,
height: fullGridHeight,
zoom: containerContentScale,
margin: containerContentScale < 1 ? "0 auto" : undefined,
...(containerContentScale < 1 ? { "--board-canvas-ui-scale": combinedUiScale } : {}),
} as CSSProperties
}
style={{ width: logicalWidth, height: logicalHeight }}
data-grid-section-id={section.id}
data-kind={section.kind}
data-grid-editor-error={isEditMode && editorRuntimeStatus === "error" ? "true" : undefined}
@@ -16,10 +16,7 @@
display: flex;
align-items: center;
gap: var(--mantine-spacing-sm);
box-sizing: border-box;
min-width: 0;
height: 100%;
padding-inline-end: rem(10px);
overflow-x: auto;
scrollbar-width: none;
}
+5 -7
View File
@@ -605,12 +605,7 @@ func (provider *homarrProvider) loadQuota(
user *core.Record,
now time.Time,
) (*core.Record, quotaSnapshot, bool, error) {
day := now.Format(time.DateOnly)
quota, err := app.FindFirstRecordByFilter(
"assistant_quotas",
"user = {:user} && day = {:day}",
dbx.Params{"user": user.Id, "day": day},
)
quota, err := app.FindFirstRecordByFilter("assistant_quotas", "user = {:user}", dbx.Params{"user": user.Id})
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, quotaSnapshot{}, false, err
}
@@ -622,7 +617,10 @@ func (provider *homarrProvider) loadQuota(
}
quota = core.NewRecord(collection)
quota.Set("user", user.Id)
quota.Set("day", day)
changed = true
}
if quota.GetString("day") != now.Format(time.DateOnly) {
quota.Set("day", now.Format(time.DateOnly))
quota.Set("used", 0)
changed = true
}
@@ -1,36 +0,0 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user");
quotas.addIndex("idx_assistant_quotas_user_day", true, "user, day", "");
app.save(quotas);
},
(app) => {
const quotas = app.findCollectionByNameOrId("assistant_quotas");
quotas.removeIndex("idx_assistant_quotas_user_day");
// The previous schema can keep only one record per user. Retain the latest UTC day when rolling back.
app
.db()
.newQuery(`
DELETE FROM assistant_quotas
WHERE id NOT IN (
SELECT (
SELECT candidate.id
FROM assistant_quotas candidate
WHERE candidate.user = quota_user.user
ORDER BY candidate.day DESC, candidate.created DESC, candidate.id DESC
LIMIT 1
)
FROM assistant_quotas quota_user
GROUP BY quota_user.user
)
`)
.execute();
quotas.addIndex("idx_assistant_quotas_user", true, "user", "");
app.save(quotas);
},
);
@@ -82,14 +82,6 @@ for (const forbidden of ['name: "assistant_requests"', 'name: "assistant_activit
if (providerMigration.includes(forbidden)) throw new Error(`Homarr provider must not persist ${forbidden}`);
}
const dailyQuotaMigration = await read("apps/workshop/pb_migrations/1787670446_daily_assistant_quotas.js");
for (const required of [
'removeIndex("idx_assistant_quotas_user")',
'addIndex("idx_assistant_quotas_user_day", true, "user, day", "")',
]) {
if (!dailyQuotaMigration.includes(required)) throw new Error(`Daily quota migration is missing: ${required}`);
}
const providerBackend = await read("apps/workshop/homarr_provider.go");
for (const required of [
'"homarr/model"',
+2 -14
View File
@@ -436,21 +436,9 @@ const resetUsage = await request("/api/ai/usage", { headers: authorSession.heade
if (resetUsage.limit !== 50 || resetUsage.used !== 0 || resetUsage.remaining !== 50) {
throw new Error(`Quota did not reset at the UTC day boundary: ${JSON.stringify(resetUsage)}`);
}
const today = new Date().toISOString().slice(0, 10);
const resetQuotas = (
const resetQuota = (
await request("/api/collections/assistant_quotas/records?perPage=20", { headers: rootHeaders })
).items.filter((item) => item.user === author.id);
const historicalQuota = resetQuotas.find((item) => item.id === authorQuota.id);
const resetQuota = resetQuotas.find((item) => item.day === today);
if (
resetQuotas.length !== 2 ||
historicalQuota?.day !== "2000-01-01" ||
historicalQuota.used !== 49 ||
!resetQuota ||
resetQuota.used !== 0
) {
throw new Error(`Daily quota history was not preserved: ${JSON.stringify(resetQuotas)}`);
}
).items.find((item) => item.user === author.id);
await request(`/api/collections/assistant_quotas/records/${resetQuota.id}`, {
method: "PATCH",
headers: rootHeaders,
+7 -2
View File
@@ -3,7 +3,6 @@ import { describe, test } from "vitest";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { loginAsync } from "./shared/login";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
import * as sqliteSchema from "../packages/db/schema/sqlite";
@@ -41,7 +40,13 @@ describe("Assistant management", () => {
const page = await context.newPage();
try {
await loginAsync({ page, baseUrl, credentials: adminCredentials, destination: "/manage/assistant" });
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
await page.getByRole("button", { name: "Login" }).click();
await expect(page).not.toHaveURL(/\/auth\/login/, { timeout: 30_000 });
await page.goto(`${baseUrl}/manage/assistant`);
await expect(page.getByRole("heading", { name: "Assistant" })).toBeVisible();
await expect(page.getByText("Connection ready")).toBeVisible();
await expect(page.getByText("Encrypted API key saved")).toBeVisible();
+9 -2
View File
@@ -8,7 +8,6 @@ import { describe, test } from "vitest";
import * as sqliteSchema from "../packages/db/schema/sqlite";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { loginAsync } from "./shared/login";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const adminCredentials = {
@@ -117,7 +116,15 @@ describe("lazy widget application graph", () => {
let releaseRefresh: (() => void) | undefined;
try {
await loginAsync({ page, baseUrl, credentials: adminCredentials });
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const signedIn = page.waitForURL((url) => url.origin === baseUrl && url.pathname === "/", {
waitUntil: "commit",
timeout: 60_000,
});
await page.locator("css=button[type='submit']").click();
await signedIn;
const visibleBoard = page.locator("[data-homarr-dev-benchmark-board]").filter({ visible: true }).first();
await expect(visibleBoard).toBeVisible({ timeout: 30_000 });
@@ -3,12 +3,7 @@ import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import {
anchorNotesListInputSchema,
anchorNoteUpdateInputSchema,
createIntegrationAsync,
mockWidgetData,
} from "@homarr/integrations";
import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrationAsync } from "@homarr/integrations";
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
@@ -20,12 +15,6 @@ const noteIdInput = z.object({
const logger = createLogger({ module: "anchorNotesRouter" });
const assertMockNoteExists = (noteId: string) => {
if (noteId === mockWidgetData.anchorNote.id) return;
throw new TRPCError({ code: "NOT_FOUND", message: "Note not found" });
};
const isJsonDeltaString = (value: string) => {
try {
const parsed: unknown = JSON.parse(value);
@@ -51,64 +40,34 @@ const normalizeAnchorContent = (content: string | undefined) => {
export const anchorNotesRouter = createTRPCRouter({
listNotes: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(anchorNotesListInputSchema)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
const search = input.search?.toLowerCase();
const notes = mockWidgetData.anchorNotes.filter((note) => !search || note.title.toLowerCase().includes(search));
return notes.slice(0, input.limit ?? 50);
}
const handler = anchorNotesListRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{
...input,
limit: input.limit ?? 50,
},
);
const handler = anchorNotesListRequestHandler.handler(ctx.integration, { ...input, limit: input.limit ?? 50 });
const { data } = await handler.getDataAsync();
return data;
}),
getNote: publicProcedure
.concat(createOneIntegrationMiddleware("query", "anchor", "mock"))
.concat(createOneIntegrationMiddleware("query", "anchor"))
.input(noteIdInput)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return mockWidgetData.anchorNote;
}
const handler = anchorNoteRequestHandler.handler(
{ ...ctx.integration, kind: "anchor" },
{ noteId: input.noteId },
);
const handler = anchorNoteRequestHandler.handler(ctx.integration, { noteId: input.noteId });
const { data } = await handler.getDataAsync();
return data;
}),
updateNote: protectedProcedure
.concat(createOneIntegrationMiddleware("interact", "anchor", "mock"))
.concat(createOneIntegrationMiddleware("interact", "anchor"))
.input(anchorNoteUpdateInputSchema)
.mutation(async ({ ctx, input }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
const normalizedContent = normalizeAnchorContent(input.content);
const isContentNormalized =
input.content !== undefined && normalizedContent !== undefined && input.content !== normalizedContent;
if (ctx.integration.kind === "mock") {
assertMockNoteExists(input.noteId);
return {
...mockWidgetData.anchorNote,
...(input.title !== undefined ? { title: input.title } : {}),
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
};
}
const integrationInstance = await createIntegrationAsync({ ...ctx.integration, kind: "anchor" });
try {
const updatedNote = await integrationInstance.updateNoteAsync({
noteId: input.noteId,
@@ -1,18 +1,13 @@
import { archiveTeamWarriorRequestHandler } from "@homarr/request-handler/archive-team-warrior";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const archiveTeamWarriorRouter = createTRPCRouter({
getStatus: publicProcedure
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior", "mock"))
.concat(createOneIntegrationMiddleware("query", "archiveTeamWarrior"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { status: mockWidgetData.archiveTeamWarrior, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = archiveTeamWarriorRequestHandler.handler({ ...ctx.integration, kind: "archiveTeamWarrior" }, {});
const handler = archiveTeamWarriorRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
return {
@@ -1,16 +1,14 @@
import { audiobookshelfRequestHandler } from "@homarr/request-handler/audiobookshelf";
import { navidromeRequestHandler } from "@homarr/request-handler/navidrome";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const audioStatsRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf", "mock"))
.concat(createOneIntegrationMiddleware("query", "navidrome", "audiobookshelf"))
.query(async ({ ctx }) => {
const { kind } = ctx.integration;
if (kind === "mock") return { kind: "navidrome" as const, data: mockWidgetData.audioStats };
const fetchByKind = {
navidrome: async () => {
+2 -5
View File
@@ -1,5 +1,4 @@
import { bazarrBadgesRequestHandler } from "@homarr/request-handler/bazarr";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -13,11 +12,9 @@ export const bazarrRouter = createTRPCRouter({
"Get missing subtitle counts, provider issues, and health warnings for a Bazarr integration. REQUIRED: integrationId from integration_all",
},
})
.concat(createOneIntegrationMiddleware("query", "bazarr", "mock"))
.concat(createOneIntegrationMiddleware("query", "bazarr"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.bazarr;
const innerHandler = bazarrBadgesRequestHandler.handler({ ...ctx.integration, kind: "bazarr" }, {});
const innerHandler = bazarrBadgesRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
+2 -13
View File
@@ -1,5 +1,4 @@
import type { CoolifyInstanceInfo } from "@homarr/integrations/types";
import { mockWidgetData } from "@homarr/integrations";
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
@@ -17,22 +16,12 @@ interface CoolifyInstanceResult {
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify", "mock"))
.concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<CoolifyInstanceResult> => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: mockWidgetData.coolify,
updatedAt: new Date(mockWidgetData.timestamp),
};
}
const innerHandler = coolifyRequestHandler.handler({ ...integration, kind: "coolify" }, {});
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+16 -58
View File
@@ -1,6 +1,4 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
firewallCpuRequestHandler,
firewallInterfacesRequestHandler,
@@ -12,53 +10,29 @@ import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware(
"query",
...getIntegrationKindsByCategory("firewall"),
"mock",
);
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
interface FirewallResult<TSummary> {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: TSummary;
error?: string;
}
const queryFirewall = <TSummary>(
handler: {
handler: (
integration: any,
input: Record<string, never>,
) => { getDataAsync: () => Promise<{ data: TSummary; timestamp: Date }> };
const queryFirewall = <
THandler extends {
handler: (integration: any, input: any) => { getDataAsync: () => Promise<{ data: any; timestamp: Date }> };
},
createFallback: () => TSummary,
getMockData: () => TSummary,
>(
handler: THandler,
createFallback: () => Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<FirewallResult<TSummary>> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: getMockData(),
error: undefined,
};
}
const { data, timestamp } = await handler.handler({ ...integration, kind: "opnsense" }, {}).getDataAsync();
async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
error: undefined,
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
error: undefined as string | undefined,
};
},
{
fallback: (integration, error): FirewallResult<TSummary> => ({
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: toPublicIntegrationError(error),
@@ -69,24 +43,8 @@ const queryFirewall = <TSummary>(
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(
firewallCpuRequestHandler,
() => ({ total: 0 }),
() => mockWidgetData.firewallCpu,
),
getFirewallInterfacesStatus: queryFirewall(
firewallInterfacesRequestHandler,
() => [],
() => mockWidgetData.firewallInterfaces,
),
getFirewallVersionStatus: queryFirewall(
firewallVersionRequestHandler,
() => ({ version: "Unknown" }),
() => mockWidgetData.firewallVersion,
),
getFirewallMemoryStatus: queryFirewall(
firewallMemoryRequestHandler,
() => ({ used: 0, total: 0, percent: 0 }),
() => mockWidgetData.firewallMemory,
),
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler, () => ({ total: 0 })),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler, () => []),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler, () => ({ version: "Unknown" })),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler, () => ({ used: 0, total: 0, percent: 0 })),
});
+4 -18
View File
@@ -1,7 +1,6 @@
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import {
immichAlbumRequestHandler,
immichAlbumsRequestHandler,
@@ -13,12 +12,11 @@ import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createImmichIntegrationMiddleware = (action: IntegrationAction) =>
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"), "mock");
createOneIntegrationMiddleware(action, ...getIntegrationKindsByCategory("photoService"));
export const immichRouter = createTRPCRouter({
getServerStats: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichStats;
const innerHandler = immichStatsRequestHandler.handler({ ...ctx.integration, kind: "immich" }, {});
const innerHandler = immichStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -31,13 +29,7 @@ export const immichRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbum;
const innerHandler = immichAlbumRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
albumId: input.albumId,
},
);
const innerHandler = immichAlbumRequestHandler.handler(ctx.integration, { albumId: input.albumId });
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -50,13 +42,7 @@ export const immichRouter = createTRPCRouter({
)
.concat(createImmichIntegrationMiddleware("query"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.immichAlbums.slice(0, input.limit);
const innerHandler = immichAlbumsRequestHandler.handler(
{ ...ctx.integration, kind: "immich" },
{
limit: input.limit,
},
);
const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
const data = await innerHandler.getDataAsync();
return data.data;
}),
@@ -1,16 +1,12 @@
import { paperlessNgxStatsRequestHandler } from "@homarr/request-handler/paperless-ngx";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const paperlessNgxRouter = createTRPCRouter({
getStats: publicProcedure
.concat(createOneIntegrationMiddleware("query", "paperlessNgx", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.paperlessNgx;
const innerHandler = paperlessNgxStatsRequestHandler.handler({ ...ctx.integration, kind: "paperlessNgx" }, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
getStats: publicProcedure.concat(createOneIntegrationMiddleware("query", "paperlessNgx")).query(async ({ ctx }) => {
const innerHandler = paperlessNgxStatsRequestHandler.handler(ctx.integration, {});
const data = await innerHandler.getDataAsync();
return data.data;
}),
});
+2 -4
View File
@@ -1,5 +1,4 @@
import { patchmonStatsRequestHandler } from "@homarr/request-handler/patchmon";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, protectedProcedure } from "../../trpc";
@@ -13,10 +12,9 @@ export const patchmonRouter = createTRPCRouter({
"Get PatchMon host patch statistics including total hosts, hosts needing updates, security update counts, up-to-date hosts, outdated packages, repositories, and OS distribution. REQUIRED: integrationId (single PatchMon integration ID from integration_all). Requires authenticated session (API key or browser login) and integration query access.",
},
})
.concat(createOneIntegrationMiddleware("query", "patchmon", "mock"))
.concat(createOneIntegrationMiddleware("query", "patchmon"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.patchmon;
const innerHandler = patchmonStatsRequestHandler.handler({ ...ctx.integration, kind: "patchmon" }, {});
const innerHandler = patchmonStatsRequestHandler.handler(ctx.integration, {});
const { data } = await innerHandler.getDataAsync();
return data;
}),
@@ -1,5 +1,4 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -7,22 +6,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker", "mock"))
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.speedtestTracker,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = speedtestTrackerRequestHandler.handler({ ...integration, kind: "speedtestTracker" }, {});
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+28 -41
View File
@@ -1,50 +1,37 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "tracearr", "mock"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.tracearr,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = tracearrRequestHandler.handler({ ...integration, kind: "tracearr" }, {});
const { data, timestamp } = await innerHandler.getDataAsync();
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data as typeof data | null,
updatedAt: timestamp,
error: undefined as string | undefined,
};
},
{
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: null,
updatedAt: new Date(0),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
);
}),
});
+2 -13
View File
@@ -1,5 +1,4 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -14,22 +13,12 @@ export const traefikRouter = createTRPCRouter({
"Retrieve Traefik dashboard data, including routers, services, middlewares, and entry points, for the given integration IDs.",
},
})
.concat(createManyIntegrationMiddleware("query", "traefik", "mock"))
.concat(createManyIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.traefik,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = traefikRequestHandler.handler({ ...integration, kind: "traefik" }, {});
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+40 -79
View File
@@ -2,7 +2,7 @@ import { z } from "zod/v4";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { createIntegrationAsync, mockWidgetData } from "@homarr/integrations";
import { createIntegrationAsync } from "@homarr/integrations";
import {
umamiActiveVisitorsRequestHandler,
umamiEventNamesRequestHandler,
@@ -19,18 +19,15 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
export const umamiRouter = createTRPCRouter({
getWebsites: publicProcedure
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiWebsites;
try {
const instance = await createIntegrationAsync({ ...ctx.integration, kind: "umami" });
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getWebsites: publicProcedure.concat(createOneIntegrationMiddleware("query", "umami")).query(async ({ ctx }) => {
try {
const instance = await createIntegrationAsync(ctx.integration);
return await instance.getWebsitesAsync();
} catch (error) {
logger.warn(new Error("Failed to load websites", { cause: error }));
return [];
}
}),
getVisitorStats: publicProcedure
.input(
@@ -40,26 +37,14 @@ export const umamiRouter = createTRPCRouter({
eventName: z.string().optional(),
}),
)
.concat(createManyIntegrationMiddleware("query", "umami", "mock"))
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: { ...mockWidgetData.umamiVisitorStats, timeFrame: input.timeFrame },
updatedAt: new Date(mockWidgetData.timestamp),
};
}
const innerHandler = umamiRequestHandler.handler(
{ ...integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
},
);
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
@@ -74,16 +59,10 @@ export const umamiRouter = createTRPCRouter({
getEventNames: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiEventNames;
try {
const innerHandler = umamiEventNamesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const innerHandler = umamiEventNamesRequestHandler.handler(ctx.integration, { websiteId: input.websiteId });
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -102,18 +81,14 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopPages.slice(0, input.limit);
try {
const innerHandler = umamiTopPagesRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const innerHandler = umamiTopPagesRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -136,18 +111,14 @@ export const umamiRouter = createTRPCRouter({
limit: z.number().int().min(1).max(500),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return mockWidgetData.umamiTopReferrers.slice(0, input.limit);
try {
const innerHandler = umamiTopReferrersRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
},
);
const innerHandler = umamiTopReferrersRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
limit: input.limit,
});
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -170,21 +141,15 @@ export const umamiRouter = createTRPCRouter({
eventNames: z.array(z.string()),
}),
)
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") {
return mockWidgetData.umamiEventSeries.filter((series) => input.eventNames.includes(series.eventName));
}
try {
const sortedNames = [...input.eventNames].toSorted();
const innerHandler = umamiMultiEventRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
},
);
const innerHandler = umamiMultiEventRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventNames: sortedNames,
});
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
@@ -201,16 +166,12 @@ export const umamiRouter = createTRPCRouter({
getActiveVisitors: publicProcedure
.input(z.object({ websiteId: z.string() }))
.concat(createOneIntegrationMiddleware("query", "umami", "mock"))
.concat(createOneIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
if (ctx.integration.kind === "mock") return 7;
try {
const innerHandler = umamiActiveVisitorsRequestHandler.handler(
{ ...ctx.integration, kind: "umami" },
{
websiteId: input.websiteId,
},
);
const innerHandler = umamiActiveVisitorsRequestHandler.handler(ctx.integration, {
websiteId: input.websiteId,
});
const { data } = await innerHandler.getDataAsync();
return data;
} catch (error) {
+2 -13
View File
@@ -1,5 +1,4 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { mockWidgetData } from "@homarr/integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
@@ -7,22 +6,12 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma", "mock"))
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
if (integration.kind === "mock") {
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: mockWidgetData.uptimeKuma,
updatedAt: new Date(mockWidgetData.timestamp),
error: undefined,
};
}
const innerHandler = uptimeKumaRequestHandler.handler({ ...integration, kind: "uptimeKuma" }, {});
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
+4 -27
View File
@@ -1,41 +1,18 @@
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { IntegrationKind } from "@homarr/definitions";
import { mockWidgetData } from "@homarr/integrations";
import type { VpnSummary } from "@homarr/integrations/types";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
interface VpnResult {
integration: { id: string; name: string; kind: IntegrationKind; updatedAt: Date };
summary: VpnSummary | null;
error?: string;
}
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn"), "mock"))
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration): Promise<VpnResult> => {
if (integration.kind === "mock") {
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: new Date(mockWidgetData.timestamp),
},
summary: mockWidgetData.vpn,
error: undefined,
};
}
const { data, timestamp } = await vpnSummaryHandler
.handler({ ...integration, kind: "gluetun" }, {})
.getDataAsync();
async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
@@ -48,7 +25,7 @@ export const vpnRouter = createTRPCRouter({
};
},
{
fallback: (integration, error): VpnResult => ({
fallback: (integration, error) => ({
integration: {
id: integration.id,
name: integration.name,
+3 -7
View File
@@ -1,5 +1,4 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { mockWidgetData } from "@homarr/integrations";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
@@ -10,15 +9,12 @@ export const wudRouter = createTRPCRouter({
mcp: {
enabled: true,
description:
"Returns monitored-container counts and available updates from a What's Up Docker integration, or mock statistics from a mock integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud", "mock"))
.concat(createOneIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
if (ctx.integration.kind === "mock") {
return { stats: mockWidgetData.wud, updatedAt: new Date(mockWidgetData.timestamp) };
}
const handler = wudStatsRequestHandler.handler({ ...ctx.integration, kind: "wud" }, {});
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
return {
@@ -144,8 +144,7 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
type: "function",
function: {
name: "customWidget_getComponent",
description:
"Get installed documentation and safety rules for one named Custom JSX component. Fetch only a selected component, one tool call at a time, after any optional example.",
description: "Get installed documentation and safety rules for one named Custom JSX component.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
@@ -163,8 +162,7 @@ export const customWidgetAssistantEvaluationToolDefinitions: ToolDefinition[] =
type: "function",
function: {
name: "customWidget_getExample",
description:
"Optionally get one installed Custom JSX example by catalog ID before requesting any component documentation.",
description: "Get one installed Custom JSX example by catalog ID.",
parameters: objectSchema({ name: { type: "string" } }, ["name"]),
},
},
@@ -253,24 +251,11 @@ export function executeAssistantEvaluationTool(
name: string,
input: Record<string, unknown>,
): unknown {
const componentDocumentCount = state.calledTools.filter(
(toolName) => toolName === "customWidget_getComponent",
).length;
if (name === "customWidget_getExample") {
if (componentDocumentCount > 0) {
return {
error:
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
};
}
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
if (!example) return { error: "Custom JSX example not found" };
state.calledTools.push(name);
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}
const componentDocumentLimit = state.calledTools.includes("customWidget_getExample") ? 4 : 8;
if (name === "customWidget_getComponent" && componentDocumentCount >= componentDocumentLimit) {
if (
name === "customWidget_getComponent" &&
state.calledTools.filter((toolName) => toolName === "customWidget_getComponent").length >= componentDocumentLimit
) {
return {
error:
"The targeted component-document budget is exhausted. Continue with validation using the documentation already loaded.",
@@ -292,6 +277,12 @@ export function executeAssistantEvaluationTool(
? getCustomWidgetSharedProps(names)
: { error: "At least one shared prop name is required" };
}
if (name === "customWidget_getExample") {
const example = typeof input.name === "string" ? getCustomWidgetExample(input.name) : null;
if (!example) return { error: "Custom JSX example not found" };
const { template, ...widget } = example.widget;
return { ...example, widget: { ...widget, templateLines: template.split("\n") } };
}
if (name === "customWidget_validate") {
const parsed = parseDefinition(input.widget);
if (!parsed.success) return { valid: false, issues: parsed.issues };
@@ -585,11 +576,11 @@ async function runAssistantAttempt(args: {
process.stdout.write(` tool: ${toolCall.function.name}\n`);
let output: unknown;
try {
const parsed: unknown = JSON.parse(toolCall.function.arguments);
let input: Record<string, unknown> = {};
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
input = Object.fromEntries(Object.entries(parsed));
}
const parsed = JSON.parse(toolCall.function.arguments) as unknown;
const input =
parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
output = executeAssistantEvaluationTool(args.testCase, state, toolCall.function.name, input);
} catch (error) {
output = { error: error instanceof Error ? error.message : "Tool input was not valid JSON" };
@@ -219,7 +219,7 @@ export function buildRepairPrompt(
export function buildJudgePrompt(testCase: CustomWidgetAiEvaluationCase, widget: HomarrCustomWidgetV2): string {
return `You are a hostile-but-fair product review panel evaluating a safe dashboard widget. Most competent drafts should score 55-75, not 90. Judge only evidence present in the manifest and JSX. Never reward unsupported capabilities, invented API routes, aspirational claims, or code that merely validates.
The installed Homarr skill and runtime references below are authoritative. The verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes unless the validated manifest contradicts them. Do not invent external endpoint or authentication objections from outside assumptions. Decorative icons paired with equivalent adjacent visible status text need no separate aria-label. A Badge containing explicit visible status text is not color-only. A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
The installed Homarr skill and runtime references below are authoritative. In particular, request state is exposed as status.<requestId> with loading/ok/status/error fields while successful payloads are exposed as data.<requestId>. RefreshButton is an installed runtime helper that manually refreshes load queries. Every component in this already-validated template exists in the installed release. Do not penalize those documented facts. The widget has already passed Homarr's real schema and JSX analyzer, which proves syntax and component compatibility but does not prove API correctness, visual quality, usefulness, or accessibility.
Installed Homarr Custom Widget authoring contract:
${getCustomWidgetSkillContent()}
@@ -254,14 +254,16 @@ Required review behavior:
- Give a concrete evidence sentence for every category. List all score-capping issues under fatalProblems.
- Return an empty fatalProblems array when there are no fatal problems; never put "none" or an explanation of their absence in that array.
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
Homarr computes the weighted total and final verdict itself. Your total and verdict are advisory, but must be internally honest. A pass requires a weighted total of at least 90, every category at least 75, goalFulfillment/visualQuality/dailyUsefulness at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily". Return only the requested structured object.`;
}
export function judgePasses(result: CustomWidgetJudgeResult): boolean {
return (
result.total >= 85 &&
result.total >= 90 &&
Object.values(result.categories).every((score) => score >= 75) &&
result.categories.goalFulfillment >= 85 &&
result.categories.visualQuality >= 85 &&
result.categories.dailyUsefulness >= 85 &&
result.categories.complexityDiscipline >= 80 &&
result.dailyUseDecision === "would-use-daily" &&
result.fatalProblems.length === 0
@@ -15,55 +15,58 @@ if (!apiKey) {
const requestedCase = process.argv.find((value) => value.startsWith("--case="))?.slice("--case=".length);
const assistantMode = process.argv.includes("--assistant");
const configuredLoops = Number(process.env.CUSTOM_WIDGET_AI_MAX_LOOPS ?? 10);
const maxLoops = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
let selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES;
if (assistantMode) {
selectableCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
(testCase) =>
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
testCase.expectations !== undefined,
);
}
let selectedCases = selectableCases;
if (requestedCase)
selectedCases = CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase);
const totalLoopBudget = Number.isInteger(configuredLoops) && configuredLoops > 0 ? Math.min(configuredLoops, 10) : 10;
const selectableCases = assistantMode
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter(
(testCase) =>
(testCase.sampleResponse !== undefined || testCase.previewResponses?.length) &&
testCase.expectations !== undefined,
)
: CUSTOM_WIDGET_AI_EVALUATION_CASES;
const selectedCases = requestedCase
? CUSTOM_WIDGET_AI_EVALUATION_CASES.filter((testCase) => testCase.id === requestedCase)
: selectableCases;
if (selectedCases.length === 0) throw new Error(`Unknown AI evaluation case '${requestedCase}'`);
if (totalLoopBudget < selectedCases.length) {
throw new Error(`CUSTOM_WIDGET_AI_MAX_LOOPS must be at least ${selectedCases.length} for this run`);
}
const runId = new Date().toISOString().replaceAll(/[:.]/gu, "-");
const outputRoot = path.resolve(process.cwd(), ".ai-evaluations", runId);
await mkdir(outputRoot, { recursive: true });
const results: Array<AiEvaluationResult | CustomWidgetAssistantEvaluationResult> = [];
let remainingLoopBudget = totalLoopBudget;
for (const testCase of selectedCases) {
const remainingCases = selectedCases.length - results.length;
const maxLoops = Math.max(1, Math.floor(remainingLoopBudget / remainingCases));
process.stdout.write(`Evaluating ${testCase.id}...\n`);
let result: AiEvaluationResult | CustomWidgetAssistantEvaluationResult;
if (assistantMode) {
result = await evaluateCustomWidgetAssistantCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
} else {
result = await evaluateCustomWidgetCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
}
const result = assistantMode
? await evaluateCustomWidgetAssistantCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
})
: await evaluateCustomWidgetCase({
testCase,
apiKey,
baseUrl: providerBaseUrl,
outputRoot,
maxLoops,
generatorModel,
judgeModel,
});
results.push(result);
if (result.judge) {
process.stdout.write(` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`);
} else {
process.stdout.write(` failed after ${result.attempts} attempt(s)\n`);
}
remainingLoopBudget -= result.attempts;
process.stdout.write(
result.judge
? ` ${result.judge.total}/100 after ${result.attempts} attempt(s)\n`
: ` failed after ${result.attempts} attempt(s)\n`,
);
}
const summary = {
+11 -31
View File
@@ -1,11 +1,5 @@
import type { HomarrCustomWidgetV2 } from "./custom-jsx-schema";
import { customJsxAuthoringCatalog } from "./component-catalog";
import {
redactCustomWidgetAiContext,
redactCustomWidgetAiResponse,
redactCustomWidgetAiText,
redactCustomWidgetAiUrl,
} from "./definition-security";
import { customJsxExamples } from "./examples";
export interface CustomWidgetAiDraft {
@@ -112,28 +106,19 @@ export const CUSTOM_WIDGET_AUTHORING_PROMPT = AUTHORING_PROMPT;
export const CUSTOM_WIDGET_MCP_AUTHORING_PROMPT = `Author one Homarr Custom JSX v2 widget at a time.
Mandatory sequence — follow it exactly:
1. First call customWidget_getSkill and follow the complete installed skill and bundled references.
2. Call customWidget_schema, then customWidget_getComponentCatalog.
3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call. Then choose at most four component names total and never request a fifth. Fetch only those named docs and, once, any named shared props with customWidget_getSharedProps.
4. Before validation, quickly reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span; use catalog components instead. Immediately call customWidget_validate with the complete definition and repair every issue.
5. Call customWidget_previewCreate with that exact validated definition.
6. Call customWidget_previewQuery once for every query returned by the preview and inspect every response.
7. Call customWidget_createFromPreview with the exact fully tested preview session ID.
Make one documentation tool call at a time. Never issue batched customWidget_getComponent calls, speculate about components, or walk the catalog. If preview evidence requires a definition change, restart at step 4 with the revised complete definition. The live resources are authoritative for this Homarr release.
First call customWidget_getSkill. Its single lazy response contains the complete installed SKILL.md and every bundled reference; follow all of it. Read the live schema and compact component catalog. Fetch only the named component and example resources needed for the design. Component resources contain component-specific props; if shared props are needed, collect their catalog names and fetch those docs together once with customWidget_getSharedProps. The live resources are authoritative for this Homarr release.
Keep the documentation phase bounded. Fetch at most eight named component documents before the first validation, or at most four after loading a complete example. Fetch another component only to resolve a concrete validation issue or missing interaction; never walk the catalog component-by-component. Move promptly from the smallest sufficient documentation set to a complete customWidget_validate call.
Construct one credential-free definition. Supply required manual parameters to preview queries, inspect the real response shape and HTTP status, and open or provide the returned previewPath for visual review. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
Follow this lifecycle without skipping steps: construct one credential-free definition; call customWidget_validate; repair every reported issue; call customWidget_previewCreate; call customWidget_previewQuery once for every query returned by that preview (supplying required manual parameters); inspect the real response shape and HTTP status; adjust paths and template bindings; validate and preview again after changes; open or provide the returned previewPath for visual review; only then call customWidget_createFromPreview with the exact final tested preview session ID. Use customWidget_create only when no reusable preview session exists. Prefer templateLines over a JSON-escaped template string for multiline JSX in validate and preview tool inputs.
Do not claim a query works when it has not returned a successful preview response. Configure deployment-specific source URLs and request credentials through Homarr when needed; never repeat plaintext. Use simulated preview actions unless the user explicitly enables live preview actions.
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show timestamps as concise human-readable absolute dates and times; do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper. Give recoverable load errors and empty states a clear refresh or retry path.
Treat preview data as the binding contract: render every core requested field, guard optional arrays and nested values before indexing, and do not silently drop returned items. Show concise freshness context when the response supplies a timestamp. Give recoverable load errors and empty states a clear refresh or retry path.
Use the lean keyed sources, requests, and options contract. Bind saved values directly with $option and invocation values with $param. Load queries cannot use $param. Keep the design responsive, accessible, theme-safe, loading-aware, empty-aware, and error-aware.
Create distinctive polish with one restrained accent for decorative chrome, while operational, degraded, and down states retain distinct semantic colors. Use clear surface contrast and purposeful typography rather than decorative gradients or excessive nested cards. Do not wrap every metric or service row in Paper or Card. Prefer one dominant surface with clearly separated service rows, a one-column compact narrow layout, and an intentionally more spacious wide layout. Label standalone icons for assistive technology or pair them with visible text.
Create distinctive polish with one semantic accent, clear surface contrast, purposeful typography, and compact narrow-tile rows rather than decorative gradients or excessive nested cards. Label standalone icons for assistive technology or pair them with visible text.
Make initial states actionable with an example, useful hint, or clear next step. Use wrapping groups for variable-length labels and values on narrow tiles. Do not use an unlabeled decorative icon as an empty state.
@@ -144,7 +129,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
if (documentationUrl) {
const documentationSection = formatBudgetedContextSection(
"API documentation URL (reference only)",
redactCustomWidgetAiUrl(documentationUrl),
documentationUrl,
700,
"data",
);
@@ -153,7 +138,7 @@ export function buildCustomWidgetMcpPrompt(request?: string | null, documentatio
if (request?.trim()) {
const requestSection = formatBudgetedContextSection(
"User-authored widget request (product intent only)",
redactCustomWidgetAiText(request.trim()),
request.trim(),
1_700,
"request",
);
@@ -216,7 +201,7 @@ function buildCustomWidgetPromptSections(
const sections = [CUSTOM_WIDGET_CONTEXT_BOUNDARY_INSTRUCTION];
const requestSection = formatBudgetedContextSection(
"User-authored widget request (product intent only)",
redactCustomWidgetAiText(request?.trim() || "Describe the widget you want to create."),
request?.trim() || "Describe the widget you want to create.",
1_700,
"request",
);
@@ -224,7 +209,7 @@ function buildCustomWidgetPromptSections(
if (documentationUrl) {
const documentationSection = formatBudgetedContextSection(
"API documentation URL (reference only)",
redactCustomWidgetAiUrl(documentationUrl),
documentationUrl,
700,
"data",
);
@@ -233,7 +218,7 @@ function buildCustomWidgetPromptSections(
if (currentConfig) {
const draftSection = formatBudgetedContextSection(
"Current raw widget draft",
JSON.stringify(redactCustomWidgetAiContext(currentConfig), null, 2),
JSON.stringify(currentConfig, null, 2),
4_000,
"data",
);
@@ -242,19 +227,14 @@ function buildCustomWidgetPromptSections(
if (diagnostics?.length) {
const diagnosticSection = formatBudgetedContextSection(
"Current normalized diagnostics",
JSON.stringify(redactCustomWidgetAiContext(diagnostics)),
JSON.stringify(diagnostics),
1_500,
"data",
);
if (diagnosticSection) sections.push(diagnosticSection);
}
if (rawResponse) {
const responseSection = formatBudgetedContextSection(
"Sample API response",
redactCustomWidgetAiResponse(rawResponse),
1_000,
"data",
);
const responseSection = formatBudgetedContextSection("Sample API response", rawResponse, 1_000, "data");
if (responseSection) sections.push(responseSection);
}
const authoringBudget = CUSTOM_WIDGET_AI_PROMPT_LIMIT - sections.join("\n\n").length - footerLength - 8;
@@ -191,70 +191,6 @@ export function redactCustomWidgetCredentialLiterals(value: string): string {
);
}
export function redactCustomWidgetAiContext(value: unknown, path: DefinitionPath = []): unknown {
if (Array.isArray(value)) return value.map((entry, index) => redactCustomWidgetAiContext(entry, [...path, index]));
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value).map(([key, entry]) => {
const entryPath = [...path, key];
const risk = getCustomWidgetCredentialKeyRisk(key);
if (risk === "strong" && !isSchemaAuthenticationControl(entryPath)) {
if (!isHarmlessCustomWidgetCredentialSetting(entry)) return [key, "[REDACTED]"];
}
if (risk === "ambiguous" && typeof entry === "string") {
if (redactCustomWidgetCredentialLiterals(entry) !== entry) return [key, "[REDACTED]"];
}
return [key, redactCustomWidgetAiContext(entry, entryPath)];
}),
);
}
if (typeof value !== "string") return value;
const lastPathPart = path.at(-1);
let key = "";
if (typeof lastPathPart === "string") key = lastPathPart;
if (["sources", "requests", "options"].includes(key)) {
try {
const parsed: unknown = JSON.parse(value);
return redactCustomWidgetAiContext(parsed, path);
} catch {
/* Invalid editor JSON is still useful prompt context. */
}
}
if (key === "baseUrl" || key === "iconUrl") {
return redactCustomWidgetCredentialLiterals(redactCustomWidgetAiUrl(value));
}
return redactCustomWidgetAiText(value);
}
export function redactCustomWidgetAiResponse(value: string): string {
try {
const parsed: unknown = JSON.parse(value);
return JSON.stringify(redactCustomWidgetAiContext(parsed), null, 2);
} catch {
return redactCustomWidgetAiText(value);
}
}
export function redactCustomWidgetAiUrl(value: string): string {
try {
const url = new URL(value);
url.username = "";
url.password = "";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return "[REDACTED_URL]";
}
}
export function redactCustomWidgetAiText(value: string): string {
return redactCustomWidgetCredentialLiterals(
value.replace(/\bhttps?:\/\/[^\s"'<>]+/giu, (candidate) => redactCustomWidgetAiUrl(candidate)),
);
}
function isSchemaAuthenticationControl(path: DefinitionPath): boolean {
return (
path.length === 3 &&
+2 -2
View File
@@ -36,8 +36,8 @@ export const customJsxExamples: readonly CustomJsxExample[] = [
requests: { overview: { path: "/api/overview", cacheSeconds: 30 } },
options: {},
template: `<Stack gap="md" p="md">
<Group justify="space-between" wrap="wrap"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton label="Refresh service health" /></Group>
{status.overview?.loading ? <Skeleton height={180} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">Service health is unavailable. Use Refresh service health to try again. {status.overview.error}</Alert> : <Paper withBorder p={{ base: "sm", sm: "md" }} radius="lg"><Stack gap="md"><SimpleGrid cols={{ base: 1, sm: 2, md: 4 }} spacing={{ base: "xs", sm: "lg" }}><Stack gap={2}><Text size="xs" c="dimmed">Services</Text><Text fw={700}>{data.overview?.services?.length ?? 0}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Online</Text><Text fw={700} c="green">{(data.overview?.services ?? []).filter(service => service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Offline</Text><Text fw={700} c="red">{(data.overview?.services ?? []).filter(service => !service.online).length}</Text></Stack><Stack gap={2}><Text size="xs" c="dimmed">Last checked</Text><Text size="sm">{(data.overview?.checkedAt ?? data.overview?.lastCheckedAt) ? Date.toLocaleDateString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") + " " + Date.toLocaleTimeString(data.overview.checkedAt ?? data.overview.lastCheckedAt, "en-US") : "Not reported"}</Text></Stack></SimpleGrid><Divider />{(data.overview?.services?.length ?? 0) > 0 ? <Stack gap={0}>{data.overview.services.map(service => <Group key={service.name} justify="space-between" wrap="wrap" py={{ base: "xs", sm: "sm" }}><Stack gap={2}><Text size="sm" fw={600}>{service.name}</Text><Text size="xs" c="dimmed">{service.latency != null ? service.latency + " ms" : "Latency unavailable"}</Text></Stack><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group>)}</Stack> : <Alert color="gray" title="No services">No services were returned. Use Refresh service health to try again.</Alert>}</Stack></Paper>}
<Group justify="space-between"><Stack gap={2}><Text fw={700}>System overview</Text><Text size="xs" c="dimmed">Live service health</Text></Stack><RefreshButton /></Group>
{status.overview?.loading ? <Skeleton height={120} radius="lg" /> : status.overview?.error ? <Alert color="red" title="Could not load status">{status.overview.error}</Alert> : <SimpleGrid cols={{ base: 1, xs: 3 }}>{(data.overview?.services ?? []).map(service => <Paper key={service.name} withBorder p="sm" radius="md"><Group justify="space-between"><Text size="sm" fw={600}>{service.name}</Text><Badge color={service.online ? "green" : "red"} variant="light">{service.online ? "Online" : "Offline"}</Badge></Group><Text mt="xs" size="xl" fw={700}>{service.latency ?? "—"}<Text span size="xs" c="dimmed"> ms</Text></Text></Paper>)}</SimpleGrid>}
</Stack>`,
},
},
@@ -1,4 +1,3 @@
import { customJsxCatalogComponentByName } from "../core/component-catalog";
import {
customJsxBindableComponentNames,
customJsxComponentByName,
@@ -32,20 +31,6 @@ export function analyzeCustomJsxElement(
const name = nameNode ? customJsxTagName(nameNode) : null;
const resolvedName = name ? resolveCustomJsxComponentName(name) : null;
const descriptor = resolvedName ? customJsxComponentByName.get(resolvedName) : undefined;
const attributes = nodesOf(opening.attributes);
if (resolvedName === "TablerIcon") {
const attributeNames = new Set<string>();
for (const attribute of attributes) {
if (attribute.type !== "JSXAttribute") continue;
const attributeName = nodeOf(attribute.name);
if (attributeName?.type === "JSXIdentifier") attributeNames.add(String(attributeName.name));
}
for (const prop of customJsxCatalogComponentByName.get(resolvedName)?.props ?? []) {
if (prop.required && !attributeNames.has(prop.name)) {
context.add(opening, `MISSING_REQUIRED_PROP: '${prop.name}' on ${name} is required`);
}
}
}
if (descriptor?.safety === "denied") {
context.add(
opening,
@@ -61,7 +46,7 @@ export function analyzeCustomJsxElement(
);
}
for (const attribute of attributes) {
for (const attribute of nodesOf(opening.attributes)) {
if (attribute.type === "JSXSpreadAttribute") {
const argument = nodeOf(attribute.argument);
if (argument) context.visit(argument, depth + 1, bindings);
@@ -86,11 +71,7 @@ export function analyzeCustomJsxElement(
attributeName !== "bind" &&
!customJsxSupportedPropsByName.get(resolvedName)?.has(attributeName)
) {
if (resolvedName === "TablerIcon") {
context.add(attribute, `UNKNOWN_COMPONENT_PROP: '${attributeName}' on ${name} is not supported`);
} else {
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
}
context.add(attribute, `UNKNOWN_MANTINE_PROP: '${attributeName}' on ${name} will be passed through`, "warning");
} else if (resolvedName && attributeName === "bind" && !customJsxBindableComponentNames.has(resolvedName)) {
context.add(attribute, `BINDING_UNAVAILABLE: '${name}' does not have a declarative binding adapter`, "warning");
}
@@ -18,7 +18,7 @@ import { Collapsible, PaginatedList, StatBar, TabPanel, TabsContainer, TypeBadge
import { getScopedCustomJsxControlName, isSafeCustomJsxUrl } from "./runtime-component-policy";
import { sanitizeCustomJsxProps } from "./safe-properties";
type Namespace = object;
type Namespace = Readonly<Record<string, unknown>>;
function resolveExport(namespace: Namespace, name: string): ComponentType<never> | undefined {
let value: unknown = namespace;
@@ -26,7 +26,7 @@ function resolveExport(namespace: Namespace, name: string): ComponentType<never>
if (!value || (typeof value !== "object" && typeof value !== "function") || !Object.hasOwn(value, segment)) {
return undefined;
}
value = Reflect.get(value, segment);
value = (value as Record<string, unknown>)[segment];
}
return typeof value === "function" || (typeof value === "object" && value !== null)
? (value as ComponentType<never>)
@@ -233,9 +233,9 @@ export interface CustomJsxComponentAdapters {
export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters): Record<string, ComponentType<never>> {
const namespaces: Record<string, Namespace> = {
"@mantine/core": Core,
"@mantine/charts": Charts,
"@mantine/dates": Dates,
"@mantine/core": Core as unknown as Namespace,
"@mantine/charts": Charts as unknown as Namespace,
"@mantine/dates": Dates as unknown as Namespace,
};
const components: Record<string, ComponentType<never>> = {};
for (const descriptor of enabledCustomJsxComponents) {
@@ -253,7 +253,7 @@ export function createCustomJsxComponents(adapters: CustomJsxComponentAdapters):
components[descriptor.name] = wrap(descriptor.name, component, additions);
}
}
const core: Namespace = Core;
const core = Core as unknown as Namespace;
for (const name of [
"Notification",
"LoadingOverlay",
@@ -146,24 +146,6 @@ describe("Custom Widget assistant live evaluation harness", () => {
});
});
it("rejects a late example without reducing the existing component-document budget", () => {
const testCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!testCase) throw new Error("Fake service-health case is missing");
const state = createAssistantEvaluationState();
for (let index = 0; index < 8; index += 1) {
executeAssistantEvaluationTool(testCase, state, "customWidget_getComponent", { name: "Text" });
}
expect(
executeAssistantEvaluationTool(testCase, state, "customWidget_getExample", { name: "service-dashboard" }),
).toEqual({
error:
"An example must be loaded before component documentation. Keep the existing component-document budget and continue to customWidget_validate.",
});
expect(state.calledTools).not.toContain("customWidget_getExample");
expect(state.calledTools.filter((name) => name === "customWidget_getComponent")).toHaveLength(8);
});
it("enforces explicit multi-iteration preview requests before persistence", () => {
const baseCase = CUSTOM_WIDGET_AI_EVALUATION_CASES.find(({ id }) => id === "fake-service-health");
if (!baseCase) throw new Error("Fake service-health case is missing");
@@ -226,38 +226,14 @@ describe("AI authoring evaluation", () => {
});
});
it("requires an excellent result without weak categories", () => {
expect(
judgePasses({
...makeJudgeResult(85),
categories: {
...makeCategories(85),
visualQuality: 76,
dailyUsefulness: 76,
complexityDiscipline: 80,
},
}),
).toBe(true);
expect(judgePasses(makeJudgeResult(84))).toBe(false);
expect(
judgePasses({
...makeJudgeResult(90),
categories: { ...makeCategories(90), accessibility: 74 },
}),
).toBe(false);
it("requires exceptional goal, design, practicality, and complexity scores", () => {
expect(judgePasses(makeJudgeResult(90))).toBe(true);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), goalFulfillment: 84 },
categories: { ...makeCategories(92), dailyUsefulness: 84 },
}),
).toBe(false);
expect(
judgePasses({
...makeJudgeResult(92),
categories: { ...makeCategories(92), complexityDiscipline: 79 },
}),
).toBe(false);
expect(judgePasses({ ...makeJudgeResult(92), dailyUseDecision: "promising-but-not-daily" })).toBe(false);
expect(judgePasses({ ...makeJudgeResult(95), fatalProblems: ["A requested core action is missing."] })).toBe(false);
});
@@ -281,20 +257,6 @@ describe("AI authoring evaluation", () => {
expect(prompt).toContain(JSON.stringify(testCase.sampleResponse, null, 2));
expect(prompt).toContain("# Bundled file: references/runtime.md");
expect(prompt).toContain("does not prove API correctness, visual quality, usefulness, or accessibility");
expect(prompt).toContain(
"verified API notes and representative response fixtures are authoritative for endpoint paths, authentication requirements, and response shapes",
);
expect(prompt).toContain("Do not invent external endpoint or authentication objections from outside assumptions");
expect(prompt).toContain(
"Decorative icons paired with equivalent adjacent visible status text need no separate aria-label",
);
expect(prompt).toContain("A Badge containing explicit visible status text is not color-only");
expect(prompt).toContain(
"A readable absolute UTC date and time is valid; do not require relative or localized time without a documented safe helper",
);
expect(prompt).toContain(
'A pass requires a weighted total of at least 85, every category at least 75, goalFulfillment at least 85, complexityDiscipline at least 80, no fatal problem, and dailyUseDecision="would-use-daily"',
);
});
it("computes the weighted score and refuses inflated advisory verdicts", () => {
@@ -53,46 +53,11 @@ describe("AI prompt", () => {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most eight named component documents");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("at most four after loading a complete example");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Treat preview data as the binding contract");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("freshness context");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make initial states actionable");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("compact narrow-tile rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Label standalone icons");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Prefer templateLines");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Make one documentation tool call at a time");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Never issue batched customWidget_getComponent calls");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"choose at most four component names total and never request a fifth",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"reject IIFEs, new, block callbacks or => {, and raw HTML tags such as span",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("concise human-readable absolute dates and times");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"do not compute relative time with Date, Date.now, or Date.parse unless the live docs provide a safe helper",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one restrained accent for decorative chrome");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"operational, degraded, and down states retain distinct semantic colors",
);
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("Do not wrap every metric or service row in Paper or Card");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain("one dominant surface with clearly separated service rows");
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(
"a one-column compact narrow layout, and an intentionally more spacious wide layout",
);
const orderedSteps = [
"1. First call customWidget_getSkill",
"2. Call customWidget_schema",
"3. If a complete example is useful, call customWidget_getExample before any customWidget_getComponent call",
"4. Before validation",
"5. Call customWidget_previewCreate",
"6. Call customWidget_previewQuery once for every query",
"7. Call customWidget_createFromPreview",
];
for (const [index, step] of orderedSteps.entries()) {
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT).toContain(step);
if (index === 0) continue;
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(orderedSteps[index - 1] ?? "")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf(step),
);
}
expect(CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_previewQuery")).toBeLessThan(
CUSTOM_WIDGET_MCP_AUTHORING_PROMPT.indexOf("customWidget_create"),
);
@@ -109,7 +74,7 @@ describe("AI prompt", () => {
it("preserves raw context and the final instruction when optional context is large", () => {
const prompt = buildCustomWidgetAiPrompt(undefined, JSON.stringify({ data: "x".repeat(20_000) }), null, "Build it");
expect(prompt).toContain('"data": "');
expect(prompt).toContain('"data":"');
expect(prompt.endsWith(CUSTOM_WIDGET_FINAL_OUTPUT_INSTRUCTION)).toBe(true);
expect(prompt.length).toBeLessThanOrEqual(12_000);
});
@@ -137,25 +102,4 @@ describe("AI prompt", () => {
expect(prompt).toContain("Use compact cards and show the current latency");
});
it("redacts credentials from widget context", () => {
const prompt = buildCustomWidgetAiPrompt(
undefined,
JSON.stringify({ token: "sensitive" }),
{
requests: {
status: {
path: "/status?credential=Bearer-sk-secret-123456",
headers: { "X-Auth": "Bearer sk-secret-123456", "X-Feature-Key": "dashboard-layout" },
},
},
},
"Use Authorization: Bearer sk-secret-123456",
);
expect(prompt).not.toContain("sensitive");
expect(prompt).not.toContain("sk-secret-123456");
expect(prompt).toContain('"X-Auth": "[REDACTED]"');
expect(prompt).toContain('"X-Feature-Key": "dashboard-layout"');
});
});
@@ -10,7 +10,6 @@ import {
getCustomWidgetSkill,
getCustomWidgetSkillContent,
} from "../core/authoring-resources";
import { validateCustomJsxTemplate } from "../jsx/analyzer";
const skillSourceDirectory = path.resolve(import.meta.dirname, "../../../../.agents/skills/homarr-custom-widget");
@@ -56,11 +55,6 @@ describe("authoring resources", () => {
sources: { default: { baseUrl: "https://pokeapi.co" } },
},
});
const serviceDashboard = getCustomWidgetExample("service-dashboard");
if (!serviceDashboard) throw new Error("Service dashboard example is missing");
expect(validateCustomJsxTemplate(serviceDashboard.widget.template)).toEqual([]);
expect(serviceDashboard.widget.template.match(/<Paper/gu)).toHaveLength(1);
expect(serviceDashboard.widget.template).toContain('RefreshButton label="Refresh service health"');
expect(Buffer.byteLength(JSON.stringify(pokedex), "utf8")).toBeLessThan(16_000);
expect(catalog.sharedProps).toMatchObject({
count: expect.any(Number),
@@ -43,21 +43,6 @@ describe("shared Custom JSX policy", () => {
expect(validateCustomJsxTemplate('<ThemeIcon><Icon name="brand-docker" /></ThemeIcon>')).toEqual([]);
});
test("rejects TablerIcon without its required name prop", () => {
expect(validateCustomJsxTemplate('<TablerIcon icon="IconHeartbeat" />')).toEqual(
expect.arrayContaining([
expect.objectContaining({
severity: "error",
message: expect.stringContaining("MISSING_REQUIRED_PROP: 'name' on TablerIcon"),
}),
expect.objectContaining({
severity: "error",
message: expect.stringContaining("UNKNOWN_COMPONENT_PROP: 'icon' on TablerIcon"),
}),
]),
);
});
test("accepts common bounded formatting and collection operations", () => {
expect(
validateCustomJsxTemplate(
@@ -277,8 +277,7 @@ function EditorLoader() {
function formatCode(value: string, language: "jsx" | "json" | "css") {
if (language === "json") {
try {
const parsed: unknown = JSON.parse(value);
return `${JSON.stringify(parsed, null, 2)}\n`;
return `${JSON.stringify(JSON.parse(value) as unknown, null, 2)}\n`;
} catch {
return value;
}
@@ -1,4 +1,4 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT ('{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}') NOT NULL;
--> statement-breakpoint
INSERT IGNORE INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}');
@@ -2620,7 +2620,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
"default": "('{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}')"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
@@ -1,4 +1,4 @@
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE "user" ADD COLUMN "header_preferences" text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
INSERT INTO "serverSetting" ("setting_key", "value")
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
@@ -2485,7 +2485,7 @@
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
-162
View File
@@ -730,168 +730,6 @@ const buildDemoWidgets = (appIds: string[], customWidgetDefinitionId: string): D
{ kind: "notifications", xOffset: 3, yOffset: 9, width: 2, height: 2, needsIntegration: true },
{ kind: "beszelAlerts", xOffset: 5, yOffset: 9, width: 4, height: 2, needsIntegration: true },
// Extended integrations
{ kind: "mediaTranscoding", xOffset: 0, yOffset: 14, width: 4, height: 3, needsIntegration: true },
{ kind: "beszelSystemTable", xOffset: 4, yOffset: 14, width: 4, height: 3, needsIntegration: true },
{ kind: "networkControllerSummary", xOffset: 8, yOffset: 14, width: 4, height: 3, needsIntegration: true },
{
kind: "networkControllerStatus",
xOffset: 0,
yOffset: 17,
width: 3,
height: 2,
needsIntegration: true,
options: { content: "wifi" },
},
{ kind: "ups", xOffset: 3, yOffset: 17, width: 3, height: 2, needsIntegration: true },
{
kind: "smartHome-entityState",
xOffset: 6,
yOffset: 17,
width: 3,
height: 2,
needsIntegration: true,
options: { entityId: "sensor.homarr_demo", displayName: "Demo environment", entityUnit: "online" },
},
{
kind: "smartHome-executeAutomation",
xOffset: 9,
yOffset: 17,
width: 3,
height: 2,
needsIntegration: true,
options: { automationId: "automation.homarr_demo", displayName: "Run demo routine" },
},
// Everyday tools
{ kind: "dnsHoleControls", xOffset: 0, yOffset: 19, width: 2, height: 2, needsIntegration: true },
{
kind: "bookmarks",
xOffset: 2,
yOffset: 19,
width: 4,
height: 2,
needsIntegration: false,
options: { title: "Launchpad", layout: "grid", items: appIds.slice(0, 6), openNewTab: true },
},
{
kind: "countdown",
xOffset: 6,
yOffset: 19,
width: 2,
height: 2,
needsIntegration: false,
options: {
events: [
{
id: "demo-new-year",
label: "New year 2030",
targetUtc: "2030-01-01T00:00:00.000Z",
startUtc: "2026-01-01T00:00:00.000Z",
timeZone: "Europe/Paris",
recurrence: "none",
},
],
showProgress: true,
showSeconds: false,
},
},
{ kind: "stockPrice", xOffset: 8, yOffset: 19, width: 2, height: 2, needsIntegration: false },
{ kind: "releases", xOffset: 10, yOffset: 19, width: 2, height: 2, needsIntegration: false },
{
kind: "iframe",
xOffset: 0,
yOffset: 21,
width: 4,
height: 3,
needsIntegration: false,
options: {
embedUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/homarr.svg",
allowScrolling: false,
},
},
{
kind: "video",
xOffset: 4,
yOffset: 21,
width: 4,
height: 3,
needsIntegration: false,
options: {
feedUrl: "https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4",
hasAutoPlay: false,
isMuted: true,
hasControls: true,
},
},
{ kind: "minecraftServerStatus", xOffset: 8, yOffset: 21, width: 2, height: 3, needsIntegration: false },
{
kind: "timetable",
xOffset: 10,
yOffset: 21,
width: 2,
height: 3,
needsIntegration: false,
options: { baseUrl: "https://search.ch", station: { value: "8507000", label: "Bern" } },
},
// Complete mock integration gallery
{
kind: "immich-albumCarousel",
xOffset: 0,
yOffset: 24,
width: 3,
height: 3,
needsIntegration: true,
options: { albumId: "demo-paris", rotationIntervalSeconds: 8, showPhotoInfo: true, randomizePhotos: false },
},
{ kind: "coolify", xOffset: 3, yOffset: 24, width: 3, height: 3, needsIntegration: true },
{ kind: "systemResources", xOffset: 6, yOffset: 24, width: 3, height: 3, needsIntegration: true },
{ kind: "systemDisks", xOffset: 9, yOffset: 24, width: 3, height: 3, needsIntegration: true },
{ kind: "immich-serverStats", xOffset: 0, yOffset: 27, width: 2, height: 3, needsIntegration: true },
{ kind: "paperlessNgx", xOffset: 2, yOffset: 27, width: 2, height: 3, needsIntegration: true },
{ kind: "patchmon", xOffset: 4, yOffset: 27, width: 2, height: 3, needsIntegration: true },
{
kind: "speedtestTracker",
xOffset: 6,
yOffset: 27,
width: 4,
height: 3,
needsIntegration: true,
options: { showLatestResult: true, showStats: true, showRecentResults: false, showPingGraph: false },
},
{ kind: "audioStats", xOffset: 10, yOffset: 27, width: 2, height: 3, needsIntegration: true },
{ kind: "uptimeKuma", xOffset: 0, yOffset: 30, width: 3, height: 2, needsIntegration: true },
{ kind: "firewall", xOffset: 3, yOffset: 30, width: 3, height: 2, needsIntegration: true },
{ kind: "vpn", xOffset: 6, yOffset: 30, width: 3, height: 2, needsIntegration: true },
{ kind: "bazarr", xOffset: 9, yOffset: 30, width: 3, height: 2, needsIntegration: true },
{ kind: "archiveTeamWarrior", xOffset: 0, yOffset: 32, width: 3, height: 2, needsIntegration: true },
{ kind: "wud", xOffset: 3, yOffset: 32, width: 3, height: 2, needsIntegration: true },
{
kind: "anchorNote",
xOffset: 6,
yOffset: 32,
width: 2,
height: 2,
needsIntegration: true,
options: { noteId: "homarr-demo-note", showTitle: true, showUpdatedAt: true },
},
{ kind: "tracearr", xOffset: 8, yOffset: 32, width: 4, height: 2, needsIntegration: true },
{ kind: "traefik", xOffset: 0, yOffset: 34, width: 6, height: 2, needsIntegration: true },
{
kind: "umami",
xOffset: 6,
yOffset: 34,
width: 6,
height: 2,
needsIntegration: true,
options: { websiteId: "homarr-demo", timeFrame: "24h", viewMode: "chart", chartType: "bar" },
},
// Right app rail
...appIds.map(
(appId, index): DemoWidget => ({
@@ -1,4 +1,4 @@
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logoAndText","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
ALTER TABLE `user` ADD `header_preferences` text DEFAULT '{"version":3,"visible":true,"searchDisplay":"input","logoDisplay":"logo","zones":{"left":[{"type":"builtin","id":"logo"}],"center":[{"type":"builtin","id":"search"}],"right":[{"type":"builtin","id":"boardEdit"},{"type":"builtin","id":"boardSettings"},{"type":"builtin","id":"user"}]}}' NOT NULL;
--> statement-breakpoint
INSERT INTO `serverSetting` (`setting_key`, `value`)
VALUES ('branding', '{"json":{"appName":"Homarr","greeting":"","logoImageUrl":null,"faviconImageUrl":null,"primaryColor":"#fa5252","secondaryColor":"#fd7e14","lockPrimaryColor":false,"signInBackgroundImageUrl":null,"signInBackgroundOverlay":0.55,"authBranding":{"showAppName":true,"showLogo":true,"showGreeting":true},"defaultRadius":"md"}}')
@@ -2551,7 +2551,7 @@
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logoAndText\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
"default": "'{\"version\":3,\"visible\":true,\"searchDisplay\":\"input\",\"logoDisplay\":\"logo\",\"zones\":{\"left\":[{\"type\":\"builtin\",\"id\":\"logo\"}],\"center\":[{\"type\":\"builtin\",\"id\":\"search\"}],\"right\":[{\"type\":\"builtin\",\"id\":\"boardEdit\"},{\"type\":\"builtin\",\"id\":\"boardSettings\"},{\"type\":\"builtin\",\"id\":\"user\"}]}}'"
},
"completed_manage_tour": {
"name": "completed_manage_tour",
+2 -15
View File
@@ -7,7 +7,6 @@ import SuperJSON from "superjson";
import { expect, test } from "vitest";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { widgetIntegrationSupport, widgetKinds } from "@homarr/definitions";
import type { Database } from "..";
import { seedDataAsync } from "../migrations/seed";
@@ -46,7 +45,7 @@ test("SQLite migrations seed the five disabled bundled custom widgets", async ()
connection.close();
});
test("SQLite migrations seed the complete demo widget gallery", async () => {
test("SQLite migrations seed the redesigned demo dashboard", async () => {
const previousDemoMode = process.env.DEMO_MODE;
process.env.DEMO_MODE = "true";
const connection = new BetterSqlite3(":memory:");
@@ -66,7 +65,7 @@ test("SQLite migrations seed the complete demo widget gallery", async () => {
with: {
layouts: true,
sections: { with: { collapseStates: true, layouts: true } },
items: { with: { integrations: true, layouts: true } },
items: { with: { layouts: true } },
},
});
if (!board) throw new Error("Demo board was not seeded");
@@ -127,18 +126,6 @@ test("SQLite migrations seed the complete demo widget gallery", async () => {
expectItemLayout("notifications", 3, 9, 2, 2);
expectItemLayout("beszelAlerts", 5, 9, 4, 2);
expect(new Set(board.items.map((item) => item.kind))).toEqual(new Set(widgetKinds));
const mockIntegration = await database.query.integrations.findFirst({
where: (table, { eq }) => eq(table.kind, "mock"),
});
if (!mockIntegration) throw new Error("Demo mock integration was not seeded");
for (const item of board.items) {
if (widgetIntegrationSupport[item.kind] === undefined) continue;
expect(item.integrations).toEqual([
expect.objectContaining({ integrationId: mockIntegration.id, itemId: item.id }),
]);
}
for (const kind of ["weather", "airQuality"]) {
const item = itemByKind(kind);
if (!item?.options) throw new Error(`Demo ${kind} options were not seeded`);
@@ -26,12 +26,6 @@ describe("native feature capability descriptor", () => {
}
});
test("supports the mock integration for every integration-backed widget", () => {
for (const capability of Object.values(nativeFeatureCapabilities)) {
expect(capability.integrations).toContain("mock");
}
});
test("derives optional connections and server selection limits", () => {
expect(widgetKindsWithOptionalIntegrations).toEqual(new Set(["calendar"]));
expect(widgetIntegrationLimits).toEqual({
@@ -41,32 +41,32 @@ export const nativeFeatureCapabilities = {
(kind) => kind !== "patchmon" && kind !== "wud",
),
},
firewall: { integrations: [...getIntegrationKindsByCategory("firewall"), "mock"] },
firewall: { integrations: getIntegrationKindsByCategory("firewall") },
notifications: { integrations: getIntegrationKindsByCategory("notifications") },
mediaReleases: { integrations: ["mock", "emby", "jellyfin", "plex"] },
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology", "mock"] },
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology", "mock"] },
systemResources: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"] },
systemDisks: { integrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"] },
beszelAlerts: { integrations: ["beszel", "mock"] },
beszelSystemGrid: { integrations: ["beszel", "mock"] },
beszelSystemStats: { integrations: ["beszel", "mock"] },
beszelSystemTable: { integrations: ["beszel", "mock"] },
coolify: { integrations: ["coolify", "mock"] },
"immich-serverStats": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
"immich-albumCarousel": { integrations: ["immich", "mock"], serverMaxIntegrations: 1 },
paperlessNgx: { integrations: ["paperlessNgx", "mock"], serverMaxIntegrations: 1 },
patchmon: { integrations: ["patchmon", "mock"], serverMaxIntegrations: 1 },
bazarr: { integrations: ["bazarr", "mock"], serverMaxIntegrations: 1 },
tracearr: { integrations: ["tracearr", "mock"] },
speedtestTracker: { integrations: ["speedtestTracker", "mock"] },
uptimeKuma: { integrations: ["uptimeKuma", "mock"] },
audioStats: { integrations: ["navidrome", "audiobookshelf", "mock"], serverMaxIntegrations: 1 },
umami: { integrations: ["umami", "mock"], serverMaxIntegrations: 1 },
vpn: { integrations: [...getIntegrationKindsByCategory("vpn"), "mock"] },
coolify: { integrations: ["coolify"] },
"immich-serverStats": { integrations: ["immich"], serverMaxIntegrations: 1 },
"immich-albumCarousel": { integrations: ["immich"], serverMaxIntegrations: 1 },
paperlessNgx: { integrations: ["paperlessNgx"], serverMaxIntegrations: 1 },
patchmon: { integrations: ["patchmon"], serverMaxIntegrations: 1 },
bazarr: { integrations: ["bazarr"], serverMaxIntegrations: 1 },
tracearr: { integrations: ["tracearr"] },
speedtestTracker: { integrations: ["speedtestTracker"] },
uptimeKuma: { integrations: ["uptimeKuma"] },
audioStats: { integrations: ["navidrome", "audiobookshelf"], serverMaxIntegrations: 1 },
umami: { integrations: ["umami"], serverMaxIntegrations: 1 },
vpn: { integrations: getIntegrationKindsByCategory("vpn") },
ups: { integrations: getIntegrationKindsByCategory("ups") },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior", "mock"], serverMaxIntegrations: 1 },
anchorNote: { integrations: ["anchor", "mock"], serverMaxIntegrations: 1 },
traefik: { integrations: ["traefik", "mock"] },
wud: { integrations: ["wud", "mock"], serverMaxIntegrations: 1 },
archiveTeamWarrior: { integrations: ["archiveTeamWarrior"], serverMaxIntegrations: 1 },
anchorNote: { integrations: ["anchor"], serverMaxIntegrations: 1 },
traefik: { integrations: ["traefik"] },
wud: { integrations: ["wud"], serverMaxIntegrations: 1 },
} as const satisfies Partial<Record<WidgetKind, NativeFeatureCapabilityDescriptor>>;
const nativeFeatureCapabilityEntries = Object.entries(nativeFeatureCapabilities) as [
-1
View File
@@ -45,7 +45,6 @@ export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
export { mockWidgetData } from "./mock/widget-data";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -1,355 +0,0 @@
import type { AnchorNote, AnchorNoteSummary } from "../anchor/anchor-types";
import type { ArchiveTeamWarriorStatus } from "../archive-team-warrior/archive-team-warrior-types";
import type { BazarrBadges } from "../bazarr/bazarr-types";
import type { CoolifyInstanceInfo } from "../coolify/coolify-types";
import type { ImmichAlbum, ImmichServerStats } from "../immich/immich-integration";
import type { NavidromeDashboardData } from "../navidrome/navidrome-types";
import type { PaperlessNgxStats } from "../paperless-ngx/paperless-ngx-types";
import type { PatchMonStats } from "../patchmon/patchmon-types";
import type { SpeedtestTrackerDashboardData } from "../speedtest-tracker/speedtest-tracker-types";
import type { TracearrDashboardData } from "../tracearr/tracearr-types";
import type { TraefikDashboardData } from "../traefik/traefik-types";
import type { UmamiEventSeries, UmamiMetricItem, UmamiVisitorStats, UmamiWebsite } from "../umami/umami-types";
import type { UptimeKumaDashboardData } from "../uptime-kuma/uptime-kuma-types";
import type { WudStats } from "../wud/wud-types";
import type {
FirewallCpuSummary,
FirewallInterfacesSummary,
FirewallMemorySummary,
FirewallVersionSummary,
} from "../interfaces/firewall-summary/firewall-summary-types";
import type { VpnSummary } from "../interfaces/vpn-summary/vpn-summary-types";
const demoTimestamp = "2026-08-24T12:00:00.000Z";
const anchorNote: AnchorNote = {
id: "homarr-demo-note",
title: "Homarr demo runbook",
content: JSON.stringify({
ops: [
{ insert: "Welcome to the complete Homarr widget gallery.\n", attributes: { bold: true } },
{ insert: "Every integration-backed card below is powered by safe mock data.\n" },
],
}),
createdAt: demoTimestamp,
updatedAt: demoTimestamp,
isPinned: true,
isArchived: false,
tagIds: ["demo"],
permission: "editor",
background: null,
userId: "demo",
};
export const mockWidgetData = {
timestamp: demoTimestamp,
anchorNote,
anchorNotes: [
{
id: anchorNote.id,
title: anchorNote.title,
updatedAt: anchorNote.updatedAt,
isPinned: anchorNote.isPinned,
tagIds: anchorNote.tagIds,
permission: anchorNote.permission,
},
] satisfies AnchorNoteSummary[],
archiveTeamWarrior: {
status: "running",
runnerStatus: "running",
project: { id: "urlteam2", title: "URLTeam 2" },
selectedProject: "urlteam2",
broadcastMessage: "Archiving the open web, one URL at a time.",
bandwidth: {
received: 8_942_000_000,
sent: 1_284_000_000,
receiving: 4_800_000,
sending: 720_000,
session_id: "homarr-demo-session",
},
items: [
{ id: "demo-1", name: "urls-20260824-01", status: "running", project: "urlteam2", startTime: 1_777_000_000 },
{ id: "demo-2", name: "urls-20260824-00", status: "completed", project: "urlteam2" },
],
counts: { running: 1, completed: 284, failed: 2, canceled: 0 },
updatedAt: demoTimestamp,
} satisfies ArchiveTeamWarriorStatus,
audioStats: { artistCount: 842, albumCount: 1_976, songCount: 24_681 } satisfies NavidromeDashboardData,
bazarr: {
episodes: 12,
movies: 4,
providers: 6,
status: 0,
sonarr_signalr: "connected",
radarr_signalr: "connected",
announcements: 1,
} satisfies BazarrBadges,
coolify: {
version: "4.0.0-beta.420",
servers: [
{
id: 1,
uuid: "homarr-demo-edge",
name: "Paris edge",
ip: "10.0.0.20",
is_reachable: true,
is_usable: true,
settings: { server_id: 1, is_build_server: true, is_reachable: true, is_usable: true },
},
],
applications: [
{
id: 1,
uuid: "homarr-demo-app",
name: "Homarr",
status: "running:healthy",
fqdn: "https://homarr.dev",
projectName: "Home cloud",
environmentName: "Production",
},
{
id: 2,
uuid: "media-demo-app",
name: "Media stack",
status: "running:healthy",
fqdn: null,
projectName: "Home cloud",
environmentName: "Production",
},
],
services: [
{
id: 3,
uuid: "postgres-demo-service",
name: "Postgres",
status: "running:healthy",
fqdn: null,
applications: [],
projectName: "Home cloud",
environmentName: "Production",
},
],
} satisfies CoolifyInstanceInfo,
firewallCpu: { total: 23 } satisfies FirewallCpuSummary,
firewallInterfaces: [
{
data: [
{ name: "WAN", receive: 18_400_000, transmit: 4_200_000 },
{ name: "LAN", receive: 46_700_000, transmit: 21_300_000 },
],
timestamp: new Date(demoTimestamp),
},
] satisfies FirewallInterfacesSummary[],
firewallMemory: { used: 3_220_000_000, total: 8_590_000_000, percent: 37.5 } satisfies FirewallMemorySummary,
firewallVersion: { version: "24.7.11" } satisfies FirewallVersionSummary,
immichStats: {
userCount: 4,
photoCount: 48_392,
videoCount: 2_418,
totalLibraryUsageInBytes: 684_510_412_800,
} satisfies ImmichServerStats,
immichAlbums: [
{ id: "demo-paris", albumName: "Paris weekend", assetCount: 128 },
{ id: "demo-mountains", albumName: "Alpine trails", assetCount: 84 },
],
immichAlbum: {
id: "demo-paris",
albumName: "Paris weekend",
assets: [
{
id: "demo-photo-1",
thumbhash: null,
fileModifiedAt: demoTimestamp,
fileCreatedAt: demoTimestamp,
updatedAt: demoTimestamp,
type: "IMAGE",
publicLink: "https://images.unsplash.com/photo-1502602898657-3e91760cbb34?auto=format&fit=crop&w=1200&q=80",
},
{
id: "demo-photo-2",
thumbhash: null,
fileModifiedAt: demoTimestamp,
fileCreatedAt: demoTimestamp,
updatedAt: demoTimestamp,
type: "IMAGE",
publicLink: "https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?auto=format&fit=crop&w=1200&q=80",
},
],
} satisfies ImmichAlbum,
paperlessNgx: {
documentsTotal: 2_847,
documentsInbox: 14,
correspondentsCount: 96,
tagsCount: 38,
documentTypesCount: 12,
} satisfies PaperlessNgxStats,
patchmon: {
totalHosts: 18,
hostsNeedingUpdates: 5,
securityUpdates: 7,
upToDateHosts: 13,
hostsWithSecurityUpdates: 3,
recentUpdates24h: 11,
totalOutdatedPackages: 26,
totalRepos: 42,
lastUpdated: demoTimestamp,
osDistribution: [
{ name: "Ubuntu 24.04", count: 9, osType: "linux", osVersion: "24.04" },
{ name: "Debian 13", count: 6, osType: "linux", osVersion: "13" },
{ name: "Fedora 42", count: 3, osType: "linux", osVersion: "42" },
],
} satisfies PatchMonStats,
speedtestTracker: {
latestResult: {
id: 42,
ping: 8.4,
download_bits: 936_000_000,
upload_bits: 487_000_000,
healthy: true,
created_at: new Date(demoTimestamp),
},
stats: {
ping: { avg: 9.1, min: 6.8, max: 14.2 },
download: { avg: 901, avg_bits: 901_000_000, min: 822, max: 948 },
upload: { avg: 472, avg_bits: 472_000_000, min: 431, max: 495 },
total_results: 128,
},
recentResults: [
{
id: 42,
ping: 8.4,
download_bits: 936_000_000,
upload_bits: 487_000_000,
healthy: true,
created_at: new Date(demoTimestamp),
},
],
} satisfies SpeedtestTrackerDashboardData,
tracearr: {
stats: { activeStreams: 2, totalUsers: 14, totalSessions: 1_248, recentViolations: 1, timestamp: demoTimestamp },
streams: {
data: [],
summary: {
total: 2,
transcodes: 1,
directStreams: 0,
directPlays: 1,
totalBitrate: "24.8 Mbps",
byServer: [
{
serverId: "demo",
serverName: "Homarr Media",
total: 2,
transcodes: 1,
directStreams: 0,
directPlays: 1,
totalBitrate: "24.8 Mbps",
},
],
},
},
violations: { data: [], meta: { total: 1, page: 1, pageSize: 10 } },
recentActivity: { data: [], meta: { total: 1_248, page: 1, pageSize: 10 } },
} satisfies TracearrDashboardData,
traefik: {
version: "3.5.0",
entryPoints: ["web", "websecure", "metrics"],
failedEndpoints: [],
resources: [
{ protocol: "http", type: "router", name: "homarr@docker", provider: "docker", status: "enabled" },
{ protocol: "http", type: "service", name: "homarr@docker", provider: "docker", status: "enabled" },
{ protocol: "http", type: "middleware", name: "secure-headers@file", provider: "file", status: "enabled" },
],
http: {
routers: { total: 12, enabled: 12, warnings: 0, errors: 0 },
services: { total: 10, enabled: 10, warnings: 0, errors: 0 },
middlewares: { total: 8, enabled: 8, warnings: 0, errors: 0 },
},
tcp: {
routers: { total: 2, enabled: 2, warnings: 0, errors: 0 },
services: { total: 2, enabled: 2, warnings: 0, errors: 0 },
middlewares: { total: 0, enabled: 0, warnings: 0, errors: 0 },
},
udp: {
routers: { total: 1, enabled: 1, warnings: 0, errors: 0 },
services: { total: 1, enabled: 1, warnings: 0, errors: 0 },
},
} satisfies TraefikDashboardData,
umamiWebsites: [{ id: "homarr-demo", name: "Homarr demo", domain: "demo.homarr.dev" }] satisfies UmamiWebsite[],
umamiVisitorStats: {
domain: "demo.homarr.dev",
websiteId: "homarr-demo",
websiteName: "Homarr demo",
totalVisitors: 8_429,
totalPageviews: 21_384,
totalVisits: 11_207,
bounceRate: 32.4,
avgDuration: 284,
timeFrame: "24h",
dataPoints: [
{ timestamp: "2026-08-24T08:00:00.000Z", visitors: 280, events: 0 },
{ timestamp: "2026-08-24T10:00:00.000Z", visitors: 410, events: 0 },
{ timestamp: "2026-08-24T12:00:00.000Z", visitors: 536, events: 0 },
],
eventCount: 0,
} satisfies UmamiVisitorStats,
umamiEventNames: ["dashboard_opened", "widget_added", "board_shared"],
umamiTopPages: [
{ x: "/", y: 8_240 },
{ x: "/boards/home", y: 5_812 },
{ x: "/manage/integrations", y: 1_942 },
] satisfies UmamiMetricItem[],
umamiTopReferrers: [
{ x: "github.com", y: 3_842 },
{ x: "google.com", y: 2_106 },
{ x: "docs.homarr.dev", y: 1_284 },
] satisfies UmamiMetricItem[],
umamiEventSeries: [
{
eventName: "dashboard_opened",
dataPoints: [
{ x: "2026-08-24T08:00:00.000Z", y: 92 },
{ x: "2026-08-24T10:00:00.000Z", y: 134 },
{ x: "2026-08-24T12:00:00.000Z", y: 188 },
],
},
] satisfies UmamiEventSeries[],
uptimeKuma: {
totalMonitors: 6,
upCount: 5,
downCount: 1,
pausedCount: 0,
averageUptimePercent: 99.72,
monitors: [
{ id: 1, name: "Homarr", status: "up", uptimePercent24h: 100 },
{ id: 2, name: "Media server", status: "up", uptimePercent24h: 99.98 },
{ id: 3, name: "Home cloud", status: "up", uptimePercent24h: 99.91 },
{ id: 4, name: "Backups", status: "up", uptimePercent24h: 99.62 },
{ id: 5, name: "VPN", status: "up", uptimePercent24h: 99.8 },
{ id: 6, name: "Lab service", status: "down", uptimePercent24h: 98.99 },
],
} satisfies UptimeKumaDashboardData,
vpn: {
vpnStatus: "running",
dnsStatus: "running",
publicIp: "198.51.100.42",
country: "France",
city: "Paris",
vpnProvider: { protocol: "wireguard", provider: "Mullvad" },
} satisfies VpnSummary,
wud: {
totalContainers: 24,
updatesAvailable: 3,
updates: [
{
id: "homarr",
name: "Homarr",
currentVersion: "v1.35.0",
newVersion: "v1.36.0",
link: "https://github.com/homarr-labs/homarr",
},
{ id: "postgres", name: "Postgres", currentVersion: "17.5", newVersion: "17.6", link: null },
{ id: "redis", name: "Redis", currentVersion: "8.0.2", newVersion: "8.0.3", link: null },
],
} satisfies WudStats,
} as const;
+1 -7
View File
@@ -3179,8 +3179,7 @@
"disabled": "Disabled"
},
"error": {
"internalServerError": "Failed to fetch indexers status",
"testAll": "The indexer test failed. Try again."
"internalServerError": "Failed to fetch indexers status"
}
},
"healthMonitoring": {
@@ -4250,7 +4249,6 @@
"failed": "Failed",
"completed": "Completed"
},
"unknown": "Unknown",
"toBeDetermined": "TBD"
},
"mediaRequests-requestStats": {
@@ -4963,10 +4961,6 @@
"empty": "No alerts configured",
"history": "History",
"resolved": "Resolved",
"alertDescription": {
"statusDuration": "Down for {minutes} min",
"threshold": "Exceeds {value} over {minutes} min"
},
"status": {
"triggered": "Triggered",
"ok": "OK"
-29
View File
@@ -1,4 +1,3 @@
import type { CSSProperties } from "react";
import type { Icon123, IconProps } from "@tabler/icons-react";
export * from "./src";
@@ -6,31 +5,3 @@ export * from "./src";
export type TablerIcon = typeof Icon123;
export type TablerIconProps = IconProps;
/**
* Icon sizes tied to Mantine's font-size scale instead of raw pixel
* numbers, so icons stay legible under the board's CSS zoom scaling.
* Pass as the `style` prop (not `size`) - tabler icons convert `size` into
* raw SVG width/height attributes, and CSS `var()` isn't guaranteed to
* resolve there (Firefox especially). `style` is real CSS, so it does.
*/
export const iconSizes = {
xs: { width: "var(--mantine-font-size-xs)", height: "var(--mantine-font-size-xs)", flexShrink: 0 },
sm: { width: "var(--mantine-font-size-sm)", height: "var(--mantine-font-size-sm)", flexShrink: 0 },
md: { width: "var(--mantine-font-size-md)", height: "var(--mantine-font-size-md)", flexShrink: 0 },
lg: { width: "var(--mantine-font-size-lg)", height: "var(--mantine-font-size-lg)", flexShrink: 0 },
xl: { width: "var(--mantine-font-size-xl)", height: "var(--mantine-font-size-xl)", flexShrink: 0 },
} as const satisfies Record<string, CSSProperties>;
/**
* For icon/element sizes that don't fit one of `iconSizes`' fixed tokens (e.g. a deliberately
* oversized icon). Same reasoning as `iconSizes`: pass as `style`, not a raw `size` number - a
* bare pixel value never re-scales when the board's zoom level changes, so it renders at a
* fraction of its intended size on any board that isn't at 100% zoom (which is most boards with
* more than a couple of widgets).
*/
export const zoomCompensatedSize = (px: number): CSSProperties => ({
width: `calc(${px}px * var(--board-canvas-ui-scale, 1))`,
height: `calc(${px}px * var(--board-canvas-ui-scale, 1))`,
flexShrink: 0,
});
@@ -104,7 +104,7 @@ export const headerPreferencesSchema = z
version: z.literal(headerPreferencesVersion),
visible: z.boolean(),
searchDisplay: z.enum(headerSearchDisplayValues),
logoDisplay: z.enum(headerLogoDisplayValues).default("logoAndText"),
logoDisplay: z.enum(headerLogoDisplayValues).default("logo"),
zones: headerZonesSchema,
})
.strict();
@@ -154,7 +154,7 @@ const migrateLegacyItems = (
version: headerPreferencesVersion,
visible,
searchDisplay: "input",
logoDisplay: "logoAndText",
logoDisplay: "logo",
zones: {
left: zones.left.map(createBuiltinHeaderItem),
center: zones.center.map(createBuiltinHeaderItem),
@@ -208,7 +208,7 @@ export const defaultHeaderPreferences = {
version: headerPreferencesVersion,
visible: true,
searchDisplay: "input",
logoDisplay: "logoAndText",
logoDisplay: "logo",
zones: {
left: [createBuiltinHeaderItem("logo")],
center: [createBuiltinHeaderItem("search")],
+1 -1
View File
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("anchorNot
supportsAdvancedFocus: true,
queryKey: [["widget", "anchorNotes"]],
maxIntegrations: 1,
supportedIntegrations: ["anchor", "mock"],
supportedIntegrations: ["anchor"],
createOptions() {
return optionsBuilder.from((factory) => ({
noteId: factory.anchorNote(),
+6 -6
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Box, Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -59,19 +59,19 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
size={isTiny ? "xs" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
{options.descriptionDisplayMode === "normal" && !isTiny && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
size="sm"
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={4}
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
@@ -91,7 +91,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : "50%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
@@ -14,5 +14,5 @@ export const { definition, componentLoader } = createWidgetDefinition("archiveTe
}),
}));
},
supportedIntegrations: ["archiveTeamWarrior", "mock"],
supportedIntegrations: ["archiveTeamWarrior"],
}).withDynamicImport(() => import("./component"));
@@ -5,12 +5,17 @@ import { Text } from "@mantine/core";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { zoomCompensatedSize } from "@homarr/ui";
import { getCompactStatLayout } from "../common/compact-stat-layout";
import classes from "./component.module.css";
import type { AudioStatsBackend, AudioStatsDisplayOptions } from "./shared";
import { AUDIO_STATS_TRANSLATION_SCOPE, getGridCols, getIconSize, getVisibleStats } from "./shared";
import {
AUDIO_STATS_TRANSLATION_SCOPE,
getGridCols,
getIconSize,
prioritizeVisibleStats,
getVisibleStatLimit,
getVisibleStats,
} from "./shared";
const rootClassByCompact = {
true: classes.rootCompact,
@@ -27,49 +32,11 @@ const statTileClassByCompact = {
false: "",
} as const;
const statTileClassByAdvanced = {
true: classes.statTileAdvanced,
false: "",
} as const;
const rootClassByLayout = {
default: "",
short: classes.rootShort,
narrowShort: classes.rootShort,
} as const;
const gridClassByLayout = {
default: "",
short: classes.gridShort,
narrowShort: classes.gridShort,
} as const;
const statTileClassByLayout = {
default: "",
short: classes.statTileShort,
narrowShort: `${classes.statTileShort} ${classes.statTileNarrowShort}`,
} as const;
const statLabelClassByLayout = {
default: "",
short: classes.statLabelShort,
narrowShort: classes.statLabelShort,
} as const;
const statValueClassByLayout = {
default: "",
short: "",
narrowShort: classes.statValueNarrowShort,
} as const;
const COMPACT_SINGLE_COLUMN_BREAKPOINT = 220;
interface AudioStatsContentProps {
backend: AudioStatsBackend;
stats: NavidromeDashboardData | AudiobookshelfDashboardData;
options: AudioStatsDisplayOptions;
showAllStats?: boolean;
advanced?: boolean;
width: number;
height: number;
}
@@ -79,45 +46,29 @@ export function AudioStatsContent({
stats,
options,
showAllStats = false,
advanced = false,
width,
height,
}: AudioStatsContentProps) {
const t = useI18n(AUDIO_STATS_TRANSLATION_SCOPE);
const compact = options.compactMode ?? false;
const visibleStats = getVisibleStats(backend, options, stats, showAllStats);
const enabledStats = prioritizeVisibleStats(getVisibleStats(backend, options, stats, showAllStats), compact);
const visibleStats = enabledStats.slice(0, getVisibleStatLimit(width, height, enabledStats.length, compact));
const compactKey = String(compact) as keyof typeof rootClassByCompact;
const advancedKey = String(advanced) as keyof typeof statTileClassByAdvanced;
let defaultGridCols = getGridCols(width, visibleStats.length, compact);
if (!advanced && width < COMPACT_SINGLE_COLUMN_BREAKPOINT) {
defaultGridCols = 1;
}
const layout = getCompactStatLayout({
width,
height,
visibleCount: visibleStats.length,
compactDisplay: !advanced,
defaultColumns: defaultGridCols,
defaultIconSize: getIconSize(width, compact),
});
const gridCols = getGridCols(width, visibleStats.length, compact);
const iconSize = getIconSize(width, compact);
return (
<div className={`${classes.root} ${rootClassByCompact[compactKey]} ${rootClassByLayout[layout.state]}`}>
<div className={`${classes.root} ${rootClassByCompact[compactKey]}`}>
{visibleStats.length > 0 && (
<div
className={`${classes.grid} ${gridClassByCompact[compactKey]} ${gridClassByLayout[layout.state]}`}
style={{ "--stat-cols": layout.columns } as CSSProperties}
className={`${classes.grid} ${gridClassByCompact[compactKey]}`}
style={{ "--stat-cols": gridCols } as CSSProperties}
>
{visibleStats.map(({ optionKey, statKey, value, Icon }) => (
<div
key={optionKey}
className={`${classes.statTile} ${statTileClassByCompact[compactKey]} ${statTileClassByAdvanced[advancedKey]} ${statTileClassByLayout[layout.state]}`}
>
<Icon className={classes.statIcon} style={zoomCompensatedSize(layout.iconSize)} stroke={1.5} />
<span className={`${classes.statValue} ${statValueClassByLayout[layout.state]}`}>{value}</span>
<span className={`${classes.statLabel} ${statLabelClassByLayout[layout.state]}`}>
{t(statKey as never)}
</span>
<div key={optionKey} className={`${classes.statTile} ${statTileClassByCompact[compactKey]}`}>
<Icon className={classes.statIcon} size={iconSize} stroke={1.5} />
<span className={classes.statValue}>{value}</span>
<span className={classes.statLabel}>{t(statKey as never)}</span>
</div>
))}
</div>
@@ -2,7 +2,7 @@ import { describe, expect, test } from "vitest";
import type { AudiobookshelfDashboardData, NavidromeDashboardData } from "@homarr/integrations/types";
import { getGridCols, getIconSize, getVisibleStats } from "./shared";
import { getGridCols, getIconSize, getVisibleStatLimit, getVisibleStats, prioritizeVisibleStats } from "./shared";
describe("getGridCols", () => {
test("caps columns to visible stat count", () => {
@@ -37,6 +37,17 @@ describe("getIconSize", () => {
});
});
describe("getVisibleStatLimit", () => {
test("keeps compact metrics legible when height is constrained", () => {
expect(getVisibleStatLimit(100, 80, 5, true)).toBe(1);
expect(getVisibleStatLimit(180, 50, 5, true)).toBe(3);
});
test("preserves every configured metric outside compact mode", () => {
expect(getVisibleStatLimit(100, 80, 5, false)).toBe(5);
});
});
describe("getVisibleStats", () => {
const navidromeStats: NavidromeDashboardData = {
artistCount: 100,
@@ -106,3 +117,21 @@ describe("getVisibleStats", () => {
expect(typeof result[0]?.value).toBe("string");
});
});
describe("prioritizeVisibleStats", () => {
test("puts an active session first only on compact surfaces", () => {
const stats = getVisibleStats(
"audiobookshelf",
{ showLibraryCount: true, showActiveSessions: true },
{
libraryCount: 3,
totalAudiobooks: 150,
totalPodcasts: 20,
totalListeningTimeSeconds: 86400,
activeSessions: 2,
},
);
expect(prioritizeVisibleStats(stats, true)[0]?.statKey).toBe("activeSessions");
expect(prioritizeVisibleStats(stats, false)[0]?.statKey).toBe("libraryCount");
});
});
@@ -14,11 +14,6 @@
gap: clamp(2px, 1cqi, 4px);
}
.rootShort {
padding: 4px;
gap: 2px;
}
.grid {
display: grid;
flex: 1;
@@ -34,10 +29,6 @@
gap: clamp(2px, 1cqi, 4px);
}
.gridShort {
gap: 2px;
}
.statTile {
display: flex;
flex-direction: column;
@@ -49,12 +40,8 @@
gap: clamp(2px, 1.5cqi, 4px);
padding: clamp(4px, 2cqi, 8px);
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
overflow: hidden;
}
.statTileAdvanced {
background-color: transparent;
overflow: hidden;
}
.statTileCompact {
@@ -63,17 +50,6 @@
border-radius: var(--mantine-radius-sm);
}
.statTileShort {
gap: 2px;
padding: 3px;
}
.statTileNarrowShort {
flex-direction: row;
gap: 2px;
padding: 1px 3px;
}
.statIcon {
color: var(--mantine-color-dimmed);
flex-shrink: 0;
@@ -81,12 +57,7 @@
}
.statLabel {
flex-shrink: 0;
font-size: clamp(
calc(8px * var(--board-canvas-ui-scale, 1)),
calc(2.5cqi * var(--board-canvas-ui-scale, 1)),
calc(11px * var(--board-canvas-ui-scale, 1))
);
font-size: clamp(8px, 2.5cqi, 11px);
color: var(--mantine-color-dimmed);
line-height: 1.2;
text-align: center;
@@ -97,32 +68,11 @@
}
.statTileCompact .statLabel {
font-size: clamp(
calc(7px * var(--board-canvas-ui-scale, 1)),
calc(2cqi * var(--board-canvas-ui-scale, 1)),
calc(9px * var(--board-canvas-ui-scale, 1))
);
}
.statLabelShort {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
font-size: clamp(7px, 2cqi, 9px);
}
.statValue {
flex-shrink: 0;
font-size: clamp(
calc(13px * var(--board-canvas-ui-scale, 1)),
calc(5cqi * var(--board-canvas-ui-scale, 1)),
calc(20px * var(--board-canvas-ui-scale, 1))
);
font-size: clamp(13px, 5cqi, 20px);
font-weight: 700;
color: var(--mantine-primary-color-filled);
line-height: 1.1;
@@ -134,16 +84,7 @@
}
.statTileCompact .statValue {
font-size: clamp(
calc(11px * var(--board-canvas-ui-scale, 1)),
calc(4cqi * var(--board-canvas-ui-scale, 1)),
calc(16px * var(--board-canvas-ui-scale, 1))
);
}
.statValueNarrowShort {
font-size: calc(11px * var(--board-canvas-ui-scale, 1));
line-height: 1;
font-size: clamp(11px, 4cqi, 16px);
}
.emptyState {
+3 -12
View File
@@ -18,7 +18,6 @@ export default function AudioStatsWidget({
options,
width,
height,
displayScale = 1,
displayMode = "compact",
widgetRuntimeRef,
}: WidgetComponentProps<"audioStats">) {
@@ -40,22 +39,14 @@ export default function AudioStatsWidget({
const currentStreams = streamResults ?? [];
if (!response) return <WidgetEmptyState />;
let responsiveWidth = width;
let responsiveHeight = height;
if (displayMode === "compact" && Number.isFinite(displayScale) && displayScale > 0) {
responsiveWidth *= displayScale;
responsiveHeight *= displayScale;
}
const summary = (
<AudioStatsContent
backend={response.kind}
stats={response.data}
options={{ ...options, compactMode: displayMode === "compact" && options.compactMode }}
options={{ ...options, compactMode: displayMode === "compact" && (options.compactMode || height < 160) }}
showAllStats={displayMode === "advanced"}
advanced={displayMode === "advanced"}
width={responsiveWidth}
height={responsiveHeight}
width={width}
height={height}
/>
);
if (displayMode === "compact") {
Loaded 100 of 217 files, more files were not shown because too many files have changed in this diff. Show more