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

@@ -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>
);
}