diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx
index 5ef772034..02de33b5a 100644
--- a/apps/docs/docs/widgets/bookmarks/index.mdx
+++ b/apps/docs/docs/widgets/bookmarks/index.mdx
@@ -21,12 +21,12 @@ You can add links to your favorite applications, websites, or any other resource
import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from "./img/vertical-list-all.png";
-import vericalListSimple from "./img/vertical-list-simple.png";
+import verticalListSimple from "./img/vertical-list-simple.png";
diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx
index e6fd43e15..c20ba99f9 100644
--- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx
+++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx
@@ -1,6 +1,6 @@
---
title: "DNS Hole Controls"
-description: "Control PiHole or AdGuard from your dashboard."
+description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard."
hide_title: true
---
diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx
index 7d983342a..c988e0876 100644
--- a/apps/docs/docs/widgets/iframe/index.mdx
+++ b/apps/docs/docs/widgets/iframe/index.mdx
@@ -39,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png";
Although you can integrate many websites, some may not work.
This is because the administrators of those sites disallow iframes
for security reasons, as users could easily be tricked
-by embeding the original site on a malicious page.
+by embedding the original site on a malicious page.
Some proxies disallow iframes by default for this reason.
-You can use https://iframetest.com to check, if the site is allowing iframes.
+You can use https://iframetest.com to check whether the site allows iframes.
If you manage all of your own applications, you can usually change that by configuring
-the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
+the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete
+`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers.
For example, to allow your site to be embedded in iframes from specific domains:
@@ -53,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains:
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
-Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
-add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
@@ -86,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an

-### Third party content
+### Third-party content
:::warning
-The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository.
+The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository.
We cannot guarantee the functionality nor security of these tools.
:::
diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx
index 7142ccfc5..14695f0fe 100644
--- a/apps/docs/docs/widgets/media-releases/index.mdx
+++ b/apps/docs/docs/widgets/media-releases/index.mdx
@@ -1,6 +1,6 @@
---
title: "Media releases"
-description: "Display newly added medias or upcoming releases from different integrations"
+description: "Display newly added media or upcoming releases from different integrations"
hide_title: true
---
diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx
index b381f177b..b659f9e27 100644
--- a/apps/docs/docs/widgets/media-request-stats/index.mdx
+++ b/apps/docs/docs/widgets/media-request-stats/index.mdx
@@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr";
-Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections.
+Provide an overview of your media requests, including total requests, open requests, approvals, and rejections.
### Supported Integrations
diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx
index 8a99cf8cf..bcc6e281e 100644
--- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx
+++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx
@@ -26,7 +26,7 @@ import bedrockItem from "./img/bedrock.png";
@@ -42,4 +42,4 @@ import bedrockItem from "./img/bedrock.png";
### Data source
We use the Minecraft Server Status API to fetch the server status.
-If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us`
+If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`.
diff --git a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
index 6ff513bf5..39965dbdf 100644
--- a/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
+++ b/apps/nextjs/src/components/board/advanced-focus/advanced-focus.module.css
@@ -135,7 +135,7 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
- animation-duration: 1ms;
+ animation: none;
}
}
diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts
new file mode 100644
index 000000000..ae135ea57
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/widget-query-scope.spec.ts
@@ -0,0 +1,439 @@
+import type { QueryKey } from "@tanstack/react-query";
+import { describe, expect, test } from "vitest";
+
+import type { NormalizedWidgetQuery, WidgetDefinition, WidgetQueryMatcherScope } from "@homarr/widgets";
+import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets";
+
+import { matchesWidgetItemQuery } from "./widget-query-scope";
+
+type WidgetKind = keyof typeof widgetImports;
+const allPhotosAlbumId = "all";
+
+const createScope = (overrides: Partial
= {}): WidgetQueryMatcherScope => ({
+ itemId: "item-1",
+ boardId: "board-1",
+ integrationIds: ["integration-1"],
+ options: {},
+ runtimeQueries: [],
+ ...overrides,
+});
+
+const matches = (kind: WidgetKind, path: readonly string[], input: unknown, scope: WidgetQueryMatcherScope) => {
+ const definition = widgetImports[kind].definition as WidgetDefinition & { kind: string };
+ const queryKey: QueryKey = [path, { input, type: "query" }];
+ return matchesWidgetItemQuery(queryKey, getWidgetQueryKeys(definition), scope, definition.queryMatcher);
+};
+
+describe("matchesWidgetItemQuery", () => {
+ test("matches only the current Custom API definition", () => {
+ const scope = createScope({ options: { definitionId: "definition-1" } });
+
+ expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-1" }, scope)).toBe(
+ true,
+ );
+ expect(matches("customApi", ["widget", "customApi", "getData"], { definitionId: "definition-2" }, scope)).toBe(
+ false,
+ );
+ });
+
+ test("owns only the active compact or advanced Media Missing page size", () => {
+ const path = ["widget", "mediaOrganizer", "getData"];
+ const compactInput = { integrationIds: ["radarr-1"], pageSize: 20 };
+ const advancedInput = { integrationIds: ["radarr-1"], pageSize: 50 };
+ const compactScope = createScope({
+ integrationIds: ["radarr-1"],
+ runtimeQueries: [{ path, input: compactInput }],
+ });
+ const advancedScope = createScope({
+ integrationIds: ["radarr-1"],
+ runtimeQueries: [{ path, input: advancedInput }],
+ });
+
+ expect(matches("mediaMissing", path, compactInput, compactScope)).toBe(true);
+ expect(matches("mediaMissing", path, advancedInput, compactScope)).toBe(false);
+ expect(matches("mediaMissing", path, advancedInput, advancedScope)).toBe(true);
+ expect(matches("mediaMissing", path, compactInput, advancedScope)).toBe(false);
+ });
+
+ test("owns every Releases batch by item id", () => {
+ const scope = createScope({ itemId: "release-item" });
+ const path = ["widget", "releases", "getLatest"];
+
+ expect(matches("releases", path, { itemId: "release-item", repositories: [{ id: "one" }] }, scope)).toBe(true);
+ expect(matches("releases", path, { itemId: "sibling-item", repositories: [{ id: "one" }] }, scope)).toBe(false);
+ });
+
+ test("normalizes Immich's all-photos sentinel and separates Immich widget kinds", () => {
+ const carouselScope = createScope({
+ integrationIds: ["immich-1"],
+ options: { albumId: allPhotosAlbumId },
+ });
+ const serverStatsQuery: NormalizedWidgetQuery = {
+ path: ["widget", "immich", "getServerStats"],
+ input: { integrationId: "immich-1" },
+ };
+ const albumsQuery: NormalizedWidgetQuery = {
+ path: ["widget", "immich", "getAlbums"],
+ input: { integrationId: "immich-1" },
+ };
+ const compactServerScope = createScope({
+ integrationIds: ["immich-1"],
+ runtimeQueries: [serverStatsQuery],
+ });
+ const advancedServerScope = createScope({
+ integrationIds: ["immich-1"],
+ runtimeQueries: [serverStatsQuery, albumsQuery],
+ });
+
+ expect(
+ matches("immich-albumCarousel", ["widget", "immich", "getAlbum"], { integrationId: "immich-1" }, carouselScope),
+ ).toBe(true);
+ expect(
+ matches(
+ "immich-albumCarousel",
+ ["widget", "immich", "getAlbum"],
+ { integrationId: "immich-1", albumId: "album-2" },
+ carouselScope,
+ ),
+ ).toBe(false);
+ expect(
+ matches(
+ "immich-albumCarousel",
+ ["widget", "immich", "getServerStats"],
+ { integrationId: "immich-1" },
+ carouselScope,
+ ),
+ ).toBe(false);
+ expect(matches("immich-serverStats", serverStatsQuery.path, serverStatsQuery.input, compactServerScope)).toBe(true);
+ expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, compactServerScope)).toBe(false);
+ expect(matches("immich-serverStats", albumsQuery.path, albumsQuery.input, advancedServerScope)).toBe(true);
+ expect(
+ matches("immich-serverStats", ["widget", "immich", "getAlbum"], albumsQuery.input, advancedServerScope),
+ ).toBe(false);
+ });
+
+ test("uses the resolved Beszel integration, system, and includeDocker input", () => {
+ const runtimeQuery: NormalizedWidgetQuery = {
+ path: ["widget", "beszel", "getSystemStats"],
+ input: {
+ integrationIds: ["beszel-2"],
+ systemId: "system-2",
+ timePeriod: "1h",
+ includeDocker: true,
+ },
+ };
+ const scope = createScope({
+ integrationIds: ["beszel-1", "beszel-2"],
+ options: { systemId: "beszel-2:system-2", timePeriod: "1h", showDockerCpu: true },
+ runtimeQueries: [runtimeQuery],
+ });
+
+ expect(matches("beszelSystemStats", runtimeQuery.path, runtimeQuery.input, scope)).toBe(true);
+ expect(
+ matches(
+ "beszelSystemStats",
+ runtimeQuery.path,
+ { ...(runtimeQuery.input as Record), includeDocker: false },
+ scope,
+ ),
+ ).toBe(false);
+ expect(
+ matches(
+ "beszelSystemStats",
+ runtimeQuery.path,
+ { ...(runtimeQuery.input as Record), systemId: "system-1" },
+ scope,
+ ),
+ ).toBe(false);
+ });
+
+ test("separates Beszel procedure families while retaining shared getSystems ownership", () => {
+ const scope = createScope({
+ integrationIds: ["beszel-1"],
+ options: { showHistory: true, maxHistoryItems: 10 },
+ });
+ const systemsInput = { integrationIds: ["beszel-1"] };
+ const alertsInput = { integrationIds: ["beszel-1"], includeHistory: true, maxHistoryItems: 10 };
+
+ expect(matches("beszelSystemGrid", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
+ expect(matches("beszelSystemTable", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(true);
+ expect(matches("beszelAlerts", ["widget", "beszel", "getSystems"], systemsInput, scope)).toBe(false);
+ expect(matches("beszelAlerts", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(true);
+ expect(matches("beszelSystemGrid", ["widget", "beszel", "getAlerts"], alertsInput, scope)).toBe(false);
+ });
+
+ test("owns only the active Beszel table detail query", () => {
+ const activeQuery: NormalizedWidgetQuery = {
+ path: ["widget", "beszel", "getSystemStats"],
+ input: {
+ integrationIds: ["beszel-1"],
+ systemId: "system-1",
+ timePeriod: "1h",
+ includeDocker: false,
+ },
+ };
+ const scope = createScope({ integrationIds: ["beszel-1"], runtimeQueries: [activeQuery] });
+
+ expect(matches("beszelSystemTable", activeQuery.path, activeQuery.input, scope)).toBe(true);
+ expect(
+ matches(
+ "beszelSystemTable",
+ activeQuery.path,
+ { ...(activeQuery.input as Record), systemId: "system-2" },
+ scope,
+ ),
+ ).toBe(false);
+ expect(matches("beszelSystemGrid", activeQuery.path, activeQuery.input, scope)).toBe(false);
+ });
+
+ test("owns only Umami queries registered for the active view and layout", () => {
+ const eventQuery: NormalizedWidgetQuery = {
+ path: ["widget", "umami", "getMultiEventTimeSeries"],
+ input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["signup", "view"] },
+ };
+ const topPagesQuery: NormalizedWidgetQuery = {
+ path: ["widget", "umami", "getTopPages"],
+ input: { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", limit: 5 },
+ };
+ const eventsScope = createScope({ runtimeQueries: [eventQuery] });
+ const advancedScope = createScope({ runtimeQueries: [eventQuery, topPagesQuery] });
+
+ expect(matches("umami", eventQuery.path, eventQuery.input, eventsScope)).toBe(true);
+ expect(
+ matches(
+ "umami",
+ eventQuery.path,
+ { integrationId: "umami-1", websiteId: "site-1", timeFrame: "24h", eventNames: ["view", "signup"] },
+ eventsScope,
+ ),
+ ).toBe(false);
+ expect(matches("umami", topPagesQuery.path, topPagesQuery.input, eventsScope)).toBe(false);
+ expect(matches("umami", topPagesQuery.path, topPagesQuery.input, advancedScope)).toBe(true);
+ });
+
+ test("maps Media Request status filters and separates list from stats", () => {
+ const listScope = createScope({
+ integrationIds: ["request-1"],
+ options: { statusFilter: [], recentDays: 30, linksTargetNewTab: true },
+ });
+ const allStatuses = ["pending", "approved", "declined", "failed", "completed"];
+
+ expect(
+ matches(
+ "mediaRequests-requestList",
+ ["widget", "mediaRequests", "getLatestRequests"],
+ { integrationIds: ["request-1"], statuses: allStatuses, recentDays: 30 },
+ listScope,
+ ),
+ ).toBe(true);
+ expect(
+ matches(
+ "mediaRequests-requestList",
+ ["widget", "mediaRequests", "getLatestRequests"],
+ { integrationIds: ["request-1"], statuses: ["pending"], recentDays: 30 },
+ listScope,
+ ),
+ ).toBe(false);
+ expect(
+ matches(
+ "mediaRequests-requestList",
+ ["widget", "mediaRequests", "getStats"],
+ { integrationIds: ["request-1"] },
+ listScope,
+ ),
+ ).toBe(false);
+ expect(
+ matches(
+ "mediaRequests-requestStats",
+ ["widget", "mediaRequests", "getStats"],
+ { integrationIds: ["request-1"] },
+ listScope,
+ ),
+ ).toBe(true);
+ });
+
+ test("separates Audio Stats stream details from Media Server filters", () => {
+ const statsQuery: NormalizedWidgetQuery = {
+ path: ["widget", "audioStats", "getStats"],
+ input: { integrationId: "audio-1" },
+ };
+ const streamsQuery: NormalizedWidgetQuery = {
+ path: ["widget", "mediaServer", "getCurrentStreams"],
+ input: { integrationIds: ["audio-1"], showOnlyPlaying: false },
+ };
+ const compactAudioScope = createScope({ integrationIds: ["audio-1"], runtimeQueries: [statsQuery] });
+ const advancedAudioScope = createScope({
+ integrationIds: ["audio-1"],
+ runtimeQueries: [statsQuery, streamsQuery],
+ });
+ const mediaScope = createScope({ integrationIds: ["audio-1"], options: { showOnlyPlaying: true } });
+
+ expect(matches("audioStats", statsQuery.path, statsQuery.input, compactAudioScope)).toBe(true);
+ expect(matches("audioStats", streamsQuery.path, streamsQuery.input, compactAudioScope)).toBe(false);
+ expect(matches("audioStats", streamsQuery.path, streamsQuery.input, advancedAudioScope)).toBe(true);
+ expect(
+ matches(
+ "audioStats",
+ streamsQuery.path,
+ { integrationIds: ["audio-1"], showOnlyPlaying: true },
+ advancedAudioScope,
+ ),
+ ).toBe(false);
+ expect(
+ matches("mediaServer", streamsQuery.path, { integrationIds: ["audio-1"], showOnlyPlaying: true }, mediaScope),
+ ).toBe(true);
+ expect(matches("mediaServer", streamsQuery.path, streamsQuery.input, mediaScope)).toBe(false);
+ });
+
+ test("uses each Calendar instance's registered month", () => {
+ const path = ["widget", "calendar", "findAllEvents"];
+ const januaryInput = {
+ integrationIds: ["calendar-1"],
+ month: 0,
+ year: 2026,
+ releaseType: [],
+ showUnmonitored: false,
+ };
+ const februaryInput = { ...januaryInput, month: 1 };
+ const januaryScope = createScope({ runtimeQueries: [{ path, input: januaryInput }] });
+ const februaryScope = createScope({ runtimeQueries: [{ path, input: februaryInput }] });
+
+ expect(matches("calendar", path, januaryInput, januaryScope)).toBe(true);
+ expect(matches("calendar", path, februaryInput, januaryScope)).toBe(false);
+ expect(matches("calendar", path, februaryInput, februaryScope)).toBe(true);
+ });
+
+ test("uses each Media Transcoding instance's registered page and geometry-derived page size", () => {
+ const path = ["widget", "mediaTranscoding", "getDataAsync"];
+ const firstPage = { integrationId: "transcode-1", pageSize: 8, page: 1 };
+ const secondPage = { integrationId: "transcode-1", pageSize: 12, page: 2 };
+ const firstScope = createScope({ runtimeQueries: [{ path, input: firstPage }] });
+ const secondScope = createScope({ runtimeQueries: [{ path, input: secondPage }] });
+
+ expect(matches("mediaTranscoding", path, firstPage, firstScope)).toBe(true);
+ expect(matches("mediaTranscoding", path, secondPage, firstScope)).toBe(false);
+ expect(matches("mediaTranscoding", path, secondPage, secondScope)).toBe(true);
+ });
+
+ test("matches only the Downloads query with the current integrations and limit", () => {
+ const scope = createScope({
+ integrationIds: ["sabnzbd-1", "qbittorrent-1"],
+ options: { limitPerIntegration: 25 },
+ });
+ const path = ["widget", "downloads", "getJobsAndStatuses"];
+
+ expect(
+ matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 25 }, scope),
+ ).toBe(true);
+ expect(matches("downloads", path, { integrationIds: ["sabnzbd-1"], limitPerIntegration: 25 }, scope)).toBe(false);
+ expect(
+ matches("downloads", path, { integrationIds: ["sabnzbd-1", "qbittorrent-1"], limitPerIntegration: 50 }, scope),
+ ).toBe(false);
+ expect(
+ matches("downloads", ["widget", "downloads", "pause"], { integrationIds: ["sabnzbd-1", "qbittorrent-1"] }, scope),
+ ).toBe(false);
+ });
+
+ test("matches only the active Stock Price symbol and time frame", () => {
+ const scope = createScope({ options: { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" } });
+ const path = ["widget", "stockPrice", "getPriceHistory"];
+
+ expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(true);
+ expect(matches("stockPrice", path, { stock: "MSFT", timeRange: "1mo", timeInterval: "1d" }, scope)).toBe(false);
+ expect(matches("stockPrice", path, { stock: "AAPL", timeRange: "5d", timeInterval: "1d" }, scope)).toBe(false);
+ });
+
+ test("matches Weather by the coordinates sent to the location procedure", () => {
+ const scope = createScope({
+ options: { location: { name: "Paris", latitude: 48.85341, longitude: 2.3488 } },
+ });
+ const path = ["widget", "weather", "atLocation"];
+
+ expect(matches("weather", path, { latitude: 48.85341, longitude: 2.3488 }, scope)).toBe(true);
+ expect(matches("weather", path, { latitude: 51.5072, longitude: -0.1276 }, scope)).toBe(false);
+ expect(
+ matches("weather", ["widget", "weather", "forecast"], { latitude: 48.85341, longitude: 2.3488 }, scope),
+ ).toBe(false);
+ });
+
+ test("matches Clock weather only when enabled and at its configured coordinates", () => {
+ const path = ["widget", "weather", "atLocation"];
+ const location = { name: "Brisbane", latitude: -27.4698, longitude: 153.0251 };
+ const enabledScope = createScope({ options: { showWeather: true, weatherLocation: location } });
+ const disabledScope = createScope({ options: { showWeather: false, weatherLocation: location } });
+
+ expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, enabledScope)).toBe(true);
+ expect(matches("clock", path, { latitude: 48.85341, longitude: 2.3488 }, enabledScope)).toBe(false);
+ expect(matches("clock", path, { latitude: -27.4698, longitude: 153.0251 }, disabledScope)).toBe(false);
+ });
+
+ test("matches Minecraft Server Status by the full input passed to useQuery", () => {
+ const scope = createScope({
+ options: { title: "Survival", domain: "play.example.com", isBedrockServer: false },
+ });
+ const path = ["widget", "minecraft", "getServerStatus"];
+
+ expect(
+ matches(
+ "minecraftServerStatus",
+ path,
+ { title: "Survival", domain: "play.example.com", isBedrockServer: false },
+ scope,
+ ),
+ ).toBe(true);
+ expect(
+ matches(
+ "minecraftServerStatus",
+ path,
+ { title: "Creative", domain: "play.example.com", isBedrockServer: false },
+ scope,
+ ),
+ ).toBe(false);
+ expect(
+ matches(
+ "minecraftServerStatus",
+ path,
+ { title: "Survival", domain: "bedrock.example.com", isBedrockServer: true },
+ scope,
+ ),
+ ).toBe(false);
+ });
+
+ test("matches Notifications by integrations and the full input passed to useQuery", () => {
+ const scope = createScope({ integrationIds: ["ntfy-1"], options: { hideLogos: true } });
+ const path = ["widget", "notifications", "getNotifications"];
+
+ expect(matches("notifications", path, { hideLogos: true, integrationIds: ["ntfy-1"] }, scope)).toBe(true);
+ expect(matches("notifications", path, { hideLogos: false, integrationIds: ["ntfy-1"] }, scope)).toBe(false);
+ expect(matches("notifications", path, { hideLogos: true, integrationIds: ["gotify-1"] }, scope)).toBe(false);
+ expect(
+ matches("notifications", ["widget", "notifications", "dismiss"], { integrationIds: ["ntfy-1"] }, scope),
+ ).toBe(false);
+ });
+
+ test("keeps renamed RSS input fields item-specific", () => {
+ const scope = createScope({
+ integrationIds: [],
+ options: { feedUrls: ["https://feed.example/rss"], maximumAmountPosts: 100 },
+ });
+ const path = ["widget", "rssFeed", "getFeeds"];
+
+ expect(matches("rssFeed", path, { urls: ["https://feed.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(true);
+ expect(matches("rssFeed", path, { urls: ["https://other.example/rss"], maximumAmountPosts: 100 }, scope)).toBe(
+ false,
+ );
+ });
+
+ test("generic fallback ignores transformed non-identity inputs", () => {
+ const scope = createScope({ integrationIds: ["integration-1"], options: { pageSize: 10 } });
+ const definitionKeys = [[["widget", "example"]]] as const;
+
+ expect(
+ matchesWidgetItemQuery(
+ [["widget", "example", "getData"], { input: { integrationIds: ["integration-1"], pageSize: 50 } }],
+ definitionKeys,
+ scope,
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.ts b/apps/nextjs/src/components/board/items/widget-query-scope.ts
new file mode 100644
index 000000000..03ae7a1d3
--- /dev/null
+++ b/apps/nextjs/src/components/board/items/widget-query-scope.ts
@@ -0,0 +1,94 @@
+import type { QueryKey } from "@tanstack/react-query";
+import { hashKey } from "@tanstack/react-query";
+
+import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets";
+
+export const matchesWidgetItemQuery = (
+ queryKey: QueryKey,
+ widgetQueryKeys: readonly QueryKey[],
+ scope: WidgetQueryMatcherScope,
+ queryMatcher?: WidgetQueryMatcher,
+) => {
+ const query = normalizeWidgetQuery(queryKey);
+ if (!query || !widgetQueryKeys.some((candidate) => matchesDefinitionPath(query.path, candidate))) return false;
+ if (queryMatcher) return queryMatcher(query, scope);
+
+ return matchesQueryInput(query.input, scope);
+};
+
+const normalizeWidgetQuery = (queryKey: QueryKey): NormalizedWidgetQuery | null => {
+ const path = queryKey[0];
+ if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return null;
+
+ const queryKeyOptions = queryKey[1];
+ const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
+ return { path, input };
+};
+
+const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => {
+ const candidatePath = candidate[0];
+ return (
+ Array.isArray(candidatePath) &&
+ candidatePath.every((part, index) => typeof part === "string" && path[index] === part)
+ );
+};
+
+const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => {
+ if (input == null) return true;
+ if (Array.isArray(input)) {
+ return isEqual(input, scope.integrationIds) || containsValue(scope.options, input);
+ }
+
+ if (!isRecord(input)) {
+ return (
+ input === scope.itemId ||
+ input === scope.boardId ||
+ scope.integrationIds.includes(String(input)) ||
+ containsValue(scope.options, input)
+ );
+ }
+
+ for (const [key, value] of Object.entries(input)) {
+ if (key === "itemId") {
+ if (value !== scope.itemId) return false;
+ continue;
+ }
+ if (key === "boardId") {
+ if (value !== scope.boardId) return false;
+ continue;
+ }
+ if (key === "integrationId") {
+ if (!scope.integrationIds.includes(String(value))) return false;
+ continue;
+ }
+ if (key === "integrationIds") {
+ if (!isEqual(value, scope.integrationIds)) return false;
+ continue;
+ }
+
+ if (isIdentityKey(key) && !matchesAnyScopedValue(value, scope)) return false;
+ }
+
+ return true;
+};
+
+const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) =>
+ value === scope.itemId ||
+ value === scope.boardId ||
+ scope.integrationIds.includes(String(value)) ||
+ containsValue(scope.options, value);
+
+const isIdentityKey = (key: string) => key === "id" || key.endsWith("Id") || key.endsWith("Ids");
+
+const containsValue = (container: unknown, expected: unknown, seen = new WeakSet()): boolean => {
+ if (isEqual(container, expected)) return true;
+ if (container === null || typeof container !== "object" || seen.has(container)) return false;
+ seen.add(container);
+
+ return Object.values(container).some((value) => containsValue(value, expected, seen));
+};
+
+const isEqual = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]);
+
+const isRecord = (value: unknown): value is Record =>
+ value !== null && typeof value === "object" && !Array.isArray(value);
diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css
new file mode 100644
index 000000000..fe141c3f7
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css
@@ -0,0 +1,4 @@
+.suppressTouchCallout {
+ user-select: none;
+ -webkit-touch-callout: none;
+}
diff --git a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx
index 606bc5ece..cfd98717c 100644
--- a/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx
+++ b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx
@@ -17,6 +17,10 @@ import { ItemSelectModal } from "../../items/item-select-modal";
import { useBoardPermissions } from "../../permissions/client";
import type { BoardPlacementHint } from "../../items/actions/create-item";
import type { UseGridstackRefs } from "./use-gridstack";
+import classes from "./board-empty-space-context-menu.module.css";
+import { hasExceededLongPressMoveTolerance } from "./long-press";
+
+const suppressTouchCalloutClass = classes.suppressTouchCallout as string;
interface MenuState {
x: number;
@@ -39,18 +43,25 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const [isEditMode] = useEditMode();
const [menu, setMenu] = useState(null);
const longPressTimer = useRef | null>(null);
+ const longPressPointer = useRef<{ pointerId: number; x: number; y: number } | null>(null);
+ const touchGuardElement = useRef(null);
const returnFocusRef = useRef(null);
const enabled = hasChangeAccess && settings.enableRightClickOnWidgets;
const clearLongPress = () => {
- if (longPressTimer.current === null) return;
- clearTimeout(longPressTimer.current);
- longPressTimer.current = null;
+ if (longPressTimer.current !== null) {
+ clearTimeout(longPressTimer.current);
+ longPressTimer.current = null;
+ }
+ touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
+ touchGuardElement.current = null;
+ longPressPointer.current = null;
};
useEffect(
() => () => {
if (longPressTimer.current !== null) clearTimeout(longPressTimer.current);
+ touchGuardElement.current?.classList.remove(suppressTouchCalloutClass);
},
[],
);
@@ -89,12 +100,31 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const placement = resolvePlacement(event.target, event.pageX, event.pageY);
if (!placement) return;
clearLongPress();
+ longPressPointer.current = { pointerId: event.pointerId, x: event.clientX, y: event.clientY };
+ touchGuardElement.current = event.currentTarget;
+ touchGuardElement.current.classList.add(suppressTouchCalloutClass);
longPressTimer.current = setTimeout(() => {
setMenu({ x: event.clientX, y: event.clientY, placement });
navigator.vibrate?.(20);
}, 500);
};
+ const handlePointerMove = (event: PointerEvent) => {
+ const pointer = longPressPointer.current;
+ if (
+ !pointer ||
+ pointer.pointerId !== event.pointerId ||
+ !hasExceededLongPressMoveTolerance(pointer, { x: event.clientX, y: event.clientY })
+ ) {
+ return;
+ }
+ clearLongPress();
+ };
+
+ const handlePointerEnd = (event: PointerEvent) => {
+ if (longPressPointer.current?.pointerId === event.pointerId) clearLongPress();
+ };
+
const handleKeyDown = (event: KeyboardEvent) => {
if (
!enabled ||
@@ -134,10 +164,10 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
onContextMenu={handleContextMenu}
onKeyDown={handleKeyDown}
onPointerDown={handlePointerDown}
- onPointerMove={clearLongPress}
- onPointerUp={clearLongPress}
- onPointerCancel={clearLongPress}
- onPointerLeave={clearLongPress}
+ onPointerMove={handlePointerMove}
+ onPointerUp={handlePointerEnd}
+ onPointerCancel={handlePointerEnd}
+ onPointerLeave={handlePointerEnd}
>
{children}
diff --git a/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts b/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts
new file mode 100644
index 000000000..9ab4461e3
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts
@@ -0,0 +1,17 @@
+import { describe, expect, test } from "vitest";
+
+import { hasExceededLongPressMoveTolerance } from "./long-press";
+
+describe("hasExceededLongPressMoveTolerance", () => {
+ test("allows small touch jitter", () => {
+ expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 106 })).toBe(false);
+ });
+
+ test("allows movement exactly at the threshold", () => {
+ expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 106, y: 108 })).toBe(false);
+ });
+
+ test("cancels a deliberate scroll gesture", () => {
+ expect(hasExceededLongPressMoveTolerance({ x: 100, y: 100 }, { x: 100, y: 116 })).toBe(true);
+ });
+});
diff --git a/apps/nextjs/src/components/board/sections/gridstack/long-press.ts b/apps/nextjs/src/components/board/sections/gridstack/long-press.ts
new file mode 100644
index 000000000..d7b94ad01
--- /dev/null
+++ b/apps/nextjs/src/components/board/sections/gridstack/long-press.ts
@@ -0,0 +1,7 @@
+export const longPressMoveTolerancePx = 10;
+
+export const hasExceededLongPressMoveTolerance = (
+ start: { x: number; y: number },
+ current: { x: number; y: number },
+ tolerance = longPressMoveTolerancePx,
+) => Math.hypot(current.x - start.x, current.y - start.y) > tolerance;
diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts
index bf82259fb..4eaa687db 100644
--- a/e2e/board-advanced-interactions.spec.ts
+++ b/e2e/board-advanced-interactions.spec.ts
@@ -76,6 +76,7 @@ describe("Board advanced interactions", () => {
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedDialog).toBeVisible({ timeout: 2_000 });
+ await expect(advancedDialog).toHaveCSS("animation-name", "none");
const advancedBounds = await advancedDialog.boundingBox();
expect(advancedBounds?.width).toBeGreaterThanOrEqual(900);
await expect(advancedDialog).toHaveAttribute("data-lifecycle-probe", "same-instance");
@@ -280,7 +281,7 @@ const touchAndHoldEmptyGridSpaceAsync = async (grid: Locator) => {
const clientX = bounds.x + bounds.width - 16;
const clientY = bounds.y + Math.min(24, bounds.height / 2);
await grid.dispatchEvent("pointerdown", { pointerType: "touch", clientX, clientY });
- await new Promise((resolve) => setTimeout(resolve, 550));
+ await new Promise((resolve) => setTimeout(resolve, 800));
await grid.dispatchEvent("pointerup", { pointerType: "touch", clientX, clientY });
};
@@ -310,7 +311,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
id: iframeItemId,
kind: "iframe",
boardId,
- options: stringify({ embedUrl: "https://example.com", allowScrolling: true }),
+ options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
]);
diff --git a/packages/api/src/router/test/widgets/immich.spec.ts b/packages/api/src/router/test/widgets/immich.spec.ts
new file mode 100644
index 000000000..eec383aaa
--- /dev/null
+++ b/packages/api/src/router/test/widgets/immich.spec.ts
@@ -0,0 +1,76 @@
+// @vitest-environment node
+
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import type { Session } from "@homarr/auth";
+import { createId } from "@homarr/common";
+import type { Database } from "@homarr/db";
+import { integrations, integrationUserPermissions, users } from "@homarr/db/schema";
+import { createDb } from "@homarr/db/test";
+
+import { immichRouter } from "../../widgets/immich";
+
+vi.hoisted(() => {
+ process.env.SKIP_ENV_VALIDATION = "true";
+});
+
+const mocks = vi.hoisted(() => ({
+ albumsHandler: vi.fn(),
+}));
+
+vi.mock("@homarr/request-handler/immich", () => ({
+ immichAlbumRequestHandler: { handler: vi.fn() },
+ immichStatsRequestHandler: { handler: vi.fn() },
+ immichAlbumsRequestHandler: {
+ handler: (_integration: unknown, input: unknown) => {
+ mocks.albumsHandler(input);
+ return { getDataAsync: async () => ({ data: [], timestamp: new Date() }) };
+ },
+ },
+}));
+
+const createSession = (userId: string): Session => ({
+ user: { id: userId, permissions: [], colorScheme: "light" },
+ expires: new Date().toISOString(),
+});
+
+const createCallerAsync = async () => {
+ const db = createDb();
+ const userId = createId();
+ const integrationId = createId();
+ await db.insert(users).values({ id: userId });
+ await db.insert(integrations).values({
+ id: integrationId,
+ kind: "immich",
+ name: "Immich",
+ url: "https://immich.example.com",
+ });
+ await db.insert(integrationUserPermissions).values({ integrationId, userId, permission: "use" });
+
+ return {
+ integrationId,
+ caller: immichRouter.createCaller({ db: db as Database, deviceType: undefined, session: createSession(userId) }),
+ };
+};
+
+describe("immichRouter.getAlbums", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ test("passes a validated limit to the cached request handler", async () => {
+ const { caller, integrationId } = await createCallerAsync();
+
+ await caller.getAlbums({ integrationId, limit: 50 });
+
+ expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: 50 });
+ await expect(caller.getAlbums({ integrationId, limit: 0 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ await expect(caller.getAlbums({ integrationId, limit: 501 })).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
+ test("keeps the limit optional for unbounded callers", async () => {
+ const { caller, integrationId } = await createCallerAsync();
+
+ await caller.getAlbums({ integrationId });
+
+ expect(mocks.albumsHandler).toHaveBeenCalledWith({ limit: undefined });
+ });
+});
diff --git a/packages/api/src/router/test/widgets/rssFeed.spec.ts b/packages/api/src/router/test/widgets/rssFeed.spec.ts
index 4773fa149..9e445a910 100644
--- a/packages/api/src/router/test/widgets/rssFeed.spec.ts
+++ b/packages/api/src/router/test/widgets/rssFeed.spec.ts
@@ -1,5 +1,5 @@
import SuperJSON from "superjson";
-import { describe, expect, test } from "vitest";
+import { beforeEach, describe, expect, test, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
@@ -17,7 +17,18 @@ import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import type { WidgetComponentProps } from "../../../../../widgets/src/definition";
-import { canAccessAllFeedsAsync, restrictUrlsAsync } from "../../widgets/rssFeed";
+import { canAccessAllFeedsAsync, restrictUrlsAsync, rssFeedRouter } from "../../widgets/rssFeed";
+
+const mocks = vi.hoisted(() => ({ logger: { warn: vi.fn() }, getFeed: vi.fn() }));
+
+vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => mocks.logger }));
+vi.mock("@homarr/request-handler/rss-feeds", () => ({
+ rssFeedsRequestHandler: {
+ handler: (input: unknown) => ({ getDataAsync: async () => await mocks.getFeed(input) }),
+ },
+}));
+
+beforeEach(() => vi.clearAllMocks());
const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
user: {
@@ -29,6 +40,32 @@ const createSession = (permissions: GroupPermissionKey[] = []): Session => ({
});
describe("rssFeedRouter", () => {
+ describe("getFeeds", () => {
+ test("redacts feed secrets from logs and returns a generic error when every feed fails", async () => {
+ const feedUrl = "https://user:password@example.com/feed?token=secret";
+ mocks.getFeed.mockRejectedValue(new Error(`Failed to fetch ${feedUrl}`));
+ const caller = rssFeedRouter.createCaller({
+ db: createDb(),
+ deviceType: undefined,
+ session: createSession(["board-create"]),
+ });
+
+ await expect(caller.getFeeds({ urls: [feedUrl], maximumAmountPosts: 5 })).rejects.toMatchObject({
+ code: "BAD_GATEWAY",
+ message: "All RSS feed requests failed",
+ });
+ expect(mocks.logger.warn).toHaveBeenCalledWith("RSS feed fetch failed", {
+ feedIndex: 0,
+ origin: "https://example.com",
+ errorType: "Error",
+ });
+ const logged = JSON.stringify(mocks.logger.warn.mock.calls);
+ expect(logged).not.toContain("password");
+ expect(logged).not.toContain("token");
+ expect(logged).not.toContain("secret");
+ });
+ });
+
describe("canAccessAllFeedsAsync", () => {
test("should return false for unauthenticated users", async () => {
// Arrange
diff --git a/packages/api/src/router/test/widgets/timetable.spec.ts b/packages/api/src/router/test/widgets/timetable.spec.ts
index 7933b0d16..e51ca1bef 100644
--- a/packages/api/src/router/test/widgets/timetable.spec.ts
+++ b/packages/api/src/router/test/widgets/timetable.spec.ts
@@ -1,15 +1,18 @@
import { stringify } from "superjson";
-import { describe, expect, it, vi } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
+import { eq } from "@homarr/db";
import { boards, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { timetableRouter } from "../../widgets/timetable";
-const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn() }));
+const mocks = vi.hoisted(() => ({ getTimetable: vi.fn(), searchStations: vi.fn(), lookup: vi.fn() }));
+
+vi.mock("node:dns/promises", () => ({ default: { lookup: mocks.lookup }, lookup: mocks.lookup }));
vi.mock("@homarr/request-handler/timetable", () => ({
timetableGetTimetableRequestHandler: {
@@ -30,6 +33,11 @@ vi.mock("@homarr/request-handler/timetable", () => ({
},
}));
+beforeEach(() => {
+ vi.clearAllMocks();
+ mocks.lookup.mockResolvedValue([{ address: "8.8.8.8", family: 4 }]);
+});
+
const createSession = (userId: string): Session => ({
user: { id: userId, permissions: [], colorScheme: "light" },
expires: new Date().toISOString(),
@@ -66,6 +74,14 @@ describe("timetableRouter source authorization", () => {
});
});
+ it("reports invalid timetable URLs as bad requests", async () => {
+ const caller = createCaller(createDb(), null);
+
+ await expect(
+ caller.searchStations({ baseUrl: "https://search.ch?redirect=/admin", query: "Bern" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ });
+
it("loads a persisted private endpoint only through a viewable timetable item", async () => {
const db = createDb();
const baseUrl = "http://timetable.internal:8080";
@@ -89,14 +105,161 @@ describe("timetableRouter source authorization", () => {
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
- it("allows board modifiers to search an unsaved self-hosted endpoint", async () => {
+ it("allows board modifiers to search an unsaved public endpoint", async () => {
const db = createDb();
- const baseUrl = "http://timetable.internal:8080";
+ const baseUrl = "https://timetable.example.com";
const { boardId, ownerId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
const caller = createCaller(db, createSession(ownerId));
await caller.searchStations({ baseUrl, boardId, query: "Bern" });
- expect(mocks.searchStations).toHaveBeenLastCalledWith(expect.objectContaining({ baseUrl }));
+ expect(mocks.lookup).toHaveBeenCalledWith("timetable.example.com", { all: true, verbatim: true });
+ expect(mocks.searchStations).toHaveBeenLastCalledWith(
+ expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
+ );
+ });
+
+ it.each([
+ "http://127.0.0.1:8080",
+ "http://timetable.internal:8080",
+ "http://[::1]:8080",
+ "http://[::ffff:127.0.0.1]:8080",
+ "http://[fd00::1]:8080",
+ "http://[64:ff9b::7f00:1]:8080",
+ "http://[2002:7f00:1::]:8080",
+ ])("blocks an unsaved private endpoint %s even for board modifiers", async (baseUrl) => {
+ const db = createDb();
+ const { boardId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = createCaller(db, createSession(ownerId));
+
+ await expect(caller.searchStations({ baseUrl, boardId, query: "Bern" })).rejects.toMatchObject({
+ code: "BAD_REQUEST",
+ });
+ expect(mocks.searchStations).not.toHaveBeenCalled();
+ });
+
+ it("blocks an RFC 2765 IPv4-translated loopback address", async () => {
+ const db = createDb();
+ const { boardId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = createCaller(db, createSession(ownerId));
+
+ await expect(
+ caller.searchStations({ baseUrl: "http://[::ffff:0:7f00:1]:8080", boardId, query: "Bern" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ expect(mocks.searchStations).not.toHaveBeenCalled();
+ });
+
+ it("blocks an unsaved public hostname that resolves to a private address", async () => {
+ mocks.lookup.mockResolvedValueOnce([{ address: "10.0.0.8", family: 4 }]);
+ const db = createDb();
+ const { boardId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch",
+ isPublic: false,
+ });
+ const caller = createCaller(db, createSession(ownerId));
+
+ await expect(
+ caller.searchStations({ baseUrl: "https://timetable.example.com", boardId, query: "Bern" }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ expect(mocks.searchStations).not.toHaveBeenCalled();
+ });
+
+ it("allows the exact saved private endpoint when item and board IDs match", async () => {
+ const db = createDb();
+ const baseUrl = "http://timetable.internal:8080";
+ const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, { baseUrl, isPublic: false });
+
+ await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
+
+ expect(mocks.lookup).not.toHaveBeenCalled();
+ expect(mocks.searchStations).toHaveBeenLastCalledWith(
+ expect.objectContaining({ baseUrl, pinnedAddresses: undefined }),
+ );
+ });
+
+ it("rejects an item ID that does not belong to the requested board", async () => {
+ const db = createDb();
+ const first = await createTimetableItemAsync(db, { baseUrl: "http://timetable.internal:8080", isPublic: false });
+ const second = await createTimetableItemAsync(db, { baseUrl: "https://search.ch", isPublic: false });
+
+ await expect(
+ createCaller(db, createSession(second.ownerId)).searchStations({
+ baseUrl: "http://timetable.internal:8080",
+ boardId: second.boardId,
+ itemId: first.itemId,
+ query: "Bern",
+ }),
+ ).rejects.toMatchObject({ code: "NOT_FOUND" });
+ expect(mocks.searchStations).not.toHaveBeenCalled();
+ });
+
+ it("blocks a changed private endpoint for an existing item", async () => {
+ const db = createDb();
+ const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "http://timetable.internal:8080",
+ isPublic: false,
+ });
+
+ await expect(
+ createCaller(db, createSession(ownerId)).searchStations({
+ baseUrl: "http://127.0.0.1:8080",
+ boardId,
+ itemId,
+ query: "Bern",
+ }),
+ ).rejects.toMatchObject({ code: "BAD_REQUEST" });
+ expect(mocks.searchStations).not.toHaveBeenCalled();
+ });
+
+ it("allows a changed public endpoint for an existing item and pins its address", async () => {
+ const db = createDb();
+ const { boardId, itemId, ownerId } = await createTimetableItemAsync(db, {
+ baseUrl: "http://timetable.internal:8080",
+ isPublic: false,
+ });
+ const baseUrl = "https://timetable.example.com";
+
+ await createCaller(db, createSession(ownerId)).searchStations({ baseUrl, boardId, itemId, query: "Bern" });
+
+ expect(mocks.searchStations).toHaveBeenLastCalledWith(
+ expect.objectContaining({ baseUrl, pinnedAddresses: [{ address: "8.8.8.8", family: 4 }] }),
+ );
+ });
+
+ it("reports malformed saved widget options as server data corruption", async () => {
+ const db = createDb();
+ const { itemId } = await createTimetableItemAsync(db, { baseUrl: "https://search.ch" });
+ await db.update(items).set({ options: "{" }).where(eq(items.id, itemId));
+
+ await expect(
+ createCaller(db, null).getTimetable({
+ baseUrl: "https://search.ch",
+ itemId,
+ stationId: "station",
+ limit: 10,
+ }),
+ ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
+ });
+
+ it("reports an invalid saved timetable URL as server data corruption", async () => {
+ const db = createDb();
+ const { itemId } = await createTimetableItemAsync(db, {
+ baseUrl: "https://search.ch?redirect=/admin",
+ });
+
+ await expect(
+ createCaller(db, null).getTimetable({
+ baseUrl: "https://search.ch",
+ itemId,
+ stationId: "station",
+ limit: 10,
+ }),
+ ).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Timetable widget configuration is invalid" });
});
it("does not treat public board view access as configuration access", async () => {
diff --git a/packages/api/src/router/widgets/firewall.ts b/packages/api/src/router/widgets/firewall.ts
index dbaa88b4f..fdeb77831 100644
--- a/packages/api/src/router/widgets/firewall.ts
+++ b/packages/api/src/router/widgets/firewall.ts
@@ -37,6 +37,7 @@ const queryFirewall = <
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
}),
+ throwOnAllFailures: true,
},
),
);
diff --git a/packages/api/src/router/widgets/immich.ts b/packages/api/src/router/widgets/immich.ts
index aa917271f..136d0e111 100644
--- a/packages/api/src/router/widgets/immich.ts
+++ b/packages/api/src/router/widgets/immich.ts
@@ -34,9 +34,16 @@ export const immichRouter = createTRPCRouter({
return data.data;
}),
- getAlbums: publicProcedure.concat(createImmichIntegrationMiddleware("query")).query(async ({ ctx }) => {
- const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, {});
- const data = await innerHandler.getDataAsync();
- return data.data;
- }),
+ getAlbums: publicProcedure
+ .input(
+ z.object({
+ limit: z.number().int().min(1).max(500).optional(),
+ }),
+ )
+ .concat(createImmichIntegrationMiddleware("query"))
+ .query(async ({ ctx, input }) => {
+ const innerHandler = immichAlbumsRequestHandler.handler(ctx.integration, { limit: input.limit });
+ const data = await innerHandler.getDataAsync();
+ return data.data;
+ }),
});
diff --git a/packages/api/src/router/widgets/rssFeed.ts b/packages/api/src/router/widgets/rssFeed.ts
index 2a37f3517..2b49e1ba0 100644
--- a/packages/api/src/router/widgets/rssFeed.ts
+++ b/packages/api/src/router/widgets/rssFeed.ts
@@ -1,3 +1,4 @@
+import { TRPCError } from "@trpc/server";
import SuperJSON from "superjson";
import { z } from "zod/v4";
@@ -14,6 +15,14 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
+const safeFeedLogContext = (url: string, feedIndex: number) => {
+ try {
+ return { feedIndex, origin: new URL(url).origin };
+ } catch {
+ return { feedIndex };
+ }
+};
+
const feedsInput = z.object({
urls: z.array(z.string()).max(100),
maximumAmountPosts: z.number(),
@@ -38,14 +47,24 @@ export const rssFeedRouter = createTRPCRouter({
);
const failures = settled.filter((result) => result.status === "rejected");
+ settled.forEach((result, index) => {
+ if (result.status === "rejected") {
+ logger.warn("RSS feed fetch failed", {
+ ...safeFeedLogContext(urls[index] ?? "", index),
+ errorType: result.reason instanceof Error ? result.reason.name : typeof result.reason,
+ });
+ }
+ });
if (settled.length > 0 && failures.length === settled.length) {
- throw failures[0]?.reason;
+ throw new TRPCError({
+ code: "BAD_GATEWAY",
+ message: "All RSS feed requests failed",
+ });
}
return settled
- .flatMap((result, index) => {
+ .flatMap((result) => {
if (result.status === "fulfilled") return result.value.data.entries;
- logger.warn("RSS feed fetch failed", { url: urls[index], error: result.reason });
return [];
})
.toSorted((entryA, entryB) => {
diff --git a/packages/api/src/router/widgets/timetable.ts b/packages/api/src/router/widgets/timetable.ts
index 84f680382..e92757fc0 100644
--- a/packages/api/src/router/widgets/timetable.ts
+++ b/packages/api/src/router/widgets/timetable.ts
@@ -1,3 +1,5 @@
+import { lookup } from "node:dns/promises";
+import { BlockList, isIP } from "node:net";
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import z from "zod";
@@ -6,6 +8,7 @@ import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
+import type { TimetableResolvedAddress } from "@homarr/request-handler/timetable";
import {
timetableGetTimetableRequestHandler,
timetableSearchStationsRequestHandler,
@@ -21,28 +24,184 @@ const timetableSourceSchema = z.object({
itemId: z.string().max(100).optional(),
boardId: z.string().max(100).optional(),
});
+const timetableOptionsSchema = z.object({ baseUrl: z.string().optional() }).passthrough();
+
+const blockedTimetableIpv4Addresses = new BlockList();
+const blockedTimetableIpv6Addresses = new BlockList();
+const blockedTimetableSubnets = [
+ ["0.0.0.0", 8, "ipv4"],
+ ["10.0.0.0", 8, "ipv4"],
+ ["100.64.0.0", 10, "ipv4"],
+ ["127.0.0.0", 8, "ipv4"],
+ ["169.254.0.0", 16, "ipv4"],
+ ["172.16.0.0", 12, "ipv4"],
+ ["192.0.0.0", 24, "ipv4"],
+ ["192.0.2.0", 24, "ipv4"],
+ ["192.168.0.0", 16, "ipv4"],
+ ["198.18.0.0", 15, "ipv4"],
+ ["198.51.100.0", 24, "ipv4"],
+ ["203.0.113.0", 24, "ipv4"],
+ ["224.0.0.0", 4, "ipv4"],
+ ["240.0.0.0", 4, "ipv4"],
+ ["::", 96, "ipv6"],
+ ["::ffff:0:0", 96, "ipv6"],
+ ["::ffff:0:0:0", 96, "ipv6"],
+ ["64:ff9b::", 96, "ipv6"],
+ ["64:ff9b:1::", 48, "ipv6"],
+ ["100::", 64, "ipv6"],
+ ["2001::", 32, "ipv6"],
+ ["2001:2::", 48, "ipv6"],
+ ["2001:10::", 28, "ipv6"],
+ ["2001:db8::", 32, "ipv6"],
+ ["2002::", 16, "ipv6"],
+ ["fc00::", 7, "ipv6"],
+ ["fe80::", 10, "ipv6"],
+ ["fec0::", 10, "ipv6"],
+ ["ff00::", 8, "ipv6"],
+] as const;
+
+for (const [network, prefix, family] of blockedTimetableSubnets) {
+ const blockList = family === "ipv4" ? blockedTimetableIpv4Addresses : blockedTimetableIpv6Addresses;
+ blockList.addSubnet(network, prefix, family);
+}
+
+const normalizeBaseUrlOrThrowBadRequest = (baseUrl: string) => {
+ try {
+ return normalizeTimetableBaseUrl(baseUrl);
+ } catch (cause) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: cause instanceof Error ? cause.message : "Invalid timetable base URL",
+ cause,
+ });
+ }
+};
+
+const throwInvalidSavedTimetableConfiguration = (cause: unknown): never => {
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "Timetable widget configuration is invalid",
+ cause,
+ });
+};
+
+const parseSavedTimetableOptions = (serializedOptions: string) => {
+ try {
+ return timetableOptionsSchema.parse(parse(serializedOptions));
+ } catch (cause) {
+ return throwInvalidSavedTimetableConfiguration(cause);
+ }
+};
+
+const normalizeSavedTimetableBaseUrl = (baseUrl: string) => {
+ try {
+ return normalizeTimetableBaseUrl(baseUrl);
+ } catch (cause) {
+ return throwInvalidSavedTimetableConfiguration(cause);
+ }
+};
+
+const normalizeHostname = (hostname: string) => {
+ const withoutBrackets = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
+ return withoutBrackets.toLowerCase().replace(/\.$/, "");
+};
+
+const isBlockedTimetableAddress = (address: string, family: number) => {
+ return family === 6
+ ? blockedTimetableIpv6Addresses.check(address, "ipv6")
+ : blockedTimetableIpv4Addresses.check(address, "ipv4");
+};
+
+const resolvePublicTimetableAddressesAsync = async (baseUrl: string): Promise => {
+ const hostname = normalizeHostname(new URL(baseUrl).hostname);
+ const hostnameFamily = isIP(hostname);
+ const isInternalHostname =
+ hostnameFamily === 0 &&
+ (!hostname.includes(".") ||
+ hostname === "localhost" ||
+ hostname.endsWith(".localhost") ||
+ hostname.endsWith(".local") ||
+ hostname.endsWith(".internal") ||
+ hostname.endsWith(".lan") ||
+ hostname.endsWith(".home.arpa"));
+
+ if (isInternalHostname) {
+ throw new TRPCError({ code: "BAD_REQUEST", message: "Timetable base URL must use a public host" });
+ }
+
+ let addresses: TimetableResolvedAddress[];
+ try {
+ const resolvedAddresses =
+ hostnameFamily === 0
+ ? await lookup(hostname, { all: true, verbatim: true })
+ : [{ address: hostname, family: hostnameFamily }];
+ addresses = resolvedAddresses.filter(
+ (entry): entry is TimetableResolvedAddress => entry.family === 4 || entry.family === 6,
+ );
+ } catch (cause) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Timetable base URL host could not be resolved",
+ cause,
+ });
+ }
+
+ if (addresses.length === 0 || addresses.some(({ address, family }) => isBlockedTimetableAddress(address, family))) {
+ throw new TRPCError({
+ code: "BAD_REQUEST",
+ message: "Timetable base URL must not target a private or internal address",
+ });
+ }
+
+ return addresses;
+};
+
+const getSavedTimetableBaseUrl = (serializedOptions: string) => {
+ const options = parseSavedTimetableOptions(serializedOptions);
+ return normalizeSavedTimetableBaseUrl(options.baseUrl ?? DEFAULT_TIMETABLE_BASE_URL);
+};
+
+interface ResolvedTimetableSource {
+ baseUrl: string;
+ pinnedAddresses?: TimetableResolvedAddress[];
+}
const resolveTimetableBaseUrlAsync = async (
ctx: { db: Database; session: Session | null },
input: z.infer,
allowBoardConfiguration = false,
-) => {
- const requestedBaseUrl = normalizeTimetableBaseUrl(input.baseUrl);
+): Promise => {
+ const requestedBaseUrl = normalizeBaseUrlOrThrowBadRequest(input.baseUrl);
if (allowBoardConfiguration) {
if (input.boardId) {
+ let savedOptions: string | undefined;
+ if (input.itemId) {
+ const item = await ctx.db.query.items.findFirst({
+ where: and(eq(items.id, input.itemId), eq(items.kind, "timetable"), eq(items.boardId, input.boardId)),
+ });
+ if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
+ savedOptions = item.options;
+ }
+
await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), "modify");
- return requestedBaseUrl;
+ const savedBaseUrl = savedOptions === undefined ? undefined : getSavedTimetableBaseUrl(savedOptions);
+ if (requestedBaseUrl === savedBaseUrl || requestedBaseUrl === DEFAULT_TIMETABLE_BASE_URL) {
+ return { baseUrl: requestedBaseUrl };
+ }
+
+ const pinnedAddresses = await resolvePublicTimetableAddressesAsync(requestedBaseUrl);
+ return { baseUrl: requestedBaseUrl, pinnedAddresses };
}
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "Board modify access is required for this endpoint" });
}
- return DEFAULT_TIMETABLE_BASE_URL;
+ return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
if (!input.itemId) {
if (requestedBaseUrl !== DEFAULT_TIMETABLE_BASE_URL) {
throw new TRPCError({ code: "FORBIDDEN", message: "A saved timetable widget is required for this endpoint" });
}
- return DEFAULT_TIMETABLE_BASE_URL;
+ return { baseUrl: DEFAULT_TIMETABLE_BASE_URL };
}
const item = await ctx.db.query.items.findFirst({
@@ -51,14 +210,11 @@ const resolveTimetableBaseUrlAsync = async (
if (!item) throw new TRPCError({ code: "NOT_FOUND", message: "Timetable widget not found" });
await throwIfActionForbiddenAsync(ctx, eq(boards.id, item.boardId), "view");
- const options = parse>(item.options);
- const savedBaseUrl = normalizeTimetableBaseUrl(
- typeof options.baseUrl === "string" ? options.baseUrl : DEFAULT_TIMETABLE_BASE_URL,
- );
+ const savedBaseUrl = getSavedTimetableBaseUrl(item.options);
if (requestedBaseUrl !== savedBaseUrl) {
throw new TRPCError({ code: "FORBIDDEN", message: "Timetable URL does not match the saved widget" });
}
- return savedBaseUrl;
+ return { baseUrl: savedBaseUrl };
};
export const timetableRouter = createTRPCRouter({
@@ -70,7 +226,7 @@ export const timetableRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
- const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input);
+ const { baseUrl } = await resolveTimetableBaseUrlAsync(ctx, input);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
const { data } = await timetableGetTimetableRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
return data;
@@ -82,9 +238,11 @@ export const timetableRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
- const baseUrl = await resolveTimetableBaseUrlAsync(ctx, input, true);
+ const { baseUrl, pinnedAddresses } = await resolveTimetableBaseUrlAsync(ctx, input, true);
const { itemId: _itemId, boardId: _boardId, ...handlerInput } = input;
- const { data } = await timetableSearchStationsRequestHandler.handler({ ...handlerInput, baseUrl }).getDataAsync();
+ const { data } = await timetableSearchStationsRequestHandler
+ .handler({ ...handlerInput, baseUrl, pinnedAddresses })
+ .getDataAsync();
return data;
}),
});
diff --git a/packages/api/src/settle-integrations.ts b/packages/api/src/settle-integrations.ts
index 773117877..ec16b955d 100644
--- a/packages/api/src/settle-integrations.ts
+++ b/packages/api/src/settle-integrations.ts
@@ -1,3 +1,5 @@
+import { TRPCError } from "@trpc/server";
+
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
@@ -50,7 +52,11 @@ export async function settleIntegrationQueries 0 &&
(results.length === 0 || (options?.throwOnAllFailures === true && errors.length === integrations.length))
) {
- throw errors[0];
+ throw new TRPCError({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "All integration queries failed",
+ cause: errors[0],
+ });
}
return results;
diff --git a/packages/api/src/test/settle-integrations.spec.ts b/packages/api/src/test/settle-integrations.spec.ts
index 70024da7f..372c2889e 100644
--- a/packages/api/src/test/settle-integrations.spec.ts
+++ b/packages/api/src/test/settle-integrations.spec.ts
@@ -1,4 +1,4 @@
-import { describe, expect, test, vi } from "vitest";
+import { beforeEach, describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
@@ -19,6 +19,8 @@ const integrations = [
{ id: "second", name: "Second", kind: "jellyfin" },
];
+beforeEach(() => vi.clearAllMocks());
+
describe("settleIntegrationQueries", () => {
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
@@ -35,9 +37,14 @@ describe("settleIntegrationQueries", () => {
});
test("rejects when every integration fails", async () => {
- const error = new Error("Integration unavailable");
+ const error = new Error("Integration unavailable with token=secret");
- await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toBe(error);
+ await expect(settleIntegrationQueries(integrations, () => Promise.reject(error))).rejects.toMatchObject({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "All integration queries failed",
+ cause: error,
+ });
+ expect(mocks.logger.warn).toHaveBeenCalledTimes(integrations.length);
});
test("uses configured fallbacks when every integration fails", async () => {
@@ -62,6 +69,10 @@ describe("settleIntegrationQueries", () => {
options,
),
).resolves.toEqual(["First", "Second"]);
- await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toBe(error);
+ await expect(settleIntegrationQueries(integrations, () => Promise.reject(error), options)).rejects.toMatchObject({
+ code: "INTERNAL_SERVER_ERROR",
+ message: "All integration queries failed",
+ cause: error,
+ });
});
});
diff --git a/packages/core/src/infrastructure/http/request.ts b/packages/core/src/infrastructure/http/request.ts
index b3c3ea354..ab0fdaf2f 100644
--- a/packages/core/src/infrastructure/http/request.ts
+++ b/packages/core/src/infrastructure/http/request.ts
@@ -1,8 +1,9 @@
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
+import type { LookupFunction } from "node:net";
import { checkServerIdentity } from "node:tls";
import axios from "axios";
-import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
+import type { EnvHttpProxyAgent as UndiciEnvHttpProxyAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
import {
@@ -30,17 +31,25 @@ export const createCustomCheckServerIdentity = (
};
export const createCertificateAgentAsync = async (
- override?: {
+ override?: Partial<{
ca: string | string[];
checkServerIdentity: typeof checkServerIdentity;
- },
- agentOptions?: Pick,
+ lookup: LookupFunction;
+ }>,
+ agentOptions?: Pick<
+ UndiciEnvHttpProxyAgent.Options,
+ "autoSelectFamily" | "bodyTimeout" | "httpProxy" | "httpsProxy" | "noProxy"
+ >,
) => {
+ const ca = override?.ca ?? (await getAllTrustedCertificatesAsync());
+ const identityCheck =
+ override?.checkServerIdentity ?? createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync());
return new UndiciHttpAgent({
...agentOptions,
- connect: override ?? {
- ca: await getAllTrustedCertificatesAsync(),
- checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
+ connect: {
+ ca,
+ checkServerIdentity: identityCheck,
+ ...(override?.lookup ? { lookup: override.lookup } : {}),
},
});
};
diff --git a/packages/integrations/src/overseerr/overseerr-integration.spec.ts b/packages/integrations/src/overseerr/overseerr-integration.spec.ts
index 1fc4484d0..2f021f608 100644
--- a/packages/integrations/src/overseerr/overseerr-integration.spec.ts
+++ b/packages/integrations/src/overseerr/overseerr-integration.spec.ts
@@ -25,6 +25,15 @@ describe("Overseerr media request actions", () => {
])("rejects %s when Overseerr returns a non-success response", async (_action, run) => {
mockFetch.mockResolvedValue(new Response(null, { status: 500 }));
- await expect(run()).rejects.toThrow("Response from integration did not indicate success");
+ await expect(run()).rejects.toMatchObject({
+ name: "IntegrationResponseError",
+ message: "Response from integration did not indicate success",
+ cause: {
+ name: "ResponseError",
+ message: "Response did not indicate success",
+ statusCode: 500,
+ },
+ });
+ expect(mockFetch).toHaveBeenCalledOnce();
});
});
diff --git a/packages/request-handler/src/immich.spec.ts b/packages/request-handler/src/immich.spec.ts
new file mode 100644
index 000000000..a8739587f
--- /dev/null
+++ b/packages/request-handler/src/immich.spec.ts
@@ -0,0 +1,50 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import { immichAlbumsRequestHandler } from "./immich";
+
+const mocks = vi.hoisted(() => ({
+ createIntegrationAsync: vi.fn(),
+}));
+
+vi.mock("@homarr/integrations", () => ({
+ createIntegrationAsync: mocks.createIntegrationAsync,
+}));
+
+const integration = {
+ id: "immich-integration",
+ kind: "immich",
+} as Parameters[0];
+
+const albums = [
+ { id: "upstream-first", albumName: "Zebra", assetCount: 1 },
+ { id: "second-b", albumName: "Beta", assetCount: 10 },
+ { id: "second-a", albumName: "Alpha", assetCount: 10 },
+];
+
+describe("immichAlbumsRequestHandler", () => {
+ beforeEach(() => {
+ immichAlbumsRequestHandler.invalidateCache();
+ mocks.createIntegrationAsync.mockReset();
+ });
+
+ test("sorts deterministically before caching a limited response", async () => {
+ const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
+ mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
+
+ const first = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
+ const cached = await immichAlbumsRequestHandler.handler(integration, { limit: 2 }).getDataAsync();
+
+ expect(first.data.map(({ id }) => id)).toStrictEqual(["second-a", "second-b"]);
+ expect(cached.data).toStrictEqual(first.data);
+ expect(getAlbumsAsync).toHaveBeenCalledOnce();
+ });
+
+ test("preserves upstream order when no limit is requested", async () => {
+ const getAlbumsAsync = vi.fn().mockResolvedValue(albums);
+ mocks.createIntegrationAsync.mockResolvedValue({ getAlbumsAsync });
+
+ const result = await immichAlbumsRequestHandler.handler(integration, {}).getDataAsync();
+
+ expect(result.data).toStrictEqual(albums);
+ });
+});
diff --git a/packages/request-handler/src/immich.ts b/packages/request-handler/src/immich.ts
index a6e2a996c..bc93eb83c 100644
--- a/packages/request-handler/src/immich.ts
+++ b/packages/request-handler/src/immich.ts
@@ -22,14 +22,27 @@ export const immichAlbumsRequestHandler = createIntegrationRequestHandler<
assetCount: number;
}[],
IntegrationKindByCategory<"photoService">,
- Record
+ { limit?: number }
>({
- async requestAsync(integration) {
+ async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
- return await integrationInstance.getAlbumsAsync();
+ const albums = await integrationInstance.getAlbumsAsync();
+
+ if (input.limit === undefined) return albums;
+
+ return albums
+ .toSorted(
+ (left, right) =>
+ right.assetCount - left.assetCount ||
+ compareText(left.albumName, right.albumName) ||
+ compareText(left.id, right.id),
+ )
+ .slice(0, input.limit);
},
});
+const compareText = (left: string, right: string) => (left < right ? -1 : left > right ? 1 : 0);
+
export const immichAlbumRequestHandler = createIntegrationRequestHandler<
ImmichAlbum,
IntegrationKindByCategory<"photoService">,
diff --git a/packages/request-handler/src/timetable.spec.ts b/packages/request-handler/src/timetable.spec.ts
index cb08cb313..b0185b775 100644
--- a/packages/request-handler/src/timetable.spec.ts
+++ b/packages/request-handler/src/timetable.spec.ts
@@ -1,16 +1,25 @@
import { Response } from "undici";
import { beforeEach, describe, expect, it, vi } from "vitest";
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { timetableGetTimetableRequestHandler, timetableSearchStationsRequestHandler } from "./timetable";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
-vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
+vi.mock("@homarr/core/infrastructure/http", () => ({
+ createCertificateAgentAsync: vi.fn(),
+ fetchWithTrustedCertificatesAsync: vi.fn(),
+}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
+const mockCreateCertificateAgent = vi.mocked(createCertificateAgentAsync);
+const mockCloseDispatcher = vi.fn();
+const mockDispatcher = { close: mockCloseDispatcher };
-beforeEach(() => vi.clearAllMocks());
+beforeEach(() => {
+ vi.clearAllMocks();
+ mockCreateCertificateAgent.mockResolvedValue(mockDispatcher as never);
+});
describe("normalizeTimetableBaseUrl", () => {
it("normalizes an HTTP endpoint", () => {
@@ -55,6 +64,58 @@ describe("timetable response bounds", () => {
);
});
+ it("pins the vetted address into a direct connection without changing the request hostname", async () => {
+ mockFetch.mockResolvedValue(
+ new Response(JSON.stringify([{ id: "station", label: "Station", iconclass: "train" }])),
+ );
+ const pinnedAddresses = [
+ { address: "93.184.216.34", family: 4 as const },
+ { address: "2606:2800:220:1:248:1893:25c8:1946", family: 6 as const },
+ ];
+
+ await timetableSearchStationsRequestHandler
+ .handler({
+ baseUrl: "https://timetable.example.com",
+ query: "pinned-stations",
+ pinnedAddresses,
+ })
+ .getDataAsync();
+
+ expect(mockCreateCertificateAgent).toHaveBeenCalledWith(
+ { lookup: expect.any(Function) },
+ {
+ autoSelectFamily: true,
+ bodyTimeout: 10_000,
+ httpProxy: "",
+ httpsProxy: "",
+ noProxy: "*",
+ },
+ );
+ const requestUrl = mockFetch.mock.calls[0]?.[0];
+ expect(new URL(String(requestUrl)).hostname).toBe("timetable.example.com");
+ expect(mockFetch).toHaveBeenCalledWith(
+ expect.any(String),
+ expect.objectContaining({ dispatcher: mockDispatcher, redirect: "error" }),
+ );
+
+ const pinnedLookup = mockCreateCertificateAgent.mock.calls[0]?.[0]?.lookup;
+ expect(pinnedLookup).toBeTypeOf("function");
+ if (!pinnedLookup) throw new Error("Expected a pinned lookup function");
+
+ const lookupCallback = vi.fn();
+ pinnedLookup("timetable.example.com", { all: true }, lookupCallback);
+ expect(lookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
+
+ const repeatedLookupCallback = vi.fn();
+ pinnedLookup("timetable.example.com", { all: true }, repeatedLookupCallback);
+ expect(repeatedLookupCallback).toHaveBeenCalledWith(null, pinnedAddresses);
+
+ const unexpectedHostCallback = vi.fn();
+ pinnedLookup("different.example.com", { all: true }, unexpectedHostCallback);
+ expect(unexpectedHostCallback.mock.calls[0]?.[0]).toMatchObject({ code: "ENOTFOUND" });
+ expect(mockCloseDispatcher).toHaveBeenCalledOnce();
+ });
+
it("never caches more departures than requested", async () => {
mockFetch.mockResolvedValue(
new Response(
diff --git a/packages/request-handler/src/timetable.ts b/packages/request-handler/src/timetable.ts
index b55a688f7..e8144d435 100644
--- a/packages/request-handler/src/timetable.ts
+++ b/packages/request-handler/src/timetable.ts
@@ -1,7 +1,8 @@
+import type { LookupFunction } from "node:net";
import { z } from "zod/v4";
import { ResponseError } from "@homarr/common/server";
-import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
+import { createCertificateAgentAsync, fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createRequestHandler } from "./lib/request-handler";
import { normalizeTimetableBaseUrl, readBoundedTimetableJsonAsync } from "./timetable-url";
@@ -36,12 +37,17 @@ const MAX_STATION_RESULTS = 100;
const MAX_TIMETABLE_ENTRIES = 100;
const timetableFetchOptions = { redirect: "error", timeout: 10_000, bodyTimeout: 10_000 } as const;
+export interface TimetableResolvedAddress {
+ address: string;
+ family: 4 | 6;
+}
+
export const timetableSearchStationsRequestHandler = createRequestHandler<
Station[],
- { baseUrl: string; query: string }
+ { baseUrl: string; query: string; pinnedAddresses?: TimetableResolvedAddress[] }
>({
async requestAsync(input) {
- return await searchStationsAsync(input.baseUrl, input.query);
+ return await searchStationsAsync(input.baseUrl, input.query, input.pinnedAddresses);
},
cacheTtlMs: 24 * 60 * 60 * 1000,
});
@@ -64,19 +70,73 @@ const buildUrl = (baseUrl: string, path: `/${string}`, queryParams: Record => {
- const response = await fetchWithTrustedCertificatesAsync(
- buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
- timetableFetchOptions,
- );
- if (!response.ok) throw new ResponseError(response);
+const normalizeLookupHostname = (hostname: string) =>
+ hostname
+ .replace(/^\[(.*)\]$/, "$1")
+ .toLowerCase()
+ .replace(/\.$/, "");
- const body = await readBoundedTimetableJsonAsync(response);
- const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
- return data
- .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
- .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
- .filter((item) => item !== null);
+const createPinnedLookup = (baseUrl: string, addresses: TimetableResolvedAddress[]): LookupFunction => {
+ const expectedHostname = normalizeLookupHostname(new URL(baseUrl).hostname);
+
+ return (hostname, options, callback) => {
+ const requestedFamily = options.family === 4 || options.family === 6 ? options.family : undefined;
+ const candidates = requestedFamily ? addresses.filter(({ family }) => family === requestedFamily) : addresses;
+ if (normalizeLookupHostname(hostname) !== expectedHostname || candidates.length === 0) {
+ const error = new Error("Pinned timetable address is unavailable") as NodeJS.ErrnoException;
+ error.code = "ENOTFOUND";
+ callback(error, []);
+ return;
+ }
+
+ if (options.all) {
+ callback(null, candidates);
+ return;
+ }
+
+ const selected = candidates[0];
+ if (!selected) return callback(new Error("Pinned timetable address is unavailable"), []);
+ callback(null, selected.address, selected.family);
+ };
+};
+
+const createPinnedTimetableDispatcherAsync = async (baseUrl: string, addresses: TimetableResolvedAddress[]) => {
+ // Proxies resolve CONNECT targets independently, which would defeat DNS pinning. Force this security-sensitive
+ // configuration request to connect directly. The original URL remains unchanged for the Host header and TLS SNI.
+ return await createCertificateAgentAsync(
+ { lookup: createPinnedLookup(baseUrl, addresses) },
+ {
+ autoSelectFamily: addresses.length > 1,
+ bodyTimeout: timetableFetchOptions.bodyTimeout,
+ httpProxy: "",
+ httpsProxy: "",
+ noProxy: "*",
+ },
+ );
+};
+
+const searchStationsAsync = async (
+ baseUrl: string,
+ query: string,
+ pinnedAddresses?: TimetableResolvedAddress[],
+): Promise => {
+ const dispatcher = pinnedAddresses ? await createPinnedTimetableDispatcherAsync(baseUrl, pinnedAddresses) : undefined;
+ try {
+ const response = await fetchWithTrustedCertificatesAsync(
+ buildUrl(baseUrl, "/timetable/api/completion.json", { term: query, show_ids: 1, nofavorites: 1 }),
+ { ...timetableFetchOptions, dispatcher },
+ );
+ if (!response.ok) throw new ResponseError(response);
+
+ const body = await readBoundedTimetableJsonAsync(response);
+ const data = await searchSchema.parseAsync(Array.isArray(body) ? body.slice(0, MAX_STATION_RESULTS) : body);
+ return data
+ .filter((item) => supportedStationTypes.some((type) => item.iconclass.endsWith(type)))
+ .map((item) => (item.id !== undefined ? { id: item.id, name: item.label } : null))
+ .filter((item) => item !== null);
+ } finally {
+ await dispatcher?.close();
+ }
};
const getTimetableAsync = async (
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index 6b0676b86..b207e11bd 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2782,10 +2782,6 @@
"running": "Running automation",
"success": "Automation completed"
},
- "status": {
- "running": "Running automation",
- "success": "Automation completed"
- },
"error": {
"executeFailed": "Unable to run automation"
}
diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx
index aa3dad356..2e8b192ff 100644
--- a/packages/widgets/src/app/component.tsx
+++ b/packages/widgets/src/app/component.tsx
@@ -69,7 +69,7 @@ export default function AppWidget({ options, isEditMode, height, width, displayM
))}
position="right-start"
multiline
- disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
+ disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode || isAdvanced}
styles={{ tooltip: { maxWidth: 300 } }}
>
({
diff --git a/packages/widgets/src/archive-team-warrior/component.tsx b/packages/widgets/src/archive-team-warrior/component.tsx
index 50b213257..5dff480dd 100644
--- a/packages/widgets/src/archive-team-warrior/component.tsx
+++ b/packages/widgets/src/archive-team-warrior/component.tsx
@@ -5,7 +5,7 @@ import { Avatar, Badge, Card, Group, ScrollArea, SimpleGrid, Stack, Text } from
import { clientApi } from "@homarr/api/client";
import { formatByteRate } from "@homarr/common";
import { getIconUrl } from "@homarr/definitions";
-import { useScopedI18n } from "@homarr/translation/client";
+import { useCurrentLocale, useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
@@ -47,6 +47,7 @@ const ArchiveTeamWarriorWidgetContent = ({
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.archiveTeamWarrior");
+ const locale = useCurrentLocale();
const [data] = clientApi.widget.archiveTeamWarrior.getStatus.useSuspenseQuery({ integrationId });
const status = data.status;
@@ -96,7 +97,7 @@ const ArchiveTeamWarriorWidgetContent = ({
{t("runnerStatus", { status: t(`status.${getStatusKey(status.runnerStatus ?? status.status)}`) })}
- {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString() })}
+ {t("updatedAt", { date: new Date(status.updatedAt).toLocaleString(locale) })}
diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx
index d31aca8fe..ad246613f 100644
--- a/packages/widgets/src/audio-stats/component.tsx
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -8,6 +8,7 @@ import type { AudiobookshelfDashboardData } from "@homarr/integrations/types";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
+import { setWidgetRuntimeQueries } from "../definition";
import { AudioStatsContent } from "./audio-stats-content";
export default function AudioStatsWidget({
@@ -16,14 +17,19 @@ export default function AudioStatsWidget({
width,
height,
displayMode = "compact",
+ widgetStateRef,
}: WidgetComponentProps<"audioStats">) {
- const { data: response } = clientApi.widget.audioStats.getStats.useQuery({
- integrationId: integrationIds[0] ?? "",
+ const statsInput = { integrationId: integrationIds[0] ?? "" };
+ const { data: response } = clientApi.widget.audioStats.getStats.useQuery(statsInput);
+ const streamsInput = { integrationIds, showOnlyPlaying: false };
+ const streamsEnabled = displayMode === "advanced" && response?.kind === "navidrome";
+ const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(streamsInput, {
+ enabled: streamsEnabled,
});
- const { data: streamResults = [] } = clientApi.widget.mediaServer.getCurrentStreams.useQuery(
- { integrationIds, showOnlyPlaying: false },
- { enabled: displayMode === "advanced" && response?.kind === "navidrome" },
- );
+ setWidgetRuntimeQueries(widgetStateRef, [
+ { path: ["widget", "audioStats", "getStats"], input: statsInput },
+ ...(streamsEnabled ? [{ path: ["widget", "mediaServer", "getCurrentStreams"], input: streamsInput }] : []),
+ ]);
if (!response) return ;
diff --git a/packages/widgets/src/audio-stats/index.ts b/packages/widgets/src/audio-stats/index.ts
index c5783fb89..a21e70278 100644
--- a/packages/widgets/src/audio-stats/index.ts
+++ b/packages/widgets/src/audio-stats/index.ts
@@ -1,6 +1,6 @@
import { IconHeadphones, IconServerOff } from "@tabler/icons-react";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const hideUnlessNavidrome = {
@@ -13,7 +13,8 @@ const hideUnlessAudiobookshelf = {
export const { definition, componentLoader } = createWidgetDefinition("audioStats", {
icon: IconHeadphones,
- queryKeys: [[["widget", "audioStats"]], [["widget", "mediaServer"]]],
+ queryKeys: [[["widget", "audioStats", "getStats"]], [["widget", "mediaServer", "getCurrentStreams"]]],
+ queryMatcher: matchesWidgetRuntimeQuery,
supportedIntegrations: ["navidrome", "audiobookshelf"],
integrationsRequired: true,
maxIntegrations: 1,
diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts
index f7dd6dee7..f5fdc9d01 100644
--- a/packages/widgets/src/beszel-alerts/index.ts
+++ b/packages/widgets/src/beszel-alerts/index.ts
@@ -1,12 +1,18 @@
import { IconBell, IconServerOff } from "@tabler/icons-react";
import { z } from "zod/v4";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", {
icon: IconBell,
- queryKey: [["widget", "beszel"]],
+ queryKey: [["widget", "beszel", "getAlerts"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ integrationIds: scope.integrationIds,
+ includeHistory: scope.options.showHistory,
+ maxHistoryItems: scope.options.maxHistoryItems,
+ }),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx
index 8359b2353..9754622c4 100644
--- a/packages/widgets/src/beszel-system-grid/component.tsx
+++ b/packages/widgets/src/beszel-system-grid/component.tsx
@@ -430,7 +430,9 @@ export default function BeszelSystemGridWidget({
const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight;
const cellWidth = width / cols;
const size = getSizeConfig(cellWidth, effectiveCellHeight);
- const maxMetrics = advanced ? metricRenderers.length : getMaxVisibleMetrics(effectiveCellHeight, size);
+ const maxMetrics = advanced
+ ? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
+ : getMaxVisibleMetrics(effectiveCellHeight, size);
return (
diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts
index 69dc7e2ba..9a5444e28 100644
--- a/packages/widgets/src/beszel-system-grid/index.ts
+++ b/packages/widgets/src/beszel-system-grid/index.ts
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", {
icon: IconLayoutGrid,
- queryKey: [["widget", "beszel"]],
+ queryKey: [["widget", "beszel", "getSystems"]],
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx
index 1b964c409..83cb8b92a 100644
--- a/packages/widgets/src/beszel-system-stats/component.tsx
+++ b/packages/widgets/src/beszel-system-stats/component.tsx
@@ -13,6 +13,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
+import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
@@ -28,6 +29,7 @@ export default function BeszelSystemStatsWidget({
boardId,
itemId,
setOptions,
+ widgetStateRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -45,6 +47,25 @@ export default function BeszelSystemStatsWidget({
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
+ const includeDocker =
+ (displayMode === "advanced" || height >= 380) &&
+ (options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
+ setWidgetRuntimeQueries(
+ widgetStateRef,
+ selectedSystem && options.timePeriod !== "1m"
+ ? [
+ {
+ path: ["widget", "beszel", "getSystemStats"],
+ input: {
+ integrationIds: [selectedSystem.integrationId],
+ systemId: selectedSystem.systemId,
+ timePeriod: options.timePeriod,
+ includeDocker,
+ },
+ },
+ ]
+ : [],
+ );
const handleSelectSystem = useCallback(
(value: string) => {
@@ -178,7 +199,7 @@ export default function BeszelSystemStatsWidget({
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
}}
- onSwitchToHistorical={() => setOptions({ newOptions: { timePeriod: "1h" } })}
+ onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
diff --git a/packages/widgets/src/beszel-system-stats/index.ts b/packages/widgets/src/beszel-system-stats/index.ts
index 3a885f563..71a696750 100644
--- a/packages/widgets/src/beszel-system-stats/index.ts
+++ b/packages/widgets/src/beszel-system-stats/index.ts
@@ -2,9 +2,9 @@ import { IconChartAreaLine, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
-import { createBeszelSystemChoices } from "./selection";
+import { createBeszelSystemChoices, resolveStoredBeszelQuerySelection } from "./selection";
const timePeriodOptions = [
{ value: "1m", label: "Live" },
@@ -17,7 +17,29 @@ const timePeriodOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
icon: IconChartAreaLine,
- queryKey: [["widget", "beszel"]],
+ queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
+ queryMatcher(query, scope) {
+ if (query.path.at(-1) === "getSystems") {
+ return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds });
+ }
+
+ const hasRuntimeStatsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getSystemStats");
+ if (hasRuntimeStatsQuery) return matchesWidgetRuntimeQuery(query, scope);
+
+ const selection = resolveStoredBeszelQuerySelection(String(scope.options.systemId ?? ""), scope.integrationIds);
+ if (!selection) return false;
+ const dockerEnabled = ["showDockerCpu", "showDockerMemory", "showDockerNetwork"].some(
+ (key) => scope.options[key] === true,
+ );
+ const expected = {
+ ...selection,
+ timePeriod: scope.options.timePeriod,
+ };
+ return (
+ widgetQueryInputMatches(query.input, { ...expected, includeDocker: false }) ||
+ (dockerEnabled && widgetQueryInputMatches(query.input, { ...expected, includeDocker: true }))
+ );
+ },
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/beszel-system-stats/selection.spec.ts b/packages/widgets/src/beszel-system-stats/selection.spec.ts
index 8280761ad..cc237e947 100644
--- a/packages/widgets/src/beszel-system-stats/selection.spec.ts
+++ b/packages/widgets/src/beszel-system-stats/selection.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
+import { createBeszelSystemChoices, resolveBeszelSystemChoice, resolveStoredBeszelQuerySelection } from "./selection";
const choices = createBeszelSystemChoices([
{ integrationId: "one", integrationName: "Primary", systems: [{ id: "same", name: "Host A" }] },
@@ -13,7 +13,34 @@ describe("Beszel system selection", () => {
expect(resolveBeszelSystemChoice(choices, "two:same")?.integrationId).toBe("two");
});
- it("keeps legacy plain system ids working", () => {
- expect(resolveBeszelSystemChoice(choices, "same")?.integrationId).toBe("one");
+ it("keeps unique legacy plain system ids working", () => {
+ expect(resolveBeszelSystemChoice(choices.slice(0, 1), "same")?.integrationId).toBe("one");
+ });
+
+ it("defaults an empty selection to the first system", () => {
+ expect(resolveBeszelSystemChoice(choices, "")?.integrationId).toBe("one");
+ });
+
+ it("does not guess when a legacy plain system id is ambiguous", () => {
+ expect(resolveBeszelSystemChoice(choices, "same")).toBeUndefined();
+ });
+
+ it("does not silently replace an unresolved stored system", () => {
+ expect(resolveBeszelSystemChoice(choices, "missing")).toBeUndefined();
+ });
+
+ it("derives the queried integration and system from a composite selection", () => {
+ expect(resolveStoredBeszelQuerySelection("two:same", ["one", "two"])).toEqual({
+ integrationIds: ["two"],
+ systemId: "same",
+ });
+ });
+
+ it("supports a legacy plain system id only when the integration is unambiguous", () => {
+ expect(resolveStoredBeszelQuerySelection("same", ["one"])).toEqual({
+ integrationIds: ["one"],
+ systemId: "same",
+ });
+ expect(resolveStoredBeszelQuerySelection("same", ["one", "two"])).toBeNull();
});
});
diff --git a/packages/widgets/src/beszel-system-stats/selection.ts b/packages/widgets/src/beszel-system-stats/selection.ts
index bd7445704..d48ffa45e 100644
--- a/packages/widgets/src/beszel-system-stats/selection.ts
+++ b/packages/widgets/src/beszel-system-stats/selection.ts
@@ -24,9 +24,25 @@ export function createBeszelSystemChoices(results: BeszelSystemsResult[]): Besze
}
export function resolveBeszelSystemChoice(choices: BeszelSystemChoice[], storedValue: string) {
- return (
- choices.find((choice) => choice.value === storedValue) ??
- choices.find((choice) => choice.systemId === storedValue) ??
- choices[0]
- );
+ const exactMatch = choices.find((choice) => choice.value === storedValue);
+ if (exactMatch) return exactMatch;
+ if (storedValue === "") return choices[0];
+
+ const legacyMatches = choices.filter((choice) => choice.systemId === storedValue);
+ return legacyMatches.length === 1 ? legacyMatches[0] : undefined;
+}
+
+export function resolveStoredBeszelQuerySelection(storedValue: string, integrationIds: readonly string[]) {
+ for (const integrationId of integrationIds) {
+ const prefix = `${integrationId}:`;
+ if (storedValue.startsWith(prefix)) {
+ return { integrationIds: [integrationId], systemId: storedValue.slice(prefix.length) };
+ }
+ }
+
+ if (storedValue !== "" && integrationIds.length === 1) {
+ return { integrationIds: [integrationIds[0] as string], systemId: storedValue };
+ }
+
+ return null;
}
diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts
index e31bbc597..76fb0264f 100644
--- a/packages/widgets/src/beszel-system-table/index.ts
+++ b/packages/widgets/src/beszel-system-table/index.ts
@@ -1,6 +1,6 @@
import { IconServerOff, IconTable } from "@tabler/icons-react";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition";
import { statusOptions } from "../beszel/_shared/options";
import { optionsBuilder } from "../options";
@@ -25,7 +25,11 @@ const sortDirectionOptions = [
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", {
icon: IconTable,
- queryKey: [["widget", "beszel"]],
+ queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
+ queryMatcher: (query, scope) =>
+ query.path.at(-1) === "getSystems"
+ ? widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds })
+ : matchesWidgetRuntimeQuery(query, scope),
supportedIntegrations: ["beszel", "mock"],
integrationsRequired: true,
createOptions() {
diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx
index 72cb062a0..4771fc96e 100644
--- a/packages/widgets/src/calendar/component.tsx
+++ b/packages/widgets/src/calendar/component.tsx
@@ -13,6 +13,7 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useSettings } from "@homarr/settings";
import type { WidgetComponentProps } from "../definition";
+import { setWidgetRuntimeQueries } from "../definition";
import { CalendarDay } from "./calender-day";
import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events";
import { CalendarEventList } from "./calendar-event-list";
@@ -22,6 +23,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
const [month, setMonth] = useState(() => new Date());
if (props.integrationIds.length === 0) {
+ setWidgetRuntimeQueries(props.widgetStateRef, []);
return ;
}
@@ -33,7 +35,15 @@ interface FetchCalendarProps extends WidgetComponentProps<"calendar"> {
setMonth: (date: Date) => void;
}
-const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, displayMode }: FetchCalendarProps) => {
+const FetchCalendar = ({
+ month,
+ setMonth,
+ isEditMode,
+ integrationIds,
+ options,
+ displayMode,
+ widgetStateRef,
+}: FetchCalendarProps) => {
const input = {
integrationIds,
month: month.getMonth(),
@@ -41,6 +51,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d
releaseType: options.releaseType,
showUnmonitored: options.showUnmonitored,
};
+ setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "calendar", "findAllEvents"], input }]);
const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input);
const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]);
@@ -75,6 +86,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
const actualItemRadius = mantineTheme.radius[board.itemRadius];
const { ref, width, height } = useElementSize();
const isSmall = width < 256;
+ const calendarHeight = displayMode === "advanced" ? height * 0.62 : height;
const normalizedEvents = useMemo(() => splitEvents(events), [events]);
const visibleEvents = useMemo(
@@ -159,7 +171,7 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod
events={eventsForDate}
disabled={isEditMode || eventsForDate.length === 0}
rootWidth={width}
- rootHeight={height}
+ rootHeight={calendarHeight}
/>
);
}}
diff --git a/packages/widgets/src/calendar/index.ts b/packages/widgets/src/calendar/index.ts
index d03b2f610..270981f5d 100644
--- a/packages/widgets/src/calendar/index.ts
+++ b/packages/widgets/src/calendar/index.ts
@@ -4,11 +4,13 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { radarrReleaseTypes } from "@homarr/integrations/types";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("calendar", {
icon: IconCalendar,
+ queryKey: [["widget", "calendar", "findAllEvents"]],
+ queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
releaseType: factory.multiSelect({
diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts
index 3fe152b73..e7657b918 100644
--- a/packages/widgets/src/clock/index.ts
+++ b/packages/widgets/src/clock/index.ts
@@ -1,12 +1,28 @@
import { IconClock } from "@tabler/icons-react";
import dayjs from "dayjs";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("clock", {
icon: IconClock,
- queryKey: [["widget", "weather"]],
+ queryKey: [["widget", "weather", "atLocation"]],
+ queryMatcher: ({ input }, scope) => {
+ const location = scope.options.weatherLocation;
+ if (
+ scope.options.showWeather !== true ||
+ location === null ||
+ typeof location !== "object" ||
+ !("latitude" in location) ||
+ !("longitude" in location)
+ ) {
+ return false;
+ }
+ return widgetQueryInputMatches(input, {
+ latitude: location.latitude,
+ longitude: location.longitude,
+ });
+ },
createOptions() {
return optionsBuilder.from(
(factory) => ({
diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx
index 2cc694f9c..73faec197 100644
--- a/packages/widgets/src/coolify/instance-card.tsx
+++ b/packages/widgets/src/coolify/instance-card.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Badge, Card, Group, Image, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -33,12 +33,27 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, isAdvanced
key: `coolify-sections-${cardKey}`,
defaultValue: ["applications"],
});
- const visibleSections = [
- options.showServers ? "servers" : null,
- options.showApplications ? "applications" : null,
- options.showServices ? "services" : null,
- ].filter((section): section is string => section !== null);
+ const visibleSections = useMemo(
+ () =>
+ [
+ options.showServers ? "servers" : null,
+ options.showApplications ? "applications" : null,
+ options.showServices ? "services" : null,
+ ].filter((section): section is string => section !== null),
+ [options.showApplications, options.showServers, options.showServices],
+ );
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
+ const previousVisibleSectionsRef = useRef(visibleSections);
+
+ useEffect(() => {
+ const newlyVisibleSections = visibleSections.filter(
+ (section) => !previousVisibleSectionsRef.current.includes(section),
+ );
+ previousVisibleSectionsRef.current = visibleSections;
+ if (newlyVisibleSections.length === 0) return;
+
+ setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
+ }, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx
index 4d05b71d6..2360f8f59 100644
--- a/packages/widgets/src/coolify/single-instance-layout.tsx
+++ b/packages/widgets/src/coolify/single-instance-layout.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useMemo, useRef, useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -32,12 +32,27 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, isA
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
- const visibleSections = [
- options.showServers ? "servers" : null,
- options.showApplications ? "applications" : null,
- options.showServices ? "services" : null,
- ].filter((section): section is string => section !== null);
+ const visibleSections = useMemo(
+ () =>
+ [
+ options.showServers ? "servers" : null,
+ options.showApplications ? "applications" : null,
+ options.showServices ? "services" : null,
+ ].filter((section): section is string => section !== null),
+ [options.showApplications, options.showServers, options.showServices],
+ );
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
+ const previousVisibleSectionsRef = useRef(visibleSections);
+
+ useEffect(() => {
+ const newlyVisibleSections = visibleSections.filter(
+ (section) => !previousVisibleSectionsRef.current.includes(section),
+ );
+ previousVisibleSectionsRef.current = visibleSections;
+ if (newlyVisibleSections.length === 0) return;
+
+ setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
+ }, [visibleSections]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts
index fddcae2fc..8d0ef73ef 100644
--- a/packages/widgets/src/custom-api/index.ts
+++ b/packages/widgets/src/custom-api/index.ts
@@ -1,11 +1,13 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { z } from "zod/v4";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
+ queryKey: [["widget", "customApi", "getData"]],
+ queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
contextActions({ options, widgetStateRef }) {
return [
{
@@ -13,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("customApi
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
- disabled: typeof widgetStateRef.current?.togglePolling !== "function",
+ disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
onClick: () => {
- const action = widgetStateRef.current?.togglePolling;
+ const action = widgetStateRef?.current?.togglePolling;
if (typeof action === "function") action();
},
},
diff --git a/packages/widgets/src/definition.spec.ts b/packages/widgets/src/definition.spec.ts
index ce08a0ace..3ad2e96ba 100644
--- a/packages/widgets/src/definition.spec.ts
+++ b/packages/widgets/src/definition.spec.ts
@@ -1,6 +1,8 @@
import { describe, expect, test } from "vitest";
-import { getWidgetQueryKeys } from "./definition";
+import { getWidgetQueryKeys, getWidgetRuntimeQueries, setWidgetRuntimeQueries } from "./definition";
+
+const togglePolling = () => undefined;
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -12,8 +14,21 @@ describe("getWidgetQueryKeys", () => {
});
test("preserves every explicit key for multi-root widgets", () => {
- const queryKeys = [[["widget", "audioStats"]], [["widget", "mediaServer"]]] as const;
+ const queryKeys = [
+ [["widget", "audioStats", "getStats"]],
+ [["widget", "mediaServer", "getCurrentStreams"]],
+ ] as const;
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
+
+ test("registers runtime queries without replacing imperative widget actions", () => {
+ const widgetStateRef = { current: { togglePolling } };
+ const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
+
+ setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
+
+ expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
+ expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
+ });
});
diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts
index cc8f9a5b0..60064de9a 100644
--- a/packages/widgets/src/downloads/index.ts
+++ b/packages/widgets/src/downloads/index.ts
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import type { ExtendedDownloadClientItem } from "@homarr/integrations";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const columnsList = [
@@ -42,6 +42,12 @@ const sortColumns = [
export const { definition, componentLoader } = createWidgetDefinition("downloads", {
icon: IconDownload,
+ queryKey: [["widget", "downloads", "getJobsAndStatuses"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ integrationIds: scope.integrationIds,
+ limitPerIntegration: scope.options.limitPerIntegration,
+ }),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from(
diff --git a/packages/widgets/src/firewall/component.tsx b/packages/widgets/src/firewall/component.tsx
index a3bd86064..9ab1714c5 100644
--- a/packages/widgets/src/firewall/component.tsx
+++ b/packages/widgets/src/firewall/component.tsx
@@ -127,7 +127,9 @@ export default function FirewallWidget({
const memory = firewallsMemoryData.find(({ integration }) => integration.id === firewallId);
const version = firewallsVersionData.find(({ integration }) => integration.id === firewallId);
const interfaces = firewallsInterfacesData.find(({ integration }) => integration.id === firewallId);
- const hasError = Boolean(cpu?.error || memory?.error || version?.error || interfaces?.error);
+ const hasError = Boolean(
+ cpu?.error || memory?.error || version?.error || interfacesQuery.isError || interfaces?.error,
+ );
const metadata = firewallMetadata.get(firewallId);
return (
@@ -138,13 +140,18 @@ export default function FirewallWidget({
version={version?.error ? undefined : version?.summary.version}
cpu={cpu?.error ? undefined : cpu?.summary.total}
memory={memory?.error ? undefined : memory?.summary.percent}
- interfaces={interfaces?.error ? undefined : interfaces?.summary}
+ interfaces={interfaces?.summary}
+ interfacesLoaded={interfaces !== undefined}
+ interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
- noDataLabel={t("widget.firewall.error.internalServerError")}
+ errorLabel={t("widget.firewall.error.internalServerError")}
+ noDataLabel={t("widget.firewall.empty.noInterfaces")}
+ loadingLabel={t("common.action.loading")}
+ errorBadgeLabel={t("common.error")}
interfacesLabel={t("widget.firewall.widget.interfaces.title")}
t={t}
/>
@@ -163,12 +170,17 @@ interface FirewallPanelProps {
cpu?: number;
memory?: number;
interfaces?: FirewallInterfacesSummary[];
+ interfacesLoaded: boolean;
+ interfacesError: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
+ errorLabel: string;
noDataLabel: string;
+ loadingLabel: string;
+ errorBadgeLabel: string;
interfacesLabel: string;
t: TranslationFunction;
}
@@ -180,12 +192,17 @@ const FirewallPanel = ({
cpu,
memory,
interfaces,
+ interfacesLoaded,
+ interfacesError,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
+ errorLabel,
noDataLabel,
+ loadingLabel,
+ errorBadgeLabel,
interfacesLabel,
t,
}: FirewallPanelProps) => (
@@ -205,7 +222,7 @@ const FirewallPanel = ({
{hasError && (
-
+
{t("common.error")}
@@ -235,11 +252,16 @@ const FirewallPanel = ({
@@ -294,21 +316,31 @@ const getMetricStatus = (value: number): "normal" | "warning" | "critical" => {
interface InterfacesPanelProps {
summary: FirewallInterfacesSummary[];
+ hasResult: boolean;
+ hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
+ errorLabel: string;
+ errorBadgeLabel: string;
noDataLabel: string;
+ loadingLabel: string;
label: string;
}
const InterfacesPanel = ({
summary,
+ hasResult,
+ hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
+ errorLabel,
+ errorBadgeLabel,
noDataLabel,
+ loadingLabel,
label,
}: InterfacesPanelProps) => {
const bandwidth = calculateBandwidth(summary).data;
@@ -321,35 +353,45 @@ const InterfacesPanel = ({
>
}>
- {label}
+
+ {label}
+ {hasError && (
+
+ {errorBadgeLabel}
+
+ )}
+
- {bandwidth.length > 0 ? (
- bandwidth.map(({ name, receive, transmit }) => (
-
-
- {name}
+ {bandwidth.map(({ name, receive, transmit }) => (
+
+
+ {name}
+
+
+
+
+ {formatBitsPerSec(transmit, 2)}
-
-
-
- {formatBitsPerSec(transmit, 2)}
-
-
-
-
-
- {formatBitsPerSec(receive, 2)}
-
-
- ))
- ) : (
-
- {noDataLabel}
+
+
+
+ {formatBitsPerSec(receive, 2)}
+
+
+
+ ))}
+ {hasError ? (
+
+ {errorLabel}
- )}
+ ) : bandwidth.length === 0 ? (
+
+ {hasResult ? noDataLabel : loadingLabel}
+
+ ) : null}
diff --git a/packages/widgets/src/iframe/component.spec.ts b/packages/widgets/src/iframe/component.spec.ts
index 97f9abf45..a5f3b5835 100644
--- a/packages/widgets/src/iframe/component.spec.ts
+++ b/packages/widgets/src/iframe/component.spec.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { getAllowedPermissions, getSandboxFlags, isSupportedProtocol } from "./component";
+import { getAllowedPermissions, getFrameTitle, getSandboxFlags, isSupportedProtocol } from "./component";
const permissions = {
allowFullScreen: true,
@@ -23,4 +23,12 @@ describe("iframe policy", () => {
expect(isSupportedProtocol("javascript:alert(1)")).toBe(false);
expect(isSupportedProtocol("not a url")).toBe(false);
});
+
+ it("creates a display title without credentials, paths, or query parameters", () => {
+ expect(getFrameTitle("https://user:secret@example.com/private?token=sensitive")).toBe("example.com");
+ });
+
+ it("keeps a non-default port in the display title", () => {
+ expect(getFrameTitle("http://user:secret@example.com:8080/private?token=sensitive")).toBe("example.com:8080");
+ });
});
diff --git a/packages/widgets/src/iframe/component.tsx b/packages/widgets/src/iframe/component.tsx
index 068b5dc45..96cf210b7 100644
--- a/packages/widgets/src/iframe/component.tsx
+++ b/packages/widgets/src/iframe/component.tsx
@@ -28,7 +28,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
{getFrameTitle(embedUrl)}
- {embedUrl}
+ {t("widget.app.option.openInNewTab.label")}
)}
@@ -138,9 +138,9 @@ const permissionMapping: Partial<
allowPayment: "payment",
};
-const getFrameTitle = (url: string) => {
+export const getFrameTitle = (url: string) => {
try {
- return new URL(url).hostname;
+ return new URL(url).host;
} catch {
return "iframe";
}
diff --git a/packages/widgets/src/immich/album-carousel/index.ts b/packages/widgets/src/immich/album-carousel/index.ts
index ef07d245e..eeb0d611a 100644
--- a/packages/widgets/src/immich/album-carousel/index.ts
+++ b/packages/widgets/src/immich/album-carousel/index.ts
@@ -4,23 +4,31 @@ import z from "zod";
import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
-import { createWidgetDefinition } from "../../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
- queryKey: [["widget", "immich"]],
+ queryKey: [["widget", "immich", "getAlbum"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ integrationId: scope.integrationIds[0] ?? "",
+ albumId:
+ typeof scope.options.albumId === "string" && scope.options.albumId !== ALL_PHOTOS_ALBUM_ID
+ ? scope.options.albumId
+ : undefined,
+ }),
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
- const action = widgetStateRef.current?.[key];
+ const action = widgetStateRef?.current?.[key];
if (typeof action === "function") action();
};
- const disabled = (key: string) => typeof widgetStateRef.current?.[key] !== "function";
+ const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
return [
{
key: "previousPhoto",
diff --git a/packages/widgets/src/immich/albums-query.spec.ts b/packages/widgets/src/immich/albums-query.spec.ts
new file mode 100644
index 000000000..99cef03e9
--- /dev/null
+++ b/packages/widgets/src/immich/albums-query.spec.ts
@@ -0,0 +1,59 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+import type { WidgetComponentProps } from "../definition";
+import ImmichServerStatsWidget from "./server-stats/component";
+import { definition as albumCarouselDefinition } from "./album-carousel";
+
+const mocks = vi.hoisted(() => ({
+ getAlbums: vi.fn(() => ({ data: [], isPending: false, isError: false })),
+}));
+
+vi.mock("@homarr/api/client", () => ({
+ clientApi: {
+ widget: {
+ immich: {
+ getAlbums: { useQuery: mocks.getAlbums },
+ getServerStats: { useQuery: () => ({ data: undefined }) },
+ },
+ },
+ },
+}));
+
+vi.mock("@homarr/translation/client", () => ({
+ useI18n: () => (key: string) => key,
+ useScopedI18n: () => (key: string) => key,
+}));
+
+describe("Immich album queries", () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ test("server stats requests a bounded album response", () => {
+ ImmichServerStatsWidget({
+ integrationIds: ["immich-id"],
+ options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
+ displayMode: "advanced",
+ width: 800,
+ height: 600,
+ isEditMode: false,
+ boardId: "board-id",
+ itemId: "item-id",
+ setOptions: vi.fn(),
+ } satisfies WidgetComponentProps<"immich-serverStats">);
+
+ expect(mocks.getAlbums).toHaveBeenCalledWith(
+ { integrationId: "immich-id", limit: 50 },
+ expect.objectContaining({ enabled: true }),
+ );
+ });
+
+ test("album carousel keeps requesting every album for its selector", () => {
+ const options = albumCarouselDefinition.createOptions();
+
+ options.albumId.useOptions(["immich-id"]);
+
+ expect(mocks.getAlbums).toHaveBeenCalledWith(
+ { integrationId: "immich-id" },
+ expect.objectContaining({ enabled: true }),
+ );
+ });
+});
diff --git a/packages/widgets/src/immich/server-stats/component.tsx b/packages/widgets/src/immich/server-stats/component.tsx
index 71c78657d..9296e2516 100644
--- a/packages/widgets/src/immich/server-stats/component.tsx
+++ b/packages/widgets/src/immich/server-stats/component.tsx
@@ -10,30 +10,37 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
+import { setWidgetRuntimeQueries } from "../../definition";
import classes from "./component.module.css";
+const MAX_ADVANCED_ALBUMS = 50;
+
export default function ImmichServerStatsWidget({
integrationIds,
options,
displayMode = "compact",
width,
+ widgetStateRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
- const { data: stats } = clientApi.widget.immich.getServerStats.useQuery({
- integrationId: integrationIds[0] ?? "",
+ const input = { integrationId: integrationIds[0] ?? "" };
+ const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
+ const isAdvanced = displayMode === "advanced";
+ const albumsEnabled = isAdvanced && integrationIds.length > 0;
+ const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
+ const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
+ enabled: albumsEnabled,
+ staleTime: 15 * 60 * 1000,
});
- const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(
- { integrationId: integrationIds[0] ?? "" },
- { enabled: displayMode === "advanced" && integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
- );
+ setWidgetRuntimeQueries(widgetStateRef, [
+ { path: ["widget", "immich", "getServerStats"], input },
+ ...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
+ ]);
if (!stats) return ;
const statsContent = (
- = 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1}
- spacing="sm"
- >
+ = 720 ? 4 : width >= 360 ? 2 : 1) : width >= 320 ? 2 : 1} spacing="sm">
{options.showUsers && (
} label={t("widget.immich-serverStats.users")} value={stats.userCount} />
)}
@@ -61,7 +68,7 @@ export default function ImmichServerStatsWidget({
);
- if (displayMode === "compact") {
+ if (!isAdvanced) {
return (
{statsContent}
@@ -69,7 +76,7 @@ export default function ImmichServerStatsWidget({
);
}
- const sortedAlbums = [...albums].toSorted((left, right) => right.assetCount - left.assetCount);
+ const sortedAlbums = albums;
const maxAssets = sortedAlbums[0]?.assetCount ?? 1;
return (
diff --git a/packages/widgets/src/immich/server-stats/index.ts b/packages/widgets/src/immich/server-stats/index.ts
index da9b6e1f2..a1c966664 100644
--- a/packages/widgets/src/immich/server-stats/index.ts
+++ b/packages/widgets/src/immich/server-stats/index.ts
@@ -1,11 +1,12 @@
import { IconGraphFilled } from "@tabler/icons-react";
-import { createWidgetDefinition } from "../../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../../definition";
import { optionsBuilder } from "../../options";
export const { definition, componentLoader } = createWidgetDefinition("immich-serverStats", {
icon: IconGraphFilled,
- queryKey: [["widget", "immich"]],
+ queryKeys: [[["widget", "immich", "getServerStats"]], [["widget", "immich", "getAlbums"]]],
+ queryMatcher: matchesWidgetRuntimeQuery,
refetchInterval: null,
supportedIntegrations: ["immich"],
integrationsRequired: true,
diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx
index 615d6c3fd..9af0bf20a 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -67,12 +67,15 @@ import * as customApi from "./custom-api";
import * as wud from "./wud";
export type {
+ NormalizedWidgetQuery,
WidgetDefinition,
WidgetContextMenuAction,
WidgetContextActionProps,
WidgetOptionsSettings,
+ WidgetQueryMatcher,
+ WidgetQueryMatcherScope,
} from "./definition";
-export { getWidgetQueryKeys } from "./definition";
+export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition";
export type { WidgetComponentProps };
export type { WidgetOptionDefinition, WidgetOptionType } from "./options";
diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx
index df0298b82..2868c3caf 100644
--- a/packages/widgets/src/indexer-manager/component.tsx
+++ b/packages/widgets/src/indexer-manager/component.tsx
@@ -33,7 +33,9 @@ export default function IndexerManagerWidget({
const hasSmallWidth = !isAdvanced && width < 256;
const hasSmallHeight = !isAdvanced && height < 256;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
- const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length;
+ const unavailableCount = allIndexers.filter(
+ (indexer) => indexer.status === false || indexer.enabled === false,
+ ).length;
useEffect(() => {
if (!widgetStateRef) return;
diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts
index b48ef301a..861d1b7f8 100644
--- a/packages/widgets/src/indexer-manager/index.ts
+++ b/packages/widgets/src/indexer-manager/index.ts
@@ -15,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
- typeof widgetStateRef.current?.testAllIndexers !== "function",
+ typeof widgetStateRef?.current?.testAllIndexers !== "function",
onClick: () => {
- const action = widgetStateRef.current?.testAllIndexers;
+ const action = widgetStateRef?.current?.testAllIndexers;
if (typeof action === "function") action();
},
},
diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx
index b5bb9d320..3fc2f8684 100644
--- a/packages/widgets/src/media-missing/component.tsx
+++ b/packages/widgets/src/media-missing/component.tsx
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
+import { setWidgetRuntimeQueries } from "../definition";
import { NoIntegrationDataError } from "../errors/no-data-integration";
import classes from "./component.module.css";
import type { MediaMissingTab } from "./tabs";
@@ -37,14 +38,18 @@ export default function MediaMissingWidget({
width,
height,
displayMode,
+ widgetStateRef,
}: WidgetComponentProps<"mediaMissing">) {
const t = useScopedI18n("widget.mediaMissing");
const isAdvanced = displayMode === "advanced";
const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize);
- const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(
- { integrationIds, pageSize },
- { staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false },
- );
+ const input = { integrationIds, pageSize };
+ setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]);
+ const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, {
+ staleTime: 60 * 1000,
+ refetchOnWindowFocus: false,
+ refetchOnReconnect: false,
+ });
const [selectedTab, setSelectedTab] = useState(options.showMissing ? "missing" : "queued");
const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued);
diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts
index fd325f801..69a6ecf73 100644
--- a/packages/widgets/src/media-missing/index.ts
+++ b/packages/widgets/src/media-missing/index.ts
@@ -2,14 +2,15 @@ import { IconMovie } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
const pageSizeOptions = ["10", "20", "30", "50"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", {
icon: IconMovie,
- queryKey: [["widget", "mediaOrganizer"]],
+ queryKey: [["widget", "mediaOrganizer", "getData"]],
+ queryMatcher: matchesWidgetRuntimeQuery,
createOptions() {
return optionsBuilder.from((factory) => ({
showMissing: factory.switch({ defaultValue: true }),
diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts
index bcc5dd4fd..f8f90a10f 100644
--- a/packages/widgets/src/media-requests/list/index.ts
+++ b/packages/widgets/src/media-requests/list/index.ts
@@ -4,14 +4,23 @@ import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { openMediaRequestSearch } from "@homarr/spotlight";
-import { createWidgetDefinition } from "../../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", {
icon: IconZoomQuestion,
- queryKey: [["widget", "mediaRequests"]],
+ queryKey: [["widget", "mediaRequests", "getLatestRequests"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ integrationIds: scope.integrationIds,
+ statuses:
+ Array.isArray(scope.options.statusFilter) && scope.options.statusFilter.length > 0
+ ? scope.options.statusFilter
+ : mediaRequestStatusValues,
+ recentDays: scope.options.recentDays,
+ }),
createOptions() {
return optionsBuilder.from((factory) => ({
linksTargetNewTab: factory.switch({
diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts
index 5efbd8909..2f3929877 100644
--- a/packages/widgets/src/media-requests/stats/index.ts
+++ b/packages/widgets/src/media-requests/stats/index.ts
@@ -7,7 +7,7 @@ import { createWidgetDefinition } from "../../definition";
export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", {
icon: IconChartBar,
- queryKey: [["widget", "mediaRequests"]],
+ queryKey: [["widget", "mediaRequests", "getStats"]],
createOptions() {
return {};
},
diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts
index ef746e921..46f3fd489 100644
--- a/packages/widgets/src/media-server/index.ts
+++ b/packages/widgets/src/media-server/index.ts
@@ -2,11 +2,17 @@ import { IconVideo } from "@tabler/icons-react";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
export const { componentLoader, definition } = createWidgetDefinition("mediaServer", {
icon: IconVideo,
+ queryKey: [["widget", "mediaServer", "getCurrentStreams"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ integrationIds: scope.integrationIds,
+ showOnlyPlaying: scope.options.showOnlyPlaying,
+ }),
refetchInterval: 5,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx
index 765c3cdcd..926ef992a 100644
--- a/packages/widgets/src/media-transcoding/component.tsx
+++ b/packages/widgets/src/media-transcoding/component.tsx
@@ -23,6 +23,7 @@ import type { TablerIcon } from "@homarr/ui";
import { views } from ".";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
+import { setWidgetRuntimeQueries } from "../definition";
import { HealthCheckStatus } from "./health-check-status";
import { QueuePanel } from "./panels/queue.panel";
import { StatisticsPanel } from "./panels/statistics.panel";
@@ -44,6 +45,7 @@ export default function MediaTranscodingWidget({
width,
height,
displayMode,
+ widgetStateRef,
}: WidgetComponentProps<"mediaTranscoding">) {
const isAdvanced = displayMode === "advanced";
const queuePageSize = getQueuePageSize(height, isAdvanced);
@@ -58,6 +60,7 @@ export default function MediaTranscodingWidget({
pageSize: queuePageSize,
page: requestPagination.page,
};
+ setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaTranscoding", "getDataAsync"], input }]);
const { data: transcodingData } = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input);
const [view, setView] = useState(options.defaultView);
diff --git a/packages/widgets/src/media-transcoding/index.ts b/packages/widgets/src/media-transcoding/index.ts
index f333f3168..fc91622e3 100644
--- a/packages/widgets/src/media-transcoding/index.ts
+++ b/packages/widgets/src/media-transcoding/index.ts
@@ -4,13 +4,15 @@ import { z } from "zod/v4";
import { capitalize } from "@homarr/common";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition";
import { optionsBuilder } from "../options";
export const views = ["workers", "queue", "statistics"] as const;
export const { componentLoader, definition } = createWidgetDefinition("mediaTranscoding", {
icon: IconTransform,
+ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]],
+ queryMatcher: matchesWidgetRuntimeQuery,
maxIntegrations: 1,
refetchInterval: null,
createOptions() {
diff --git a/packages/widgets/src/minecraft/server-status/index.ts b/packages/widgets/src/minecraft/server-status/index.ts
index 028393955..1215c1731 100644
--- a/packages/widgets/src/minecraft/server-status/index.ts
+++ b/packages/widgets/src/minecraft/server-status/index.ts
@@ -1,12 +1,18 @@
import { IconBrandMinecraft } from "@tabler/icons-react";
import { z } from "zod/v4";
-import { createWidgetDefinition } from "../../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition";
import { optionsBuilder } from "../../options";
export const { componentLoader, definition } = createWidgetDefinition("minecraftServerStatus", {
icon: IconBrandMinecraft,
- queryKey: [["widget", "minecraft"]],
+ queryKey: [["widget", "minecraft", "getServerStatus"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ title: scope.options.title,
+ domain: scope.options.domain,
+ isBedrockServer: scope.options.isBedrockServer,
+ }),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx
index 42c346dbc..720ded0a0 100644
--- a/packages/widgets/src/notebook/notebook.tsx
+++ b/packages/widgets/src/notebook/notebook.tsx
@@ -53,7 +53,6 @@ import { Table } from "@tiptap/extension-table";
import { TableCell } from "@tiptap/extension-table-cell";
import { TableHeader } from "@tiptap/extension-table-header";
import { TableRow } from "@tiptap/extension-table-row";
-import { TaskItem } from "@tiptap/extension-task-item";
import { TaskList } from "@tiptap/extension-task-list";
import { TextAlign } from "@tiptap/extension-text-align";
import { TextStyle } from "@tiptap/extension-text-style";
@@ -61,7 +60,6 @@ import type { Editor } from "@tiptap/react";
import { useEditor } from "@tiptap/react";
import { BubbleMenu } from "@tiptap/react/menus";
import { StarterKit } from "@tiptap/starter-kit";
-import type { Node } from "prosemirror-model";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
@@ -70,6 +68,7 @@ import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
+import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
import { NotebookTextDirection, setTextDirection } from "./text-direction";
import "@mantine/tiptap/styles.css";
@@ -208,17 +207,20 @@ export function Notebook({
}),
TableHeader,
TableRow,
- TaskItem.configure({
+ ReadOnlyTaskItem.configure({
nested: true,
- onReadOnlyChecked: (node, checked) => {
+ onReadOnlyChecked: () => {
if (!allowReadOnlyCheckRef.current) return false;
if (!canChangeRef.current) return false;
-
+ return true;
+ },
+ onReadOnlyCheckedAtPosition: (position, checked) => {
+ if (!allowReadOnlyCheckRef.current) return;
+ if (!canChangeRef.current) return;
const event = new CustomEvent(readOnlyCheckEventName, {
- detail: { node, checked },
+ detail: { position, checked },
});
dispatchEvent(event);
- return true;
},
}),
TaskList.configure({ itemTypeName: "taskItem" }),
@@ -243,25 +245,17 @@ export function Notebook({
(event: Event) => {
if (!allowReadOnlyCheckRef.current) return;
if (!editor) return;
- const { detail } = event as CustomEvent<{ node: Node; checked: boolean }>;
+ const { detail } = event as CustomEvent<{ position: number; checked: boolean }>;
+ const transaction = createReadOnlyTaskItemTransaction(editor.state, detail.position, detail.checked);
+ if (!transaction) return;
- editor.state.doc.descendants((subnode, pos) => {
- if (!detail) return;
- if (!subnode.eq(detail.node)) return;
-
- const { tr } = editor.state;
- tr.setNodeMarkup(pos, undefined, {
- ...detail.node.attrs,
- checked: detail.checked,
- });
- editor.view.dispatch(tr);
- const nextContent = editor.getHTML();
- setContent(nextContent);
- void handleContentUpdate(nextContent).then((wasSaved) => {
- if (wasSaved) return;
- setContent(previousContentRef.current);
- editor.commands.setContent(previousContentRef.current);
- });
+ editor.view.dispatch(transaction);
+ const nextContent = editor.getHTML();
+ setContent(nextContent);
+ void handleContentUpdate(nextContent).then((wasSaved) => {
+ if (wasSaved) return;
+ setContent(previousContentRef.current);
+ editor.commands.setContent(previousContentRef.current);
});
},
[editor, handleContentUpdate],
diff --git a/packages/widgets/src/notebook/read-only-task-item.spec.ts b/packages/widgets/src/notebook/read-only-task-item.spec.ts
new file mode 100644
index 000000000..a6f99a368
--- /dev/null
+++ b/packages/widgets/src/notebook/read-only-task-item.spec.ts
@@ -0,0 +1,96 @@
+import { Editor } from "@tiptap/core";
+import { TaskList } from "@tiptap/extension-task-list";
+import { Schema } from "@tiptap/pm/model";
+import { EditorState } from "@tiptap/pm/state";
+import { StarterKit } from "@tiptap/starter-kit";
+import { describe, expect, it } from "vitest";
+
+import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item";
+
+const schema = new Schema({
+ nodes: {
+ doc: { content: "taskList" },
+ paragraph: { content: "text*" },
+ text: { inline: true },
+ taskList: { content: "taskItem+" },
+ taskItem: {
+ attrs: { checked: { default: false } },
+ content: "paragraph",
+ },
+ },
+});
+
+const createTaskItem = () =>
+ schema.node("taskItem", { checked: false }, [schema.node("paragraph", null, [schema.text("Duplicate task")])]);
+
+describe("read-only task item updates", () => {
+ it("reports the exact position of the clicked duplicate task", () => {
+ const element = document.createElement("div");
+ let clickedPosition: number | undefined;
+ const editor = new Editor({
+ element,
+ editable: false,
+ extensions: [
+ StarterKit,
+ TaskList.configure({ itemTypeName: "taskItem" }),
+ ReadOnlyTaskItem.configure({
+ nested: true,
+ onReadOnlyChecked: () => true,
+ onReadOnlyCheckedAtPosition: (position) => {
+ clickedPosition = position;
+ },
+ }),
+ ],
+ content: {
+ type: "doc",
+ content: [
+ {
+ type: "taskList",
+ content: [
+ { type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
+ { type: "taskItem", attrs: { checked: false }, content: [{ type: "paragraph", content: [] }] },
+ ],
+ },
+ ],
+ },
+ });
+
+ try {
+ const positions: number[] = [];
+ editor.state.doc.descendants((node, position) => {
+ if (node.type.name === "taskItem") positions.push(position);
+ });
+ const secondPosition = positions[1];
+ const secondCheckbox = element.querySelectorAll('input[type="checkbox"]').item(1);
+ if (secondPosition === undefined || !secondCheckbox) throw new Error("Expected a second task item checkbox");
+
+ secondCheckbox.checked = true;
+ secondCheckbox.dispatchEvent(new Event("change", { bubbles: true }));
+
+ expect(clickedPosition).toBe(secondPosition);
+ } finally {
+ editor.destroy();
+ }
+ });
+
+ it("updates the selected position when task items are structurally identical", () => {
+ const doc = schema.node("doc", null, [schema.node("taskList", null, [createTaskItem(), createTaskItem()])]);
+ const taskItemPositions: number[] = [];
+ doc.descendants((node, position) => {
+ if (node.type.name === "taskItem") taskItemPositions.push(position);
+ });
+ const secondTaskItemPosition = taskItemPositions[1];
+ if (secondTaskItemPosition === undefined) throw new Error("Expected a second task item");
+
+ const state = EditorState.create({ doc });
+ const transaction = createReadOnlyTaskItemTransaction(state, secondTaskItemPosition, true);
+ if (!transaction) throw new Error("Expected a task-item transaction");
+ const nextState = state.apply(transaction);
+ const checkedStates: boolean[] = [];
+ nextState.doc.descendants((node) => {
+ if (node.type.name === "taskItem") checkedStates.push(Boolean(node.attrs.checked));
+ });
+
+ expect(checkedStates).toEqual([false, true]);
+ });
+});
diff --git a/packages/widgets/src/notebook/read-only-task-item.ts b/packages/widgets/src/notebook/read-only-task-item.ts
new file mode 100644
index 000000000..88770a110
--- /dev/null
+++ b/packages/widgets/src/notebook/read-only-task-item.ts
@@ -0,0 +1,66 @@
+import type { NodeViewRenderer } from "@tiptap/core";
+import { TaskItem } from "@tiptap/extension-task-item";
+import type { TaskItemOptions } from "@tiptap/extension-task-item";
+import type { EditorState, Transaction } from "@tiptap/pm/state";
+
+interface ReadOnlyTaskItemOptions extends TaskItemOptions {
+ onReadOnlyCheckedAtPosition?: (position: number, checked: boolean) => void;
+}
+
+export const ReadOnlyTaskItem = TaskItem.extend({
+ addOptions() {
+ const parentOptions = this.parent?.();
+ return {
+ onReadOnlyChecked: parentOptions?.onReadOnlyChecked,
+ nested: parentOptions?.nested ?? false,
+ HTMLAttributes: parentOptions?.HTMLAttributes ?? {},
+ taskListTypeName: parentOptions?.taskListTypeName ?? "taskList",
+ a11y: parentOptions?.a11y,
+ onReadOnlyCheckedAtPosition: undefined,
+ };
+ },
+ addNodeView() {
+ const parentNodeView = this.parent?.() as NodeViewRenderer | undefined;
+ if (!parentNodeView) return null;
+
+ return (props) => {
+ const nodeView = parentNodeView(props);
+ const checkbox =
+ nodeView.dom instanceof HTMLElement
+ ? nodeView.dom.querySelector('input[type="checkbox"]')
+ : null;
+ if (!checkbox) return nodeView;
+
+ const handleChange = () => {
+ if (props.editor.isEditable) return;
+ const position = props.getPos();
+ if (typeof position !== "number") return;
+ this.options.onReadOnlyCheckedAtPosition?.(position, checkbox.checked);
+ };
+ checkbox.addEventListener("change", handleChange);
+ const destroyParent = nodeView.destroy?.bind(nodeView);
+
+ return {
+ ...nodeView,
+ destroy() {
+ checkbox.removeEventListener("change", handleChange);
+ destroyParent?.();
+ },
+ };
+ };
+ },
+});
+
+export const createReadOnlyTaskItemTransaction = (
+ state: EditorState,
+ position: number,
+ checked: boolean,
+): Transaction | null => {
+ const taskItem = state.doc.nodeAt(position);
+ if (taskItem?.type.name !== "taskItem") return null;
+
+ return state.tr.setNodeMarkup(position, undefined, {
+ ...taskItem.attrs,
+ checked,
+ });
+};
diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts
index 62fa7ebab..f190ed9dc 100644
--- a/packages/widgets/src/releases/index.ts
+++ b/packages/widgets/src/releases/index.ts
@@ -4,7 +4,7 @@ import { z } from "zod/v4";
import { createId } from "@homarr/common";
import { releaseProviderKinds } from "@homarr/definitions";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
const relativeDateSchema = z
@@ -14,6 +14,8 @@ const relativeDateSchema = z
export const { definition, componentLoader } = createWidgetDefinition("releases", {
icon: IconRocket,
+ queryKey: [["widget", "releases", "getLatest"]],
+ queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts
index eee7028cb..dcef56cb4 100644
--- a/packages/widgets/src/rssFeed/index.ts
+++ b/packages/widgets/src/rssFeed/index.ts
@@ -1,7 +1,7 @@
import { IconRss } from "@tabler/icons-react";
import { z } from "zod/v4";
-import { createWidgetDefinition } from "../definition";
+import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { optionsBuilder } from "../options";
/**
@@ -12,6 +12,12 @@ import { optionsBuilder } from "../options";
*/
export const { definition, componentLoader } = createWidgetDefinition("rssFeed", {
icon: IconRss,
+ queryKey: [["widget", "rssFeed", "getFeeds"]],
+ queryMatcher: ({ input }, scope) =>
+ widgetQueryInputMatches(input, {
+ urls: scope.options.feedUrls,
+ maximumAmountPosts: scope.options.maximumAmountPosts,
+ }),
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
diff --git a/packages/widgets/src/smart-home/entity-state/component.tsx b/packages/widgets/src/smart-home/entity-state/component.tsx
index e0f90bd52..197832f34 100644
--- a/packages/widgets/src/smart-home/entity-state/component.tsx
+++ b/packages/widgets/src/smart-home/entity-state/component.tsx
@@ -7,7 +7,7 @@ import { IconBinaryTree } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
import { useRegisterSpotlightContextActions } from "@homarr/spotlight";
-import { useI18n } from "@homarr/translation/client";
+import { useCurrentLocale, useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../../definition";
@@ -20,6 +20,7 @@ export default function SmartHomeEntityStateWidget({
displayMode,
}: WidgetComponentProps<"smartHome-entityState">) {
const t = useI18n();
+ const locale = useCurrentLocale();
// It will always have at least one integration as otherwise the NoIntegrationSelectedError would be thrown in item-content.tsx
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const integrationId = integrationIds[0]!;
@@ -123,7 +124,7 @@ export default function SmartHomeEntityStateWidget({
{t("widget.smartHome-entityState.advanced.lastUpdated", {
- date: new Date(entity.last_updated).toLocaleString(),
+ date: new Date(entity.last_updated).toLocaleString(locale),
})}
>
diff --git a/packages/widgets/src/speedtest-tracker/component.tsx b/packages/widgets/src/speedtest-tracker/component.tsx
index c243d1fef..56fbfa07e 100644
--- a/packages/widgets/src/speedtest-tracker/component.tsx
+++ b/packages/widgets/src/speedtest-tracker/component.tsx
@@ -37,12 +37,16 @@ export default function SpeedtestTrackerWidget({
(options.showLatestResult && combined.latestResult !== null) || (options.showStats && combined.stats !== null);
const hasChart = options.showRecentResults && recentFiltered.length > 0;
const noSectionsEnabled = !options.showLatestResult && !options.showStats && !options.showRecentResults;
+ const advancedSectionWidth = width >= 720 ? width / 2 : width;
const latest = options.showLatestResult && combined.latestResult && (
-
+
);
const averages = options.showStats && combined.stats && (
-
+
);
if (displayMode === "advanced") {
@@ -69,6 +73,12 @@ export default function SpeedtestTrackerWidget({
)}
+ {noSectionsEnabled && (
+