fix: complete widget modernization review
This commit is contained in:
35 files changed
+621
-265
No files matched your search
@@ -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);
|
||||
+25
-11
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
}),
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user