diff --git a/apps/docs/docs/management/api/index.mdx b/apps/docs/docs/management/api/index.mdx
index 2c5bd42e2..833cfd2d5 100644
--- a/apps/docs/docs/management/api/index.mdx
+++ b/apps/docs/docs/management/api/index.mdx
@@ -83,3 +83,10 @@ The OpenAPI and MCP APIs include board management endpoints for common automatio
- Configure global board defaults such as public home boards and status behavior
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.
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/docs/docs/widgets/media-server/index.mdx b/apps/docs/docs/widgets/media-server/index.mdx
index 685032f90..3749cd6ae 100644
--- a/apps/docs/docs/widgets/media-server/index.mdx
+++ b/apps/docs/docs/widgets/media-server/index.mdx
@@ -25,6 +25,7 @@ The media server widget shows active sessions across your media servers.
It shows playback progress, pause state, time remaining, and whether each session is direct play, direct stream, or transcoding audio or video. Location and bitrate can be toggled independently.
Select a session to inspect its media metadata. Advanced mode adds search, sorting, and a summary of playing and transcoding sessions while keeping separate integration instances identifiable.
+If one selected server is unavailable, a warning indicator identifies the degraded result while sessions from healthy servers remain usable.
### Screenshots
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 2e05e7ece..1290408ab 100644
--- a/packages/widgets/src/audio-stats/component.tsx
+++ b/packages/widgets/src/audio-stats/component.tsx
@@ -8,6 +8,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";
@@ -47,9 +48,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 (
-
+
-
+
@@ -136,7 +136,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 b393ba24c..cef9b9458 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 { getQueryKey } from "@trpc/react-query";
@@ -15,6 +15,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";
@@ -25,7 +26,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">)
if (props.integrationIds.length === 0) {
setWidgetRuntimeQueries(props.widgetStateRef, []);
- return ;
+ return ;
}
return ;
@@ -56,11 +57,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();
@@ -105,6 +120,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/definition.ts b/packages/widgets/src/definition.ts
index c4c1bccce..d4e350451 100644
--- a/packages/widgets/src/definition.ts
+++ b/packages/widgets/src/definition.ts
@@ -120,6 +120,15 @@ export const getWidgetQueryKeys = (definition: {
const runtimeQueriesStateKey = "__homarrRuntimeQueries";
+export 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 };
+};
+
export const setWidgetRuntimeQueries = (
widgetStateRef: React.MutableRefObject | null> | undefined,
queryKeys: readonly QueryKey[],
@@ -127,12 +136,8 @@ export const setWidgetRuntimeQueries = (
if (!widgetStateRef) return;
const state = widgetStateRef.current ?? {};
state[runtimeQueriesStateKey] = queryKeys.flatMap((queryKey) => {
- const path = queryKey[0];
- if (!Array.isArray(path) || !path.every((part): part is string => typeof part === "string")) return [];
-
- const queryKeyOptions = queryKey[1];
- const input = isRecord(queryKeyOptions) && "input" in queryKeyOptions ? queryKeyOptions.input : undefined;
- return [{ path, input }];
+ const query = normalizeWidgetQuery(queryKey);
+ return query ? [query] : [];
});
widgetStateRef.current = state;
};
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 fd65be9c2..dd870cf22 100644
--- a/packages/widgets/src/index.tsx
+++ b/packages/widgets/src/index.tsx
@@ -74,7 +74,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 && }
["metadata"]>["transcoding"];
@@ -305,6 +306,7 @@ export default function MediaServerWidget({
0,
);
const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null;
+ const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error));
return (
@@ -317,6 +319,14 @@ export default function MediaServerWidget({
{t("summary.transcoding", { count: transcodingCount })}
+
+
+
+
+ )}
+ {!isAdvanced && hasFailedIntegrations && (
+
+
)}
diff --git a/packages/widgets/src/traefik/component.tsx b/packages/widgets/src/traefik/component.tsx
index e73de954c..42d2d013b 100644
--- a/packages/widgets/src/traefik/component.tsx
+++ b/packages/widgets/src/traefik/component.tsx
@@ -146,48 +146,61 @@ function TraefikWidgetContent({
(left, right) =>
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 b2a1c9915..602922b7e 100644
--- a/packages/widgets/src/umami/component.tsx
+++ b/packages/widgets/src/umami/component.tsx
@@ -14,6 +14,7 @@ export default function UmamiWidget({
options,
integrationIds,
width,
+ height,
displayMode = "compact",
widgetStateRef,
}: WidgetComponentProps<"umami">) {
@@ -75,6 +76,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}
);
}