diff --git a/apps/docs/docs/management/boards/index.mdx b/apps/docs/docs/management/boards/index.mdx index 75c8c6e6f..02ca0e6c9 100644 --- a/apps/docs/docs/management/boards/index.mdx +++ b/apps/docs/docs/management/boards/index.mdx @@ -36,13 +36,13 @@ Underscores and hyphen-minus are allowed. ### Column count -Homarr has a complex drag and drop system that can be used from the browser. +Homarr has a complex drag-and-drop system that can be used from the browser. The column count defines how many horizontal columns are available to drag and drop elements to. ### Public This will enable public access for everyone - regardless of whether they are logged in or not. -This can be really useful if you want to have public boards in your company or for family and fiends. +This can be really useful if you want to have public boards in your company or for family and friends. Modifications for public boards must be allowed explicitly using user groups and their permissions. ## Board settings @@ -106,7 +106,7 @@ Amount of columns for this layout. Defines how many items can be placed in a row #### Breakpoint Minimum width of the screen where the layout will be used. -Once the minmum of the next layout is reached the layout will change. +Once the minimum width of the next layout is reached, the layout will change. #### Preview diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx index 5ef772034..02de33b5a 100644 --- a/apps/docs/docs/widgets/bookmarks/index.mdx +++ b/apps/docs/docs/widgets/bookmarks/index.mdx @@ -21,12 +21,12 @@ You can add links to your favorite applications, websites, or any other resource import gridIconsOnly from "./img/grid-icons-only.png"; import gridWithTitle from "./img/grid-with-title.png"; import verticalListAll from "./img/vertical-list-all.png"; -import vericalListSimple from "./img/vertical-list-simple.png"; +import verticalListSimple from "./img/vertical-list-simple.png";
bookmarks grid icons-only - bookmarks vertical-list no-domain + bookmarks vertical-list no-domain
bookmarks grid with-title diff --git a/apps/docs/docs/widgets/dns-hole-controls/index.mdx b/apps/docs/docs/widgets/dns-hole-controls/index.mdx index e6fd43e15..c20ba99f9 100644 --- a/apps/docs/docs/widgets/dns-hole-controls/index.mdx +++ b/apps/docs/docs/widgets/dns-hole-controls/index.mdx @@ -1,6 +1,6 @@ --- title: "DNS Hole Controls" -description: "Control PiHole or AdGuard from your dashboard." +description: "Control Pi-hole, AdGuard Home, or Technitium DNS from your dashboard." hide_title: true --- diff --git a/apps/docs/docs/widgets/iframe/index.mdx b/apps/docs/docs/widgets/iframe/index.mdx index 7d983342a..c988e0876 100644 --- a/apps/docs/docs/widgets/iframe/index.mdx +++ b/apps/docs/docs/widgets/iframe/index.mdx @@ -39,13 +39,14 @@ import youtubeStream from "./img/nasa-iss-stream.png"; Although you can integrate many websites, some may not work. This is because the administrators of those sites disallow iframes for security reasons, as users could easily be tricked -by embeding the original site on a malicious page. +by embedding the original site on a malicious page. Some proxies disallow iframes by default for this reason. -You can use https://iframetest.com to check, if the site is allowing iframes. +You can use https://iframetest.com to check whether the site allows iframes. If you manage all of your own applications, you can usually change that by configuring -the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes. +the `Content-Security-Policy` header and its `frame-ancestors` directive. The obsolete +`X-Frame-Options: ALLOW-FROM` directive is not supported by modern browsers. For example, to allow your site to be embedded in iframes from specific domains: @@ -53,14 +54,12 @@ For example, to allow your site to be embedded in iframes from specific domains: ```apache Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com" -Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com" ``` **Nginx:** ```nginx add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"; -add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com"; ``` ### Example URLs @@ -86,11 +85,11 @@ To create a transparent iframe like below you can use the following html code an ![transparent iframe](./img/transparent-iframe.png) -### Third party content +### Third-party content :::warning -The content below is an overview of third party projects. All support requests/problems must be redirected to the appropriate repository. +The content below is an overview of third-party projects. All support requests/problems must be redirected to the appropriate repository. We cannot guarantee the functionality nor security of these tools. ::: diff --git a/apps/docs/docs/widgets/media-releases/index.mdx b/apps/docs/docs/widgets/media-releases/index.mdx index 7142ccfc5..14695f0fe 100644 --- a/apps/docs/docs/widgets/media-releases/index.mdx +++ b/apps/docs/docs/widgets/media-releases/index.mdx @@ -1,6 +1,6 @@ --- title: "Media releases" -description: "Display newly added medias or upcoming releases from different integrations" +description: "Display newly added media or upcoming releases from different integrations" hide_title: true --- diff --git a/apps/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx index b381f177b..b659f9e27 100644 --- a/apps/docs/docs/widgets/media-request-stats/index.mdx +++ b/apps/docs/docs/widgets/media-request-stats/index.mdx @@ -19,7 +19,7 @@ import { overseerrIntegration } from "@site/docs/integrations/overseerr"; -Provide a statistics overview of your media requests, such as mount of requests, open requests, approvement and rejections. +Provide an overview of your media requests, including total requests, open requests, approvals, and rejections. ### Supported Integrations diff --git a/apps/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx index f76ce050b..69b565ad8 100644 --- a/apps/docs/docs/widgets/media-server/index.mdx +++ b/apps/docs/docs/widgets/media-server/index.mdx @@ -21,8 +21,8 @@ import { plexIntegration } from "@site/docs/integrations/plex"; -The media server widget enables you to watch and view current active sessions over all your media servers. -It also displays useful information, that you can use when providing support to users or debugging a problem. +The media server widget shows active sessions across your media servers. +It also displays useful information for user support and troubleshooting. ### Screenshots diff --git a/apps/docs/docs/widgets/minecraft-server-status/index.mdx b/apps/docs/docs/widgets/minecraft-server-status/index.mdx index 8a99cf8cf..bcc6e281e 100644 --- a/apps/docs/docs/widgets/minecraft-server-status/index.mdx +++ b/apps/docs/docs/widgets/minecraft-server-status/index.mdx @@ -26,7 +26,7 @@ import bedrockItem from "./img/bedrock.png";
minecraft-server-status java-edition - minecraft-server-status bedrock-editon + minecraft-server-status bedrock-edition
@@ -42,4 +42,4 @@ import bedrockItem from "./img/bedrock.png"; ### Data source We use the Minecraft Server Status API to fetch the server status. -If you need to whitelist the api, please use the following URL: `hhttps://api.mcsrvstat.us` +If you need to whitelist the API, use the following URL: `https://api.mcsrvstat.us`. diff --git a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx index 9f2c2b3d8..b52bf974b 100644 --- a/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx +++ b/apps/nextjs/src/app/[locale]/_client-providers/trpc.tsx @@ -34,7 +34,7 @@ import { createHeadersCallbackForSource, getTrpcUrl } from "@homarr/api/shared"; import { env } from "@homarr/common/env"; import { showWarningNotification } from "@homarr/notifications"; import type { WidgetDefinition } from "@homarr/widgets"; -import { widgetImports } from "@homarr/widgets"; +import { getWidgetQueryKeys, widgetImports } from "@homarr/widgets"; import { createWidgetQueryPersister } from "./query-cache-persister"; @@ -96,8 +96,11 @@ export function TRPCReactProvider(props: PropsWithChildren) { const def = definition as WidgetDefinition & { kind: string }; if (def.refetchInterval === undefined) continue; const interval = def.refetchInterval === null ? false : def.refetchInterval * 1000; - const primaryQueryKey = def.queryKey ?? [["widget", def.kind]]; - client.setQueryDefaults(primaryQueryKey, { refetchInterval: interval }); + for (const queryKey of getWidgetQueryKeys(def)) { + const path = queryKey[0]; + if (!Array.isArray(path) || path[0] !== "widget") continue; + client.setQueryDefaults(queryKey, { refetchInterval: interval }); + } } return client; }); 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-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx index ad3da32e7..2c89b14b9 100644 --- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx +++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx @@ -14,7 +14,7 @@ import { IconTrash, } from "@tabler/icons-react"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; -import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query"; +import { useIsFetching, useQueryClient } from "@tanstack/react-query"; import { clientApi } from "@homarr/api/client"; import { useIntegrationsWithInteractAccess, useSession } from "@homarr/auth/client"; @@ -28,8 +28,13 @@ import { useSettings } from "@homarr/settings"; import { translateIfNecessary } from "@homarr/translation"; import { useI18n, useScopedI18n } from "@homarr/translation/client"; import type { TranslationFunction } from "@homarr/translation"; -import type { WidgetContextMenuAction } from "@homarr/widgets"; -import { getWidgetQueryKeys, reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/widgets"; +import type { WidgetContextMenuAction, WidgetDefinition } from "@homarr/widgets"; +import { + getWidgetQueryKeys, + getWidgetRuntimeQueries, + reduceWidgetOptionsWithDefaultValues, + widgetImports, +} from "@homarr/widgets"; import { WidgetEditModal } from "@homarr/widgets/modals"; import type { SectionItem } from "~/app/[locale]/boards/_types"; @@ -38,6 +43,7 @@ import { useBoardPermissions } from "../permissions/client"; import { useSectionContext } from "../sections/section-context"; import { useItemActions } from "./item-actions"; import { ItemMoveModal } from "./item-move-modal"; +import { matchesWidgetItemQuery } from "./widget-query-scope"; interface WidgetContextMenuProps { item: SectionItem; @@ -70,13 +76,45 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: const integrationsWithInteractAccess = useIntegrationsWithInteractAccess(); const [menuOpened, setMenuOpened] = useState(false); + const persistBoard = useCallback( + (updater: (previous: typeof board) => typeof board) => { + updateAndPersistBoard(updater, { + onError: () => { + showErrorNotification({ + title: t("item.menu.notification.saveError.title"), + message: t("item.menu.notification.saveError.message"), + }); + }, + }); + }, + [t, updateAndPersistBoard], + ); + + const options = useMemo( + () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, + [item.kind, settings, item.options], + ); + const widgetQueryKeys = useMemo( () => getWidgetQueryKeys(currentDefinition as Parameters[0]), [currentDefinition], ); + const widgetQueryMatcher = (currentDefinition as WidgetDefinition).queryMatcher; const matchesWidgetQuery = useCallback( - (queryKey: QueryKey) => widgetQueryKeys.some((candidate) => partialMatchKey(queryKey, candidate)), - [widgetQueryKeys], + (queryKey: QueryKey) => + matchesWidgetItemQuery( + queryKey, + widgetQueryKeys, + { + itemId: item.id, + boardId: board.id, + integrationIds: item.integrationIds, + options, + runtimeQueries: getWidgetRuntimeQueries(widgetStateRef), + }, + widgetQueryMatcher, + ), + [board.id, item.id, item.integrationIds, options, widgetQueryKeys, widgetQueryMatcher, widgetStateRef], ); const isWidgetFetching = useIsFetching({ @@ -84,17 +122,29 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: predicate: (query) => matchesWidgetQuery(query.queryKey), }) > 0; const handleRefetch = useCallback(() => { - void Promise.all(widgetQueryKeys.map((queryKey) => queryClient.refetchQueries({ queryKey, type: "active" }))); - }, [queryClient, widgetQueryKeys]); + void queryClient.refetchQueries({ type: "active", predicate: (query) => matchesWidgetQuery(query.queryKey) }); + }, [matchesWidgetQuery, queryClient]); const canInteractWithSelectedIntegrations = useMemo(() => { const allowedIds = new Set(integrationsWithInteractAccess.map(({ id }) => id)); return item.integrationIds.length > 0 && item.integrationIds.every((id) => allowedIds.has(id)); }, [integrationsWithInteractAccess, item.integrationIds]); - const options = useMemo( - () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, - [item.kind, settings, item.options], + const setItemOptions = useCallback( + (partial: Record) => { + if (isEditMode) { + updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); + return; + } + + persistBoard((previous) => ({ + ...previous, + items: previous.items.map((boardItem) => + boardItem.id === item.id ? { ...boardItem, options: { ...boardItem.options, ...partial } } : boardItem, + ), + })); + }, + [isEditMode, item.id, options, persistBoard, updateItemOptions], ); type OptionDef = { type: string; skipContextMenu?: boolean }; @@ -110,9 +160,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: // eslint-disable-next-line @typescript-eslint/no-explicit-any const actions = (def.contextActions as any)({ options, - setOptions: (partial: Record) => { - updateItemOptions({ itemId: item.id, newOptions: { ...options, ...partial } }); - }, + setOptions: setItemOptions, integrationIds: item.integrationIds, context: { isEditMode, @@ -125,20 +173,6 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: return (Array.isArray(actions) ? actions : []) as WidgetContextMenuAction[]; })(); - const persistBoard = useCallback( - (updater: (previous: typeof board) => typeof board) => { - updateAndPersistBoard(updater, { - onError: () => { - showErrorNotification({ - title: t("item.menu.notification.saveError.title"), - message: t("item.menu.notification.saveError.message"), - }); - }, - }); - }, - [t, updateAndPersistBoard], - ); - const openEditModal = useCallback(() => { openModal( { @@ -195,19 +229,8 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: ]); const handleToggle = useCallback( - (key: string) => (checked: boolean) => { - if (isEditMode) { - updateItemOptions({ itemId: item.id, newOptions: { ...options, [key]: checked } }); - } else { - persistBoard((previous) => ({ - ...previous, - items: previous.items.map((boardItem) => - boardItem.id !== item.id ? boardItem : { ...boardItem, options: { ...boardItem.options, [key]: checked } }, - ), - })); - } - }, - [options, item.id, updateItemOptions, isEditMode, persistBoard], + (key: string) => (checked: boolean) => setItemOptions({ [key]: checked }), + [setItemOptions], ); if (!session) return <>{children}; @@ -306,7 +329,7 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: {tMenu("refresh")} @@ -350,15 +373,15 @@ export const WidgetContextMenu = ({ item, widgetStateRef, sourceRef, children }: interface WidgetQueryStatusProps { queryClient: QueryClient; - queryKeys: readonly QueryKey[]; + matchesQuery: (queryKey: QueryKey) => boolean; isFetching: boolean; t: TranslationFunction; } -const WidgetQueryStatus = ({ queryClient, queryKeys, isFetching, t }: WidgetQueryStatusProps) => { +const WidgetQueryStatus = ({ queryClient, matchesQuery, isFetching, t }: WidgetQueryStatusProps) => { const queries = queryClient.getQueryCache().findAll({ type: "active", - predicate: (query) => queryKeys.some((queryKey) => partialMatchKey(query.queryKey, queryKey)), + predicate: (query) => matchesQuery(query.queryKey), }); const timestamps = queries.map((query) => query.state.dataUpdatedAt).filter(Boolean); const latest = timestamps.length > 0 ? Math.max(...timestamps) : 0; 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 56fa10fd1..60250fe5d 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"; @@ -13,6 +14,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(), @@ -35,14 +44,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 dc2853b49..4ee2e61ae 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -3712,6 +3712,9 @@ "error": { "internalServerError": "Unable to get data from firewall" }, + "empty": { + "noInterfaces": "No network interface data yet" + }, "versionUnknown": "Unknown version", "metricAccessible": "{metric}: {value}%, {status}", "status": { 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/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 14ac594df..98d9c6634 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -24,6 +24,7 @@ import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { setWidgetRuntimeQueries } from "../definition"; import type { BeszelSystemRow } from "../beszel/_shared/types"; import { loadAvgColor, statusColorMap, thresholdColor } from "../beszel/_shared/colors"; import { @@ -77,6 +78,7 @@ export default function BeszelSystemTableWidget({ isEditMode, width, displayMode = "compact", + widgetStateRef, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); const { openModal } = useModalAction(BeszelSystemStatsModal); @@ -319,6 +321,25 @@ export default function BeszelSystemTableWidget({ openModal({ integrationId, systemId: record.id }, { title: record.name }); }; + const activeSystem = selectedSystem ?? sortedSystems[0]; + const activeIntegrationId = activeSystem?._key.split(":")[0] ?? ""; + setWidgetRuntimeQueries( + widgetStateRef, + displayMode === "advanced" && activeSystem + ? [ + { + path: ["widget", "beszel", "getSystemStats"], + input: { + integrationIds: [activeIntegrationId], + systemId: activeSystem.id, + timePeriod: "1h", + includeDocker: false, + }, + }, + ] + : [], + ); + if (systemsError) throw systemsError; if (isPending) { @@ -355,8 +376,6 @@ export default function BeszelSystemTableWidget({ if (displayMode === "compact") return table; - const activeSystem = selectedSystem ?? sortedSystems[0]; - const activeIntegrationId = activeSystem?._key.split(":")[0] ?? ""; return (
diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index ba90cb5e9..8570434ff 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/component.tsx b/packages/widgets/src/clock/component.tsx index dad04fe80..091115bd8 100644 --- a/packages/widgets/src/clock/component.tsx +++ b/packages/widgets/src/clock/component.tsx @@ -8,6 +8,8 @@ import timezones from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; import { clientApi } from "@homarr/api/client"; +import { metricToImperial } from "@homarr/common"; +import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { AnimatedWeatherIcon } from "../weather/animated-icon"; @@ -77,6 +79,7 @@ interface ClockWeatherCornerProps { } const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: ClockWeatherCornerProps) => { + const tCommon = useScopedI18n("common"); const { data: weather } = clientApi.widget.weather.atLocation.useQuery({ latitude, longitude }); if (!weather) return null; @@ -93,7 +96,8 @@ const ClockWeatherCorner = ({ latitude, longitude, isFahrenheit, showDetails }: {showDetails && ( - {Math.round(weather.current.windspeed)} km/h + {Math.round(isFahrenheit ? metricToImperial(weather.current.windspeed) : weather.current.windspeed)}{" "} + {isFahrenheit ? tCommon("unit.speed.milesPerHour") : tCommon("unit.speed.kilometersPerHour")} )} 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 4c6ae6654..2fc7e1849 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/definition.ts b/packages/widgets/src/definition.ts index 775963b28..3b1f1129d 100644 --- a/packages/widgets/src/definition.ts +++ b/packages/widgets/src/definition.ts @@ -1,6 +1,7 @@ import type React from "react"; import type { LoaderComponent } from "next/dynamic"; import type { QueryClient, QueryKey } from "@tanstack/react-query"; +import { hashKey } from "@tanstack/react-query"; import type { DefaultErrorData } from "@trpc/server/unstable-core-do-not-import"; import type { IntegrationKind, WidgetKind } from "@homarr/definitions"; @@ -29,6 +30,21 @@ export interface WidgetContextMenuContext { canInteractWithSelectedIntegrations: boolean; } +export interface NormalizedWidgetQuery { + path: readonly string[]; + input: unknown; +} + +export interface WidgetQueryMatcherScope { + itemId: string; + boardId: string | undefined; + integrationIds: readonly string[]; + options: Record; + runtimeQueries: readonly NormalizedWidgetQuery[]; +} + +export type WidgetQueryMatcher = (query: NormalizedWidgetQuery, scope: WidgetQueryMatcherScope) => boolean; + export interface WidgetContextActionProps< TKind extends WidgetKind, TOptions extends WidgetOptionsRecord = WidgetOptionsRecord, @@ -72,6 +88,7 @@ export interface WidgetDefinition { icon: TablerIcon; queryKey?: QueryKey; queryKeys?: readonly QueryKey[]; + queryMatcher?: WidgetQueryMatcher; refetchInterval?: number | null; supportedIntegrations?: IntegrationKind[]; integrationsRequired?: boolean; @@ -101,6 +118,39 @@ export const getWidgetQueryKeys = (definition: { return [definition.queryKey ?? [["widget", definition.kind]]]; }; +const runtimeQueriesStateKey = "__homarrRuntimeQueries"; + +export const setWidgetRuntimeQueries = ( + widgetStateRef: React.MutableRefObject | null> | undefined, + queries: readonly NormalizedWidgetQuery[], +) => { + if (!widgetStateRef) return; + const state = widgetStateRef.current ?? {}; + state[runtimeQueriesStateKey] = queries; + widgetStateRef.current = state; +}; + +export const getWidgetRuntimeQueries = ( + widgetStateRef: React.MutableRefObject | null>, +): readonly NormalizedWidgetQuery[] => { + const queries = widgetStateRef.current?.[runtimeQueriesStateKey]; + return Array.isArray(queries) ? (queries as NormalizedWidgetQuery[]) : []; +}; + +export const matchesWidgetRuntimeQuery: WidgetQueryMatcher = (query, scope) => + scope.runtimeQueries.some( + (runtimeQuery) => + widgetQueryValueEquals(runtimeQuery.path, query.path) && widgetQueryValueEquals(runtimeQuery.input, query.input), + ); + +export const widgetQueryInputMatches = (input: unknown, expected: Record) => + isRecord(input) && Object.entries(expected).every(([key, value]) => widgetQueryValueEquals(input[key], value)); + +export const widgetQueryValueEquals = (left: unknown, right: unknown) => hashKey([left]) === hashKey([right]); + +const isRecord = (value: unknown): value is Record => + value !== null && typeof value === "object" && !Array.isArray(value); + export interface WidgetProps { options: inferOptionsFromCreator>; integrationIds: string[]; 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 f0e2e1bcf..fd65be9c2 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -66,12 +66,15 @@ import * as weather from "./weather"; import * as customApi from "./custom-api"; export type { + NormalizedWidgetQuery, WidgetDefinition, WidgetContextMenuAction, WidgetContextActionProps, WidgetOptionsSettings, + WidgetQueryMatcher, + WidgetQueryMatcherScope, } from "./definition"; -export { getWidgetQueryKeys } from "./definition"; +export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition"; export type { WidgetComponentProps }; export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; diff --git a/packages/widgets/src/indexer-manager/component.tsx b/packages/widgets/src/indexer-manager/component.tsx index df0298b82..2868c3caf 100644 --- a/packages/widgets/src/indexer-manager/component.tsx +++ b/packages/widgets/src/indexer-manager/component.tsx @@ -33,7 +33,9 @@ export default function IndexerManagerWidget({ const hasSmallWidth = !isAdvanced && width < 256; const hasSmallHeight = !isAdvanced && height < 256; const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]); - const unavailableCount = allIndexers.filter((indexer) => !indexer.status || !indexer.enabled).length; + const unavailableCount = allIndexers.filter( + (indexer) => indexer.status === false || indexer.enabled === false, + ).length; useEffect(() => { if (!widgetStateRef) return; diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index b48ef301a..861d1b7f8 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -15,9 +15,9 @@ export const { definition, componentLoader } = createWidgetDefinition("indexerMa disabled: context.isEditMode || !context.canInteractWithSelectedIntegrations || - typeof widgetStateRef.current?.testAllIndexers !== "function", + typeof widgetStateRef?.current?.testAllIndexers !== "function", onClick: () => { - const action = widgetStateRef.current?.testAllIndexers; + const action = widgetStateRef?.current?.testAllIndexers; if (typeof action === "function") action(); }, }, diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index b5bb9d320..3fc2f8684 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; +import { setWidgetRuntimeQueries } from "../definition"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; import type { MediaMissingTab } from "./tabs"; @@ -37,14 +38,18 @@ export default function MediaMissingWidget({ width, height, displayMode, + widgetStateRef, }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const isAdvanced = displayMode === "advanced"; const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize); - const { data } = clientApi.widget.mediaOrganizer.getData.useQuery( - { integrationIds, pageSize }, - { staleTime: 60 * 1000, refetchOnWindowFocus: false, refetchOnReconnect: false }, - ); + const input = { integrationIds, pageSize }; + setWidgetRuntimeQueries(widgetStateRef, [{ path: ["widget", "mediaOrganizer", "getData"], input }]); + const { data } = clientApi.widget.mediaOrganizer.getData.useQuery(input, { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); const [selectedTab, setSelectedTab] = useState(options.showMissing ? "missing" : "queued"); const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued); diff --git a/packages/widgets/src/media-missing/index.ts b/packages/widgets/src/media-missing/index.ts index fd325f801..69a6ecf73 100644 --- a/packages/widgets/src/media-missing/index.ts +++ b/packages/widgets/src/media-missing/index.ts @@ -2,14 +2,15 @@ import { IconMovie } from "@tabler/icons-react"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; -import { createWidgetDefinition } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery } from "../definition"; import { optionsBuilder } from "../options"; const pageSizeOptions = ["10", "20", "30", "50"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaMissing", { icon: IconMovie, - queryKey: [["widget", "mediaOrganizer"]], + queryKey: [["widget", "mediaOrganizer", "getData"]], + queryMatcher: matchesWidgetRuntimeQuery, createOptions() { return optionsBuilder.from((factory) => ({ showMissing: factory.switch({ defaultValue: true }), diff --git a/packages/widgets/src/media-requests/list/index.ts b/packages/widgets/src/media-requests/list/index.ts index bcc5dd4fd..f8f90a10f 100644 --- a/packages/widgets/src/media-requests/list/index.ts +++ b/packages/widgets/src/media-requests/list/index.ts @@ -4,14 +4,23 @@ import { z } from "zod/v4"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { openMediaRequestSearch } from "@homarr/spotlight"; -import { createWidgetDefinition } from "../../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../../definition"; import { optionsBuilder } from "../../options"; const mediaRequestStatusValues = ["pending", "approved", "declined", "failed", "completed"] as const; export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestList", { icon: IconZoomQuestion, - queryKey: [["widget", "mediaRequests"]], + queryKey: [["widget", "mediaRequests", "getLatestRequests"]], + queryMatcher: ({ input }, scope) => + widgetQueryInputMatches(input, { + integrationIds: scope.integrationIds, + statuses: + Array.isArray(scope.options.statusFilter) && scope.options.statusFilter.length > 0 + ? scope.options.statusFilter + : mediaRequestStatusValues, + recentDays: scope.options.recentDays, + }), createOptions() { return optionsBuilder.from((factory) => ({ linksTargetNewTab: factory.switch({ diff --git a/packages/widgets/src/media-requests/stats/index.ts b/packages/widgets/src/media-requests/stats/index.ts index 5efbd8909..2f3929877 100644 --- a/packages/widgets/src/media-requests/stats/index.ts +++ b/packages/widgets/src/media-requests/stats/index.ts @@ -7,7 +7,7 @@ import { createWidgetDefinition } from "../../definition"; export const { componentLoader, definition } = createWidgetDefinition("mediaRequests-requestStats", { icon: IconChartBar, - queryKey: [["widget", "mediaRequests"]], + queryKey: [["widget", "mediaRequests", "getStats"]], createOptions() { return {}; }, diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 54f00476c..e77a61c97 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/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 17fd13393..0f34f5609 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -2,11 +2,17 @@ import { IconMessage } 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("notifications", { icon: IconMessage, + queryKey: [["widget", "notifications", "getNotifications"]], + queryMatcher: ({ input }, scope) => + widgetQueryInputMatches(input, { + hideLogos: scope.options.hideLogos, + integrationIds: scope.integrationIds, + }), refetchInterval: null, createOptions() { return optionsBuilder.from((factory) => ({ 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 d2346e6ae..4b3c6c0bc 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) => ({