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/calendar/index.mdx b/apps/docs/docs/widgets/calendar/index.mdx
index 3e1e3f180..174391e18 100644
--- a/apps/docs/docs/widgets/calendar/index.mdx
+++ b/apps/docs/docs/widgets/calendar/index.mdx
@@ -74,6 +74,7 @@ import withoutIntegrations from "./img/without-integrations.png";
- Hover over a marked date to preview its events in a popover.
- Click on a marked date to pin the popover open (it will stay visible even after moving the mouse away).
- Click the date again or click outside to close the pinned popover.
+- If one selected integration is unavailable, its warning indicator remains visible while events from healthy integrations continue to load.
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/docs/docs/widgets/media-request-stats/index.mdx b/apps/docs/docs/widgets/media-request-stats/index.mdx
index b659f9e27..fe8055826 100644
--- a/apps/docs/docs/widgets/media-request-stats/index.mdx
+++ b/apps/docs/docs/widgets/media-request-stats/index.mdx
@@ -43,6 +43,7 @@ import mediaRequestStats from "./img/stats.png";
### Interactions
- 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 while totals from healthy integrations remain visible.
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}
);
}