diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx index 145e221ea..359d189e6 100644 --- a/apps/docs/docs/management/api/index.mdx +++ b/apps/docs/docs/management/api/index.mdx @@ -84,6 +84,13 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio Use the interactive API reference to inspect the exact request body for each endpoint under the `boards` and `settings` tags. +## Partial integration failures + +Multi-integration widget queries keep data from integrations that remain available. Calendar and media-server results include +an optional `error` on a failed integration; media-request responses expose a `failedIntegrations` array, and the latest +requests endpoint returns its items in `requests`. When every selected integration fails, the query returns an error instead +of a partial response. + ### Saving dashboard layouts The dashboard editor saves item positions and sections as one board update. diff --git a/apps/docs/docs/widgets/media-request-list/index.mdx b/apps/docs/docs/widgets/media-request-list/index.mdx index 68f3a27ab..4bd3aa6d1 100644 --- a/apps/docs/docs/widgets/media-request-list/index.mdx +++ b/apps/docs/docs/widgets/media-request-list/index.mdx @@ -46,6 +46,7 @@ import mediaRequestList from "./img/list.png"; - Approve / decline a request by clicking on the thumbs up or down icon. - Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations. +- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations. diff --git a/apps/nextjs/src/components/board/items/widget-query-scope.ts b/apps/nextjs/src/components/board/items/widget-query-scope.ts index 03ae7a1d3..fd7c9edb3 100644 --- a/apps/nextjs/src/components/board/items/widget-query-scope.ts +++ b/apps/nextjs/src/components/board/items/widget-query-scope.ts @@ -1,7 +1,7 @@ import type { QueryKey } from "@tanstack/react-query"; -import { hashKey } from "@tanstack/react-query"; -import type { NormalizedWidgetQuery, WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets"; +import type { WidgetQueryMatcher, WidgetQueryMatcherScope } from "@homarr/widgets"; +import { normalizeWidgetQuery, widgetQueryValueEquals } from "@homarr/widgets"; export const matchesWidgetItemQuery = ( queryKey: QueryKey, @@ -16,15 +16,6 @@ export const matchesWidgetItemQuery = ( 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 ( @@ -36,7 +27,7 @@ const matchesDefinitionPath = (path: readonly string[], candidate: QueryKey) => const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => { if (input == null) return true; if (Array.isArray(input)) { - return isEqual(input, scope.integrationIds) || containsValue(scope.options, input); + return widgetQueryValueEquals(input, scope.integrationIds) || containsValue(scope.options, input); } if (!isRecord(input)) { @@ -62,7 +53,7 @@ const matchesQueryInput = (input: unknown, scope: WidgetQueryMatcherScope) => { continue; } if (key === "integrationIds") { - if (!isEqual(value, scope.integrationIds)) return false; + if (!widgetQueryValueEquals(value, scope.integrationIds)) return false; continue; } @@ -81,14 +72,12 @@ const matchesAnyScopedValue = (value: unknown, scope: WidgetQueryMatcherScope) = 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 (widgetQueryValueEquals(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.tsx b/apps/nextjs/src/components/board/sections/gridstack/board-empty-space-context-menu.tsx index cfd98717c..f1dfc2678 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 @@ -48,6 +48,15 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp const returnFocusRef = useRef(null); const enabled = hasChangeAccess && settings.enableRightClickOnWidgets; + const closeMenu = (afterFocusRestore?: (returnFocus: HTMLElement | null) => void) => { + const returnFocus = returnFocusRef.current; + returnFocusRef.current = null; + setMenu(null); + requestAnimationFrame(() => { + returnFocus?.focus(); + afterFocusRestore?.(returnFocus); + }); + }; const clearLongPress = () => { if (longPressTimer.current !== null) { clearTimeout(longPressTimer.current); @@ -174,15 +183,11 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp { - if (!opened) { - setMenu(null); - const returnFocus = returnFocusRef.current; - returnFocusRef.current = null; - requestAnimationFrame(() => returnFocus?.focus()); - } + if (!opened) closeMenu(); }} withinPortal position="bottom-start" + returnFocus={false} > @@ -191,11 +196,20 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp } onClick={() => { - returnFocusRef.current = null; - if (menu) { - openModal({ placement: menu.placement, board, persistImmediately: !isEditMode }); - } - setMenu(null); + const placement = menu?.placement; + closeMenu((returnFocus) => { + if (!placement) return; + openModal( + { placement, board, persistImmediately: !isEditMode }, + { + onClose: () => { + requestAnimationFrame(() => { + if (returnFocus?.isConnected) returnFocus.focus(); + }); + }, + }, + ); + }); }} > {t("item.create.addAtPosition")} diff --git a/e2e/board-advanced-interactions.spec.ts b/e2e/board-advanced-interactions.spec.ts index 4eaa687db..417f913b3 100644 --- a/e2e/board-advanced-interactions.spec.ts +++ b/e2e/board-advanced-interactions.spec.ts @@ -183,20 +183,24 @@ describe("Board advanced interactions", () => { const addHereMenuItem = page.getByRole("menuitem", { name: "Add item here" }); await expect(grid).toHaveAttribute("aria-label", "Add item here"); - await grid.focus(); - await page.keyboard.press("Shift+F10"); + await grid.press("Shift+F10"); await expect(addHereMenuItem).toBeVisible(); + await page.keyboard.press("ArrowDown"); + await expect(addHereMenuItem).toBeFocused(); + await page.keyboard.press("Enter"); + await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); await page.keyboard.press("Escape"); - await expect(addHereMenuItem).toBeHidden(); await expect(grid).toBeFocused(); await touchAndHoldEmptyGridSpaceAsync(grid); await expect(addHereMenuItem).toBeVisible({ timeout: 1_000 }); await page.keyboard.press("Escape"); + await expect(addHereMenuItem).toBeHidden(); await rightClickEmptyGridSpaceAsync(grid); - await addHereMenuItem.click(); - await expect(page.getByRole("dialog").filter({ hasText: "Choose item to add" })).toBeVisible(); + await expect(addHereMenuItem).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(addHereMenuItem).toBeHidden(); } finally { await context.close(); } diff --git a/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts new file mode 100644 index 000000000..48d555167 --- /dev/null +++ b/packages/api/src/router/test/widgets/partial-integration-failures.spec.ts @@ -0,0 +1,175 @@ +// @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 type { IntegrationKind } from "@homarr/definitions"; +import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; +import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list"; + +import { calendarRouter } from "../../widgets/calendar"; +import { mediaRequestsRouter } from "../../widgets/media-requests"; +import { mediaServerRouter } from "../../widgets/media-server"; + +vi.hoisted(() => { + process.env.SKIP_ENV_VALIDATION = "true"; +}); + +const createHandler = (integration: { name: string }, data: T) => ({ + getDataAsync: async () => { + if (integration.name === "Offline") throw new Error("offline"); + return { data, timestamp: new Date() }; + }, +}); + +vi.mock("@homarr/request-handler/calendar", () => ({ + calendarMonthRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/media-server", () => ({ + mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) }, +})); + +vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mediaRequestListRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, [ + { + id: 42, + name: "Healthy request", + type: "movie", + backdropImageUrl: "https://example.com/backdrop.jpg", + posterImagePath: "https://example.com/poster.jpg", + href: "https://example.com/request/42", + createdAt: new Date("2026-08-01T12:00:00Z"), + status: "pending", + availability: "requested", + } satisfies MediaRequest, + ]), + }, + }; +}); + +vi.mock("@homarr/request-handler/media-request-stats", () => ({ + mediaRequestStatsRequestHandler: { + handler: (integration: { name: string }) => + createHandler(integration, { + stats: { total: 0, movie: 0, tv: 0, pending: 0, approved: 0, declined: 0, processing: 0, available: 0 }, + users: [ + { + id: 7, + displayName: "Healthy user", + avatar: "https://example.com/avatar.jpg", + requestCount: 3, + link: "https://example.com/user/7", + }, + ], + } satisfies MediaRequestStats), + }, +})); + +const createSession = (userId: string): Session => ({ + user: { id: userId, permissions: [], colorScheme: "light" }, + expires: new Date().toISOString(), +}); + +const setupAsync = async (kind: IntegrationKind) => { + const db = createDb(); + const userId = createId(); + const onlineId = createId(); + const offlineId = createId(); + await db.insert(users).values({ id: userId }); + await db.insert(integrations).values([ + { id: onlineId, kind, name: "Online", url: "https://online.example.com" }, + { id: offlineId, kind, name: "Offline", url: "https://offline.example.com" }, + ]); + await db.insert(integrationUserPermissions).values([ + { integrationId: onlineId, userId, permission: "use" }, + { integrationId: offlineId, userId, permission: "use" }, + ]); + return { + db: db as Database, + session: createSession(userId), + integrationIds: [onlineId, offlineId], + onlineId, + offlineId, + }; +}; + +beforeEach(() => vi.clearAllMocks()); + +describe("partial integration failures", () => { + test("calendar and media server retain a failed integration record", async () => { + const calendar = await setupAsync("sonarr"); + const calendarResults = await calendarRouter + .createCaller({ db: calendar.db, deviceType: undefined, session: calendar.session }) + .findAllEvents({ + integrationIds: calendar.integrationIds, + year: 2026, + month: 7, + releaseType: [], + showUnmonitored: false, + }); + expect(calendarResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ integration: expect.objectContaining({ id: calendar.offlineId }), error: "offline" }), + ]), + ); + + const mediaServer = await setupAsync("plex"); + const streamResults = await mediaServerRouter + .createCaller({ db: mediaServer.db, deviceType: undefined, session: mediaServer.session }) + .getCurrentStreams({ integrationIds: mediaServer.integrationIds, showOnlyPlaying: false }); + expect(streamResults).toEqual( + expect.arrayContaining([ + expect.objectContaining({ integrationId: mediaServer.offlineId, sessions: [], error: "offline" }), + ]), + ); + }); + + test("media request responses expose failed integrations without dropping successful data", async () => { + const mediaRequests = await setupAsync("overseerr"); + const caller = mediaRequestsRouter.createCaller({ + db: mediaRequests.db, + deviceType: undefined, + session: mediaRequests.session, + }); + + const latest = await caller.getLatestRequests({ + integrationIds: mediaRequests.integrationIds, + statuses: ["pending"], + recentDays: 0, + }); + expect(latest.requests).toEqual([ + expect.objectContaining({ + id: 42, + name: "Healthy request", + integrationId: mediaRequests.onlineId, + integration: expect.objectContaining({ id: mediaRequests.onlineId }), + }), + ]); + expect(latest.failedIntegrations).toEqual([ + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }), + ]); + + const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds }); + expect(stats.failedIntegrations).toEqual([ + expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "offline" }), + ]); + expect(stats.stats).toHaveLength(1); + expect(stats.users).toEqual([ + expect.objectContaining({ + id: 7, + displayName: "Healthy user", + integration: expect.objectContaining({ id: mediaRequests.onlineId }), + }), + ]); + }); +}); diff --git a/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts b/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts deleted file mode 100644 index 905ac00a4..000000000 --- a/packages/api/src/router/widgets/__tests__/beszel-failures.spec.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { everyBeszelIntegrationFailed } from "../beszel-failures"; - -describe("everyBeszelIntegrationFailed", () => { - it("keeps partial successes", () => { - expect(everyBeszelIntegrationFailed([{ error: "offline" }, {}])).toBe(false); - }); - - it("detects when every integration failed", () => { - expect(everyBeszelIntegrationFailed([{ error: "offline" }, { error: "timeout" }])).toBe(true); - }); - - it("allows an empty integration selection", () => { - expect(everyBeszelIntegrationFailed([])).toBe(false); - }); -}); diff --git a/packages/api/src/router/widgets/beszel-failures.ts b/packages/api/src/router/widgets/beszel-failures.ts deleted file mode 100644 index e7d75f87d..000000000 --- a/packages/api/src/router/widgets/beszel-failures.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const everyBeszelIntegrationFailed = (results: readonly unknown[]) => - results.length > 0 && - results.every( - (result) => typeof result === "object" && result !== null && "error" in result && result.error !== undefined, - ); diff --git a/packages/api/src/router/widgets/beszel.ts b/packages/api/src/router/widgets/beszel.ts index 1380bc405..8cfc3e166 100644 --- a/packages/api/src/router/widgets/beszel.ts +++ b/packages/api/src/router/widgets/beszel.ts @@ -13,7 +13,6 @@ import { import { settleIntegrationQueries } from "../../settle-integrations"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; import { createTRPCRouter, publicProcedure } from "../../trpc"; -import { everyBeszelIntegrationFailed } from "./beszel-failures"; import { BoundedAsyncQueue } from "./bounded-async-queue"; const logger = createLogger({ module: "beszelRouter" }); @@ -21,20 +20,6 @@ const MAX_PENDING_LIVE_EVENTS = 4; const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error)); -function throwIfEveryBeszelIntegrationFailed(results: readonly unknown[], operation: string) { - if (everyBeszelIntegrationFailed(results)) { - const firstError = results[0]; - throw new TRPCError({ - code: "INTERNAL_SERVER_ERROR", - message: `Every Beszel integration failed while loading ${operation}: ${ - typeof firstError === "object" && firstError !== null && "error" in firstError - ? String(firstError.error) - : "unknown error" - }`, - }); - } -} - export const beszelRouter = createTRPCRouter({ getSystems: publicProcedure .meta({ @@ -70,6 +55,7 @@ export const beszelRouter = createTRPCRouter({ updatedAt: new Date(0), error: errorMessage(error), }), + throwOnAllFailures: true, }, ); logger.debug("getSystems completed", { @@ -78,7 +64,6 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); - throwIfEveryBeszelIntegrationFailed(results, "systems"); return results; }), @@ -144,6 +129,7 @@ export const beszelRouter = createTRPCRouter({ updatedAt: new Date(0), error: errorMessage(error), }), + throwOnAllFailures: true, }, ); logger.debug("getAlerts completed", { @@ -152,7 +138,6 @@ export const beszelRouter = createTRPCRouter({ resultCount: results.length, errorCount: results.filter((r) => "error" in r).length, }); - throwIfEveryBeszelIntegrationFailed(results, "alerts"); return results; }), diff --git a/packages/api/src/router/widgets/calendar.ts b/packages/api/src/router/widgets/calendar.ts index 68d968412..26806cba9 100644 --- a/packages/api/src/router/widgets/calendar.ts +++ b/packages/api/src/router/widgets/calendar.ts @@ -27,19 +27,35 @@ export const calendarRouter = createTRPCRouter({ ) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("calendar"))) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { integrationIds: _integrationIds, ...handlerInput } = input; - const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); - const { data } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { integrationIds: _integrationIds, ...handlerInput } = input; + const innerHandler = calendarMonthRequestHandler.handler(integration, handlerInput); + const { data } = await innerHandler.getDataAsync(); - return { - events: data, - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - }, - }; - }); + return { + events: data, + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + events: [], + integration: { + id: integration.id, + name: integration.name, + kind: integration.kind, + }, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/api/src/router/widgets/media-requests.ts b/packages/api/src/router/widgets/media-requests.ts index eb13b25d1..8000df4b8 100644 --- a/packages/api/src/router/widgets/media-requests.ts +++ b/packages/api/src/router/widgets/media-requests.ts @@ -1,7 +1,9 @@ import { z } from "zod/v4"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { createIntegrationAsync } from "@homarr/integrations"; +import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types"; import { mediaRequestStatusConfiguration } from "@homarr/integrations/types"; import { mediaRequestListInputSchema, @@ -13,6 +15,12 @@ import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; +interface IntegrationQueryResult { + integration: { id: string; name: string; kind: IntegrationKindByCategory<"mediaRequest"> }; + data: TData; + error?: string; +} + export const mediaRequestsRouter = createTRPCRouter({ getLatestRequests: publicProcedure .meta({ @@ -25,11 +33,26 @@ export const mediaRequestsRouter = createTRPCRouter({ .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .input(mediaRequestListInputSchema) .query(async ({ ctx, input }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); - return results + const results: IntegrationQueryResult[] = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); + const requests = results .flatMap(({ data, integration }) => data.map((request) => ({ ...request, @@ -47,6 +70,12 @@ export const mediaRequestsRouter = createTRPCRouter({ mediaRequestStatusConfiguration[dataB.status].position ); }); + return { + requests, + failedIntegrations: results.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ), + }; }), getStats: publicProcedure .meta({ @@ -58,12 +87,30 @@ export const mediaRequestsRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); - return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data }; - }); + const results: IntegrationQueryResult<{ + stats: MediaRequestStats["stats"] | null; + users: MediaRequestStats["users"]; + }>[] = await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync(); + return { + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: { ...data, stats: data.stats as typeof data.stats | null }, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: { stats: null, users: [] }, + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); return { - stats: results.flatMap((result) => result.data.stats), + stats: results.flatMap((result) => (result.data.stats ? [result.data.stats] : [])), users: results .map((result) => result.data.users.map((user) => ({ @@ -74,6 +121,9 @@ export const mediaRequestsRouter = createTRPCRouter({ .flat() .toSorted(({ requestCount: countA }, { requestCount: countB }) => countB - countA), integrations: results.map((result) => result.integration), + failedIntegrations: results.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ), }; }), answerRequest: protectedProcedure diff --git a/packages/api/src/router/widgets/media-server.ts b/packages/api/src/router/widgets/media-server.ts index 80ce9018d..1ed6b9091 100644 --- a/packages/api/src/router/widgets/media-server.ts +++ b/packages/api/src/router/widgets/media-server.ts @@ -23,16 +23,30 @@ export const mediaServerRouter = createTRPCRouter({ .concat(createMediaServerIntegrationMiddleware("query")) .input(z.object({ showOnlyPlaying: z.boolean() })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data } = await mediaServerRequestHandler - .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) - .getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationKind: integration.kind, - sessions: data, - }; - }); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const { data } = await mediaServerRequestHandler + .handler(integration, { showOnlyPlaying: input.showOnlyPlaying }) + .getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: data, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationKind: integration.kind, + sessions: [], + error: error instanceof Error ? error.message : String(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/modals/src/index.tsx b/packages/modals/src/index.tsx index 02bc4cff4..0071185a8 100644 --- a/packages/modals/src/index.tsx +++ b/packages/modals/src/index.tsx @@ -125,6 +125,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => { interface OpenModalOptions { keepMounted?: boolean; + onClose?: () => void; title?: stringOrTranslation; } diff --git a/packages/widgets/src/audio-stats/component.tsx b/packages/widgets/src/audio-stats/component.tsx index ad246613f..0504cce25 100644 --- a/packages/widgets/src/audio-stats/component.tsx +++ b/packages/widgets/src/audio-stats/component.tsx @@ -7,6 +7,7 @@ import { formatBytes, formatDuration } from "@homarr/common"; import type { AudiobookshelfDashboardData } from "@homarr/integrations/types"; import { WidgetEmptyState } from "../common/empty-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import type { WidgetComponentProps } from "../definition"; import { setWidgetRuntimeQueries } from "../definition"; import { AudioStatsContent } from "./audio-stats-content"; @@ -46,9 +47,15 @@ export default function AudioStatsWidget({ const sessions = streamResults.flatMap((result) => result.sessions.map((session) => ({ integrationId: result.integrationId, session })), ); + const hasStreamFailures = streamResults.some(({ error }) => Boolean(error)); const audiobookStats = response.kind === "audiobookshelf" ? (response.data as AudiobookshelfDashboardData) : null; return ( + {hasStreamFailures && ( + + + + )}
{summary}
{response.kind === "navidrome" ? ( diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index f81835739..bf0f79416 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -12,7 +12,7 @@ import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; -import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; const alertIconMap: Record = { CPU: Cpu, @@ -85,7 +85,7 @@ export default function BeszelAlertsWidget({ return ( - + diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 9754622c4..ff26240b4 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -36,7 +36,7 @@ import { getProgressTrackSize, } from "../beszel/_shared/format"; import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; -import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; @@ -406,7 +406,7 @@ export default function BeszelSystemGridWidget({ if (filteredSystems.length === 0) { return ( - +
@@ -436,7 +436,7 @@ export default function BeszelSystemGridWidget({ return ( - + - +
@@ -134,7 +134,7 @@ export default function BeszelSystemStatsWidget({ return ( - + "error" in r && r.error); - if (failed.length === 0) return null; - const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", "); - return ( - - - - ); -} diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 4771fc96e..d1cc45045 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from "react"; import { useParams } from "next/navigation"; -import { Box, Stack, Text, useMantineTheme } from "@mantine/core"; +import { Box, Group, Stack, Text, useMantineTheme } from "@mantine/core"; import { Calendar } from "@mantine/dates"; import { useElementSize } from "@mantine/hooks"; import dayjs from "dayjs"; @@ -14,6 +14,7 @@ import { useSettings } from "@homarr/settings"; import type { WidgetComponentProps } from "../definition"; import { setWidgetRuntimeQueries } from "../definition"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { CalendarDay } from "./calender-day"; import { getCalendarAgendaEvents, groupEventsByDate, splitEvents, toCalendarDateKey } from "./calendar-events"; import { CalendarEventList } from "./calendar-event-list"; @@ -24,7 +25,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">) if (props.integrationIds.length === 0) { setWidgetRuntimeQueries(props.widgetStateRef, []); - return ; + return ; } return ; @@ -55,11 +56,16 @@ const FetchCalendar = ({ const { data } = clientApi.widget.calendar.findAllEvents.useQuery(input); const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); + const failedIntegrations = + data?.flatMap(({ integration, error }) => + error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], + ) ?? []; return ( void; options: WidgetComponentProps<"calendar">["options"]; displayMode?: WidgetComponentProps<"calendar">["displayMode"]; } -const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMode }: CalendarBaseProps) => { +const CalendarBase = ({ + isEditMode, + events, + failedIntegrations, + month, + setMonth, + options, + displayMode, +}: CalendarBaseProps) => { const params = useParams(); const locale = params.locale as string; const { firstDayOfWeek } = useSettings(); @@ -104,6 +119,11 @@ const CalendarBase = ({ isEditMode, events, month, setMonth, options, displayMod return ( + {failedIntegrations.length > 0 && ( + + + + )} setMonth(new Date(previousMonth))} diff --git a/packages/widgets/src/common/integration-error-indicator.tsx b/packages/widgets/src/common/integration-error-indicator.tsx new file mode 100644 index 000000000..567a1a01a --- /dev/null +++ b/packages/widgets/src/common/integration-error-indicator.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { Tooltip, VisuallyHidden } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; + +interface IntegrationResult { + integrationId: string; + integrationName?: string; + error?: string; +} + +interface IntegrationErrorIndicatorProps { + results: readonly IntegrationResult[]; +} + +export function IntegrationErrorIndicator({ results }: IntegrationErrorIndicatorProps) { + const t = useI18n(); + const failed = results.filter((result) => Boolean(result.error)); + if (failed.length === 0) return null; + + const names = failed.map((result) => result.integrationName ?? result.integrationId).join(", "); + const label = `${t("common.error")}: ${names}`; + return ( + + + + {label} + + + ); +} diff --git a/packages/widgets/src/health-monitoring/component.tsx b/packages/widgets/src/health-monitoring/component.tsx index d030e57ae..6fff62e24 100644 --- a/packages/widgets/src/health-monitoring/component.tsx +++ b/packages/widgets/src/health-monitoring/component.tsx @@ -41,8 +41,19 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal } if (props.displayMode === "advanced") { + if (props.width < 900) { + return ( + + + + {clusters} + + + ); + } + return ( - = 900 ? 2 : 1} spacing="sm" p="xs" h="100%"> + diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index b52ba7fc5..25a08ce8a 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -55,7 +55,8 @@ export const SystemHealthMonitoring = ({ integrationIds, width, displayMode, -}: WidgetComponentProps<"healthMonitoring">) => { + withScrollArea = true, +}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => { const t = useI18n(); const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds, @@ -69,7 +70,7 @@ export const SystemHealthMonitoring = ({ if (healthData.length === 0) return ; return ( - + {healthData.map(({ integrationId, integrationName, healthInfo }) => { const filteredFileSystem = filterStorageVolumes( diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 9af0bf20a..ba5aad490 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -75,7 +75,12 @@ export type { WidgetQueryMatcher, WidgetQueryMatcherScope, } from "./definition"; -export { getWidgetQueryKeys, getWidgetRuntimeQueries } from "./definition"; +export { + getWidgetQueryKeys, + getWidgetRuntimeQueries, + normalizeWidgetQuery, + widgetQueryValueEquals, +} from "./definition"; export type { WidgetComponentProps }; export type { WidgetOptionDefinition, WidgetOptionType } from "./options"; diff --git a/packages/widgets/src/media-requests/list/component.tsx b/packages/widgets/src/media-requests/list/component.tsx index b8760eb56..7017d3495 100644 --- a/packages/widgets/src/media-requests/list/component.tsx +++ b/packages/widgets/src/media-requests/list/component.tsx @@ -1,19 +1,6 @@ "use client"; -import { - ActionIcon, - Anchor, - Avatar, - Badge, - Box, - Card, - Group, - Image, - ScrollArea, - Stack, - Text, - Tooltip, -} from "@mantine/core"; +import { ActionIcon, Anchor, Avatar, Badge, Card, Group, Image, ScrollArea, Stack, Text, Tooltip } from "@mantine/core"; import { IconSearch, IconThumbDown, IconThumbUp } from "@tabler/icons-react"; import type { RouterInputs, RouterOutputs } from "@homarr/api"; @@ -27,6 +14,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "../search-button.module.css"; @@ -44,7 +32,7 @@ export default function MediaServerWidget({ .filter(({ id }) => integrationIds.includes(id)) .map(({ id }) => id), ); - const { data: mediaRequests } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ + const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({ integrationIds, statuses: options.statusFilter.length > 0 @@ -53,16 +41,22 @@ export default function MediaServerWidget({ recentDays: options.recentDays, }); - if (!mediaRequests) return ; - if (mediaRequests.length === 0) throw new NoIntegrationDataError(); + if (!mediaRequestData) return ; + const { requests: mediaRequests, failedIntegrations } = mediaRequestData; + if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError(); return ( - + {!isEditMode && } + {failedIntegrations.length > 0 && ( + + + + )} {mediaRequests.map((mediaRequest) => ( @@ -77,7 +71,7 @@ export default function MediaServerWidget({ ))} - + ); } @@ -100,7 +94,7 @@ const MediaRequestSearchButton = ({ integrationIds }: { integrationIds: string[] }; interface MediaRequestCardProps { - request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"][number]; + request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number]; isTiny: boolean; isAdvanced: boolean; canInteract: boolean; diff --git a/packages/widgets/src/media-requests/stats/component.tsx b/packages/widgets/src/media-requests/stats/component.tsx index be9f2796b..9ced6ceb2 100644 --- a/packages/widgets/src/media-requests/stats/component.tsx +++ b/packages/widgets/src/media-requests/stats/component.tsx @@ -22,6 +22,7 @@ import { openMediaRequestSearch } from "@homarr/spotlight"; import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../../common/empty-state"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import type { WidgetComponentProps } from "../../definition"; import { NoIntegrationDataError } from "../../errors/no-data-integration"; import classes from "./component.module.css"; @@ -45,7 +46,12 @@ export default function MediaServerWidget({ const board = useRequiredBoard(); if (!requestStats) return ; - if (requestStats.users.length === 0 && requestStats.stats.length === 0) throw new NoIntegrationDataError(); + if ( + requestStats.users.length === 0 && + requestStats.stats.length === 0 && + requestStats.failedIntegrations.length === 0 + ) + throw new NoIntegrationDataError(); const data = [ { @@ -99,6 +105,9 @@ export default function MediaServerWidget({ return ( + + + {!isEditMode && } resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name), ); - return ( - = 900 ? 2 : 1} spacing="md" h="100%" p="md"> - {summary} + const resourceList = ( + + {combined.failedEndpoints.map((endpoint) => ( + + {t("failedEndpoint", { endpoint })} + + ))} + {resources.map((resource, index) => ( + + + + {resource.name} + + + {resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} + + + + {t(`resourceStatus.${resource.status}`)} + + + ))} + + ); + + if (width < 900) { + return ( - - {combined.failedEndpoints.map((endpoint) => ( - - {t("failedEndpoint", { endpoint })} - - ))} - {resources.map((resource, index) => ( - - - - {resource.name} - - - {resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"} - - - - {t(`resourceStatus.${resource.status}`)} - - - ))} + + {summary} + {resourceList} + ); + } + + return ( + + {summary} + {resourceList} ); } diff --git a/packages/widgets/src/umami/component.tsx b/packages/widgets/src/umami/component.tsx index c44fb8566..8364c828e 100644 --- a/packages/widgets/src/umami/component.tsx +++ b/packages/widgets/src/umami/component.tsx @@ -12,6 +12,7 @@ export default function UmamiWidget({ options, integrationIds, width, + height, displayMode = "compact", widgetStateRef, }: WidgetComponentProps<"umami">) { @@ -74,6 +75,7 @@ export default function UmamiWidget({ viewMode={options.viewMode} topCount={options.topCount} width={width} + height={height} displayMode={displayMode} /> ); diff --git a/packages/widgets/src/umami/umami-content.tsx b/packages/widgets/src/umami/umami-content.tsx index ed048c1b4..1f562dd36 100644 --- a/packages/widgets/src/umami/umami-content.tsx +++ b/packages/widgets/src/umami/umami-content.tsx @@ -1,7 +1,7 @@ "use client"; import { BarChart, LineChart } from "@mantine/charts"; -import { Box, Group, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core"; +import { Box, Group, ScrollArea, SimpleGrid, Stack, Text, useMantineColorScheme } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { formatDuration } from "@homarr/common"; @@ -24,6 +24,7 @@ interface UmamiContentProps { viewMode: string; topCount: number; width: number; + height: number; displayMode: "compact" | "advanced"; } @@ -38,6 +39,7 @@ export function UmamiContent({ viewMode, topCount, width, + height, displayMode, }: UmamiContentProps) { const t = useScopedI18n("widget.umami"); @@ -149,6 +151,44 @@ export function UmamiContent({ /> ); + const advancedLists = ( + + {viewMode !== "topPages" && ( + + )} + {viewMode !== "topReferrers" && ( + + )} + + ); + + const advancedContent = + width < 900 ? ( + + + {selectedView} + {advancedLists} + + + ) : ( + + + {selectedView} + + {advancedLists} + + ); + return ( @@ -213,31 +253,7 @@ export function UmamiContent({ )} - {displayMode === "advanced" ? ( - = 900 ? 2 : 1} spacing="md" h="100%"> - {selectedView} - - {viewMode !== "topPages" && ( - - )} - {viewMode !== "topReferrers" && ( - - )} - - - ) : ( - selectedView - )} + {displayMode === "advanced" ? advancedContent : selectedView} ); diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index b4fbd6be9..efe60c72c 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { CSSProperties } from "react"; -import { Badge, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Group, RingProgress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { IconArrowDown, IconArrowUp, IconClockPause, IconServer } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; @@ -213,33 +213,46 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode if (displayMode === "compact") return summaryContent; - return ( - = 760 ? 2 : 1} spacing="lg" h="100%" p="md"> - {summaryContent} + const monitorList = ( + + {monitors + .toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status]) + .map((monitor) => ( + + + + {monitor.name} + + {dashboardData.length > 1 && ( + + {monitor.integrationName} + + )} + + + {t(`status.${monitor.status}`)} ·{" "} + {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`} + + + ))} + + ); + + if (width < 760) { + return ( - - {monitors - .toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status]) - .map((monitor) => ( - - - - {monitor.name} - - {dashboardData.length > 1 && ( - - {monitor.integrationName} - - )} - - - {t(`status.${monitor.status}`)} ·{" "} - {monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`} - - - ))} + + {summaryContent} + {monitorList} + ); + } + + return ( + + {summaryContent} + {monitorList} ); }