From a38e731209c19177ab696feb09a34b8a2bbc3fa4 Mon Sep 17 00:00:00 2001 From: Thomas Camlong Date: Sat, 1 Aug 2026 23:44:17 +0200 Subject: [PATCH] fix: address widget review feedback --- .../advanced-focus/advanced-focus.module.css | 2 +- .../board/items/widget-query-scope.spec.ts | 439 ++++++++++++++++++ .../board/items/widget-query-scope.ts | 94 ++++ .../board-empty-space-context-menu.module.css | 4 + .../board-empty-space-context-menu.tsx | 44 +- .../sections/gridstack/long-press.spec.ts | 17 + .../board/sections/gridstack/long-press.ts | 7 + e2e/board-advanced-interactions.spec.ts | 5 +- .../src/router/test/widgets/immich.spec.ts | 76 +++ .../src/router/test/widgets/rssFeed.spec.ts | 41 +- .../src/router/test/widgets/timetable.spec.ts | 173 ++++++- packages/api/src/router/widgets/firewall.ts | 1 + packages/api/src/router/widgets/immich.ts | 17 +- packages/api/src/router/widgets/rssFeed.ts | 25 +- packages/api/src/router/widgets/timetable.ts | 184 +++++++- packages/api/src/settle-integrations.ts | 8 +- .../api/src/test/settle-integrations.spec.ts | 19 +- .../core/src/infrastructure/http/request.ts | 23 +- .../overseerr/overseerr-integration.spec.ts | 11 +- packages/request-handler/src/immich.spec.ts | 50 ++ packages/request-handler/src/immich.ts | 19 +- .../request-handler/src/timetable.spec.ts | 67 ++- packages/request-handler/src/timetable.ts | 90 +++- packages/translation/src/lang/en.json | 4 - packages/widgets/src/app/component.tsx | 2 +- packages/widgets/src/app/index.ts | 2 +- .../src/archive-team-warrior/component.tsx | 5 +- .../widgets/src/audio-stats/component.tsx | 18 +- packages/widgets/src/audio-stats/index.ts | 5 +- packages/widgets/src/beszel-alerts/index.ts | 10 +- .../src/beszel-system-grid/component.tsx | 4 +- .../widgets/src/beszel-system-grid/index.ts | 2 +- .../src/beszel-system-stats/component.tsx | 23 +- .../widgets/src/beszel-system-stats/index.ts | 28 +- .../src/beszel-system-stats/selection.spec.ts | 33 +- .../src/beszel-system-stats/selection.ts | 26 +- .../widgets/src/beszel-system-table/index.ts | 8 +- packages/widgets/src/calendar/component.tsx | 16 +- packages/widgets/src/calendar/index.ts | 4 +- packages/widgets/src/clock/index.ts | 20 +- .../widgets/src/coolify/instance-card.tsx | 27 +- .../src/coolify/single-instance-layout.tsx | 27 +- packages/widgets/src/custom-api/index.ts | 8 +- packages/widgets/src/definition.spec.ts | 19 +- packages/widgets/src/downloads/index.ts | 8 +- packages/widgets/src/firewall/component.tsx | 96 ++-- packages/widgets/src/iframe/component.spec.ts | 10 +- packages/widgets/src/iframe/component.tsx | 6 +- .../src/immich/album-carousel/index.ts | 16 +- .../widgets/src/immich/albums-query.spec.ts | 59 +++ .../src/immich/server-stats/component.tsx | 31 +- .../widgets/src/immich/server-stats/index.ts | 5 +- packages/widgets/src/index.tsx | 5 +- .../widgets/src/indexer-manager/component.tsx | 4 +- packages/widgets/src/indexer-manager/index.ts | 4 +- packages/widgets/src/media-missing/index.ts | 5 +- .../widgets/src/media-requests/list/index.ts | 13 +- .../widgets/src/media-requests/stats/index.ts | 2 +- packages/widgets/src/media-server/index.ts | 8 +- .../src/media-transcoding/component.tsx | 3 + .../widgets/src/media-transcoding/index.ts | 4 +- .../src/minecraft/server-status/index.ts | 10 +- packages/widgets/src/notebook/notebook.tsx | 44 +- .../src/notebook/read-only-task-item.spec.ts | 96 ++++ .../src/notebook/read-only-task-item.ts | 66 +++ packages/widgets/src/releases/index.ts | 4 +- packages/widgets/src/rssFeed/index.ts | 8 +- .../src/smart-home/entity-state/component.tsx | 5 +- .../src/speedtest-tracker/component.tsx | 14 +- packages/widgets/src/stocks/index.ts | 9 +- .../src/system-resources/component.tsx | 2 +- .../widgets/src/tracearr/component.spec.ts | 14 + packages/widgets/src/tracearr/component.tsx | 21 +- packages/widgets/src/umami/component.tsx | 45 ++ packages/widgets/src/umami/index.ts | 10 +- packages/widgets/src/umami/umami-content.tsx | 30 +- .../widgets/src/uptime-kuma/component.tsx | 3 +- packages/widgets/src/weather/component.tsx | 2 +- packages/widgets/src/weather/index.ts | 13 +- 79 files changed, 2138 insertions(+), 244 deletions(-) create mode 100644 apps/nextjs/src/components/board/items/widget-query-scope.spec.ts create mode 100644 apps/nextjs/src/components/board/items/widget-query-scope.ts create mode 100644 apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.module.css create mode 100644 apps/nextjs/src/components/board/sections/gridstack/long-press.spec.ts create mode 100644 apps/nextjs/src/components/board/sections/gridstack/long-press.ts create mode 100644 packages/api/src/router/test/widgets/immich.spec.ts create mode 100644 packages/request-handler/src/immich.spec.ts create mode 100644 packages/widgets/src/immich/albums-query.spec.ts create mode 100644 packages/widgets/src/notebook/read-only-task-item.spec.ts create mode 100644 packages/widgets/src/notebook/read-only-task-item.ts create mode 100644 packages/widgets/src/tracearr/component.spec.ts 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 57f2e743f..7c0ed58ce 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2800,10 +2800,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/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 && ( + + {t("noSectionsEnabled")} + + )} + {!noSectionsEnabled && !hasStatSection && !hasChart && } ); } diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index ba44f481e..7cdcbb37e 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -1,6 +1,6 @@ import { IconBuildingBank } from "@tabler/icons-react"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const stockPriceTimeFrames = { @@ -13,6 +13,13 @@ const timeIntervalOptions = stockPriceTimeFrames.interval; export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { icon: IconBuildingBank, + queryKey: [["widget", "stockPrice", "getPriceHistory"]], + queryMatcher: ({ input }, scope) => + widgetQueryInputMatches(input, { + stock: scope.options.stock, + timeRange: scope.options.timeRange, + timeInterval: scope.options.timeInterval, + }), refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index be74352de..18006fd9d 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -136,7 +136,7 @@ const SystemCharts = ({ showTitle, }: SystemChartsProps) => { const showNetwork = - items.length > 0 && items.every((item) => item.network !== null) && options.visibleCharts.includes("network"); + items.length > 0 && items[items.length - 1]?.network !== null && options.visibleCharts.includes("network"); const chartCount = options.visibleCharts.filter((chart) => chart !== "network").length + Number(showNetwork); const chartColumns = isAdvanced && width >= 560 ? 2 : 1; const chartHeight = isAdvanced diff --git a/packages/widgets/src/tracearr/component.spec.ts b/packages/widgets/src/tracearr/component.spec.ts new file mode 100644 index 000000000..171dead78 --- /dev/null +++ b/packages/widgets/src/tracearr/component.spec.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from "vitest"; + +import { getAdvancedColumnWidth } from "./component"; + +describe("Tracearr advanced layout", () => { + it("subtracts grid padding and the inter-column gap", () => { + expect(getAdvancedColumnWidth(800, true)).toBe(376); + expect(getAdvancedColumnWidth(801, true)).toBe(376.5); + }); + + it("subtracts only grid padding in a single-column layout", () => { + expect(getAdvancedColumnWidth(799, false)).toBe(767); + }); +}); diff --git a/packages/widgets/src/tracearr/component.tsx b/packages/widgets/src/tracearr/component.tsx index f026b5710..17f6df5bc 100644 --- a/packages/widgets/src/tracearr/component.tsx +++ b/packages/widgets/src/tracearr/component.tsx @@ -14,6 +14,10 @@ import { StatsBar } from "./stats-section"; import { StreamsList } from "./streams-section"; import { ViolationsList } from "./violations-section"; +const ADVANCED_GRID_BREAKPOINT = 800; +const ADVANCED_GRID_GAP_PX = 16; +const ADVANCED_GRID_PADDING_PX = 16; + export default function TracearrWidget({ options, integrationIds, @@ -122,14 +126,20 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } const recentActivity = combined.recentActivity?.data ?? []; if (displayMode === "advanced") { + const isTwoColumn = width >= ADVANCED_GRID_BREAKPOINT; + const columnWidth = getAdvancedColumnWidth(width, isTwoColumn); return ( - = 800 ? 2 : 1} spacing="md" p="md"> + {options.showStats && ( - + )} - {options.showStreams && } + {options.showStreams && } {options.showViolations && } @@ -156,3 +166,8 @@ function TracearrContent({ integrationIds, options, width, height, displayMode } ); } + +export const getAdvancedColumnWidth = (width: number, isTwoColumn: boolean) => { + const contentWidth = Math.max(0, width - ADVANCED_GRID_PADDING_PX * 2); + return isTwoColumn ? Math.max(0, (contentWidth - ADVANCED_GRID_GAP_PX) / 2) : contentWidth; +}; diff --git a/packages/widgets/src/umami/component.tsx b/packages/widgets/src/umami/component.tsx index 73ccaf679..c44fb8566 100644 --- a/packages/widgets/src/umami/component.tsx +++ b/packages/widgets/src/umami/component.tsx @@ -5,6 +5,7 @@ import { Stack, Text } from "@mantine/core"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { setWidgetRuntimeQueries } from "../definition"; import { UmamiContent } from "./umami-content"; export default function UmamiWidget({ @@ -12,11 +13,55 @@ export default function UmamiWidget({ integrationIds, width, displayMode = "compact", + widgetStateRef, }: WidgetComponentProps<"umami">) { if (!options.websiteId || options.websiteId.trim() === "") { + setWidgetRuntimeQueries(widgetStateRef, []); return ; } + const integrationId = integrationIds[0] ?? ""; + const commonTopInput = { + integrationId, + websiteId: options.websiteId, + timeFrame: options.timeFrame, + limit: options.topCount, + }; + setWidgetRuntimeQueries(widgetStateRef, [ + { + path: ["widget", "umami", "getVisitorStats"], + input: { + integrationIds, + websiteId: options.websiteId, + timeFrame: options.timeFrame, + eventName: options.eventName || undefined, + }, + }, + { + path: ["widget", "umami", "getActiveVisitors"], + input: { integrationId, websiteId: options.websiteId }, + }, + ...(options.viewMode === "events" + ? [ + { + path: ["widget", "umami", "getMultiEventTimeSeries"], + input: { + integrationId, + websiteId: options.websiteId, + timeFrame: options.timeFrame, + eventNames: [...options.eventNames].toSorted(), + }, + }, + ] + : []), + ...(displayMode === "advanced" || options.viewMode === "topPages" + ? [{ path: ["widget", "umami", "getTopPages"], input: commonTopInput }] + : []), + ...(displayMode === "advanced" || options.viewMode === "topReferrers" + ? [{ path: ["widget", "umami", "getTopReferrers"], input: commonTopInput }] + : []), + ]); + return ( = 900 ? 2 : 1} spacing="md" h="100%"> {selectedView} - - - + + {viewMode !== "topPages" && ( + + )} + {viewMode !== "topReferrers" && ( + + )} ) : ( diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index b77d66afb..b4fbd6be9 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -201,7 +201,8 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode {monitor.name} - {t("status.down")} · {formatNumber(monitor.uptimePercent24h ?? 0, 1)}% + {t("status.down")} ·{" "} + {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`} ))} diff --git a/packages/widgets/src/weather/component.tsx b/packages/widgets/src/weather/component.tsx index 445f9bc06..2f96589f5 100644 --- a/packages/widgets/src/weather/component.tsx +++ b/packages/widgets/src/weather/component.tsx @@ -235,7 +235,7 @@ function Forecast({ {showDetails && ( - {getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, true)} + {getPreferredUnit(dayWeather.minTemp, options.isFormatFahrenheit, options.disableTemperatureDecimals)} )} diff --git a/packages/widgets/src/weather/index.ts b/packages/widgets/src/weather/index.ts index 6c30b6df8..978c9a855 100644 --- a/packages/widgets/src/weather/index.ts +++ b/packages/widgets/src/weather/index.ts @@ -2,11 +2,22 @@ import { IconCloud } from "@tabler/icons-react"; import dayjs from "dayjs"; import { z } from "zod/v4"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("weather", { icon: IconCloud, + queryKey: [["widget", "weather", "atLocation"]], + queryMatcher: ({ input }, scope) => { + const location = scope.options.location; + if (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) => ({