fix: complete widget modernization review

This commit is contained in:
Thomas Camlong committed 2026-08-02 08:05:04 +02:00
1 parent 79ae42f0c3
commit 6f27ede8bf
35 files changed
+621 -265

No files matched your search

+7
View File
@@ -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.
@@ -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.
<WidgetInteractionGuide />
@@ -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.
<WidgetInteractionGuide />
@@ -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.
<WidgetInteractionGuide />
@@ -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
@@ -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<object>()): 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<string, unknown> =>
value !== null && typeof value === "object" && !Array.isArray(value);
@@ -48,6 +48,15 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
const returnFocusRef = useRef<HTMLElement | null>(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
<Menu
opened={menu !== null}
onChange={(opened) => {
if (!opened) {
setMenu(null);
const returnFocus = returnFocusRef.current;
returnFocusRef.current = null;
requestAnimationFrame(() => returnFocus?.focus());
}
if (!opened) closeMenu();
}}
withinPortal
position="bottom-start"
returnFocus={false}
>
<Menu.Target>
<Box pos="fixed" left={menu?.x ?? 0} top={menu?.y ?? 0} w={1} h={1} style={{ pointerEvents: "none" }} />
@@ -191,11 +196,20 @@ export const BoardEmptySpaceContextMenu = ({ section, refs, children, ...boxProp
<Menu.Item
leftSection={<IconPlus size={18} />}
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")}
+9 -5
View File
@@ -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();
}
@@ -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 = <T>(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<typeof MediaRequestListHandlerModule>();
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 }),
}),
]);
});
});
@@ -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);
});
});
@@ -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,
);
+2 -17
View File
@@ -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;
}),
+29 -13
View File
@@ -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,
},
);
}),
});
@@ -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<TData> {
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<MediaRequest[]>[] = 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
+25 -11
View File
@@ -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,
},
);
}),
});
+1
View File
@@ -125,6 +125,7 @@ const ActiveModal = ({ modal, state, handleCloseModal }: ActiveModalProps) => {
interface OpenModalOptions {
keepMounted?: boolean;
onClose?: () => void;
title?: stringOrTranslation;
}
@@ -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 (
<Stack h="100%" gap="lg" p="md">
{hasStreamFailures && (
<Group justify="flex-end">
<IntegrationErrorIndicator results={streamResults} />
</Group>
)}
<div style={{ minHeight: 150 }}>{summary}</div>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
{response.kind === "navidrome" ? (
@@ -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<string, LucideIcon> = {
CPU: Cpu,
@@ -85,7 +85,7 @@ export default function BeszelAlertsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -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 (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -436,7 +436,7 @@ export default function BeszelSystemGridWidget({
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
@@ -16,7 +16,7 @@ import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
import type { BeszelTimePeriod } from "../beszel/_shared/chart";
import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicator";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -100,7 +100,7 @@ export default function BeszelSystemStatsWidget({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -136,7 +136,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={systemsResult} />
<IntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -40,7 +40,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";
import { BeszelStatsView } from "../beszel/_shared/stats-view";
@@ -426,7 +426,7 @@ export default function BeszelSystemTableWidget({
const table = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<BeszelIntegrationErrorIndicator results={results} />
<IntegrationErrorIndicator results={results} />
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -1,25 +0,0 @@
"use client";
import { Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface IntegrationResult {
integrationId: string;
integrationName?: string;
error?: string;
}
interface BeszelIntegrationErrorIndicatorProps {
results: IntegrationResult[];
}
export function BeszelIntegrationErrorIndicator({ results }: BeszelIntegrationErrorIndicatorProps) {
const failed = results.filter((r) => "error" in r && r.error);
if (failed.length === 0) return null;
const label = failed.map((r) => r.integrationName ?? r.integrationId).join(", ");
return (
<Tooltip label={label} position="left" withArrow>
<IconAlertTriangle size={14} color="var(--mantine-color-orange-6)" style={{ cursor: "help", flexShrink: 0 }} />
</Tooltip>
);
}
+23 -3
View File
@@ -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 <CalendarBase {...props} events={[]} month={month} setMonth={setMonth} />;
return <CalendarBase {...props} events={[]} failedIntegrations={[]} month={month} setMonth={setMonth} />;
}
return <FetchCalendar month={month} setMonth={setMonth} {...props} />;
@@ -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 (
<CalendarBase
isEditMode={isEditMode}
events={events}
failedIntegrations={failedIntegrations}
month={month}
setMonth={setMonth}
options={options}
@@ -72,13 +78,22 @@ const FetchCalendar = ({
interface CalendarBaseProps {
isEditMode: boolean;
events: CalendarEvent[];
failedIntegrations: { integrationId: string; integrationName: string; error: string }[];
month: Date;
setMonth: (date: Date) => 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 (
<Stack ref={ref} h="100%" w="100%" gap="xs" style={{ overflow: "hidden" }}>
{failedIntegrations.length > 0 && (
<Group px="xs" justify="flex-end">
<IntegrationErrorIndicator results={failedIntegrations} />
</Group>
)}
<Calendar
defaultDate={new Date()}
onPreviousMonth={(previousMonth) => setMonth(new Date(previousMonth))}
@@ -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 (
<Tooltip label={label} position="left" withArrow>
<span style={{ display: "inline-flex" }}>
<IconAlertTriangle
aria-hidden
size={14}
color="var(--mantine-color-orange-6)"
style={{ cursor: "help", flexShrink: 0 }}
/>
<VisuallyHidden>{label}</VisuallyHidden>
</span>
</Tooltip>
);
}
+11 -6
View File
@@ -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<Record<string, unknown> | 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;
};
@@ -41,8 +41,19 @@ export default function HealthMonitoringWidget(props: WidgetComponentProps<"heal
}
if (props.displayMode === "advanced") {
if (props.width < 900) {
return (
<ScrollArea h="100%">
<Stack gap="sm" p="xs">
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} withScrollArea={false} />
{clusters}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={props.width >= 900 ? 2 : 1} spacing="sm" p="xs" h="100%">
<SimpleGrid cols={2} spacing="sm" p="xs" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box style={{ minHeight: 0, overflow: "hidden" }}>
<SystemHealthMonitoring {...props} integrationIds={systemIntegrationIds} />
</Box>
@@ -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 <WidgetEmptyState />;
return (
<ScrollArea h="100%">
<ScrollArea h={withScrollArea ? "100%" : undefined}>
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
+6 -1
View File
@@ -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";
@@ -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 <WidgetEmptyState />;
if (mediaRequests.length === 0) throw new NoIntegrationDataError();
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
return (
<Box className={classes.searchRoot}>
<Stack className={classes.searchRoot} gap={0}>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
{failedIntegrations.length > 0 && (
<Group px="sm" pt="xs">
<IntegrationErrorIndicator results={failedIntegrations} />
</Group>
)}
<ScrollArea
className="mediaRequests-list-scrollArea"
scrollbarSize="md"
style={{ pointerEvents: isEditMode ? "none" : undefined }}
style={{ flex: 1, minHeight: 0, pointerEvents: isEditMode ? "none" : undefined }}
>
<Stack className="mediaRequests-list-list" gap="xs" p="sm">
{mediaRequests.map((mediaRequest) => (
@@ -77,7 +71,7 @@ export default function MediaServerWidget({
))}
</Stack>
</ScrollArea>
</Box>
</Stack>
);
}
@@ -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;
@@ -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 <WidgetEmptyState />;
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 (
<Box className={searchClasses.searchRoot}>
<Box pos="absolute" top="xs" left="xs" style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={requestStats.failedIntegrations} />
</Box>
{!isEditMode && <MediaRequestSearchButton integrationIds={integrationIds} />}
<Stack
className="mediaRequests-stats-layout"
@@ -25,6 +25,7 @@ import type { TablerIcon } from "@homarr/ui";
import { useTranslatedMantineReactTable } from "@homarr/ui/hooks";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
type TranscodingDecision = NonNullable<NonNullable<StreamSession["currentlyPlaying"]>["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 (
<Stack gap={0} h="100%" display="flex">
@@ -317,6 +319,14 @@ export default function MediaServerWidget({
<Badge variant="light" color="orange">
{t("summary.transcoding", { count: transcodingCount })}
</Badge>
<Group ml="auto">
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && hasFailedIntegrations && (
<Group h={18} px="xs" justify="flex-end">
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
<MantineReactTable table={table} />
+52 -39
View File
@@ -146,48 +146,61 @@ function TraefikWidgetContent({
(left, right) =>
resourceStatusOrder[left.status] - resourceStatusOrder[right.status] || left.name.localeCompare(right.name),
);
return (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%" p="md">
<ScrollArea h="100%">{summary}</ScrollArea>
const resourceList = (
<Stack gap="xs">
{combined.failedEndpoints.map((endpoint) => (
<Badge key={endpoint} color="red" variant="light">
{t("failedEndpoint", { endpoint })}
</Badge>
))}
{resources.map((resource, index) => (
<Group
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
justify="space-between"
wrap="nowrap"
p="xs"
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{resource.name}
</Text>
<Text size="xs" c="dimmed">
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
</Text>
</Stack>
<Badge
color={
resource.status === "error"
? "red"
: resource.status === "warning"
? "yellow"
: resource.status === "enabled"
? "green"
: "gray"
}
>
{t(`resourceStatus.${resource.status}`)}
</Badge>
</Group>
))}
</Stack>
);
if (width < 900) {
return (
<ScrollArea h="100%">
<Stack gap="xs">
{combined.failedEndpoints.map((endpoint) => (
<Badge key={endpoint} color="red" variant="light">
{t("failedEndpoint", { endpoint })}
</Badge>
))}
{resources.map((resource, index) => (
<Group
key={`${resource.protocol}:${resource.type}:${resource.name}:${index}`}
justify="space-between"
wrap="nowrap"
p="xs"
>
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{resource.name}
</Text>
<Text size="xs" c="dimmed">
{resource.protocol.toUpperCase()} · {t(`resourceType.${resource.type}`)} · {resource.provider ?? "—"}
</Text>
</Stack>
<Badge
color={
resource.status === "error"
? "red"
: resource.status === "warning"
? "yellow"
: resource.status === "enabled"
? "green"
: "gray"
}
>
{t(`resourceStatus.${resource.status}`)}
</Badge>
</Group>
))}
<Stack gap="md" p="md">
{summary}
{resourceList}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={2} spacing="md" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<ScrollArea h="100%">{summary}</ScrollArea>
<ScrollArea h="100%">{resourceList}</ScrollArea>
</SimpleGrid>
);
}
+2
View File
@@ -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}
/>
);
+42 -26
View File
@@ -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 = (
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
);
const advancedContent =
width < 900 ? (
<ScrollArea h="100%">
<Stack gap="md">
<Box h={Math.max(280, Math.min(420, height - 160))}>{selectedView}</Box>
{advancedLists}
</Stack>
</ScrollArea>
) : (
<SimpleGrid cols={2} spacing="md" h="100%" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
<Box h="100%" mih={260}>
{selectedView}
</Box>
{advancedLists}
</SimpleGrid>
);
return (
<Stack gap={4} p="xs" h="100%">
<Group justify="space-between" align="baseline" wrap="nowrap">
@@ -213,31 +253,7 @@ export function UmamiContent({
)}
</Group>
<Box mt={4} style={{ flex: 1, minHeight: 0 }}>
{displayMode === "advanced" ? (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="md" h="100%">
<Box mih={260}>{selectedView}</Box>
<SimpleGrid cols={viewMode === "topPages" || viewMode === "topReferrers" ? 1 : 2} spacing="md" mih={260}>
{viewMode !== "topPages" && (
<UmamiTopPagesContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
{viewMode !== "topReferrers" && (
<UmamiTopReferrersContent
integrationIds={integrationIds}
websiteId={websiteId}
timeFrame={timeFrame}
limit={topCount}
/>
)}
</SimpleGrid>
</SimpleGrid>
) : (
selectedView
)}
{displayMode === "advanced" ? advancedContent : selectedView}
</Box>
</Stack>
);
+38 -25
View File
@@ -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 (
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="lg" h="100%" p="md">
{summaryContent}
const monitorList = (
<Stack gap="xs">
{monitors
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
.map((monitor) => (
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
)}
</Stack>
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
{t(`status.${monitor.status}`)} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
</Stack>
);
if (width < 760) {
return (
<ScrollArea h="100%">
<Stack gap="xs">
{monitors
.toSorted((left, right) => statusOrder[left.status] - statusOrder[right.status])
.map((monitor) => (
<Group key={monitor.key} justify="space-between" wrap="nowrap" p="xs">
<Stack gap={0} style={{ minWidth: 0 }}>
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
)}
</Stack>
<Badge color={monitor.status === "up" ? "green" : monitor.status === "down" ? "red" : "yellow"}>
{t(`status.${monitor.status}`)} ·{" "}
{monitor.uptimePercent24h === null ? "—" : `${formatNumber(monitor.uptimePercent24h, 1)}%`}
</Badge>
</Group>
))}
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
);
}
return (
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
);
}