diff --git a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css index 5dab4d40b..f8415e012 100644 --- a/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css +++ b/apps/nextjs/src/app/[locale]/boards/[name]/settings/_layout-preview.module.css @@ -59,7 +59,7 @@ } } -.dynamicTile { +.containerTile { border-style: dashed; background: color-mix(in srgb, var(--mantine-color-default) 88%, var(--mantine-primary-color-light)); } diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts index 3124102f5..ff33fa20e 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.spec.ts @@ -1,14 +1,38 @@ import { describe, expect, test } from "vitest"; import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock"; -import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock"; +import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock"; import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; import { ItemMockBuilder } from "~/components/board/items/actions/test/mocks/item-mock"; import { getRepresentativeLayoutWidth, projectBoardLayout } from "./_layout-utils"; -const mobile = { id: "mobile", name: "Mobile", columnCount: 3, breakpoint: 0, role: "mobile" as const }; -const custom = { id: "tablet", name: "Tablet", columnCount: 6, breakpoint: 480, role: "custom" as const }; -const base = { id: "base", name: "Base", columnCount: 12, breakpoint: 768, role: "base" as const }; +const mobile = { + id: "mobile", + name: "Mobile", + columnCount: 3, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 0, + role: "mobile" as const, +}; +const custom = { + id: "tablet", + name: "Tablet", + columnCount: 6, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 480, + role: "custom" as const, +}; +const base = { + id: "base", + name: "Base", + columnCount: 12, + leftGutterColumnCount: 0, + rightGutterColumnCount: 0, + breakpoint: 768, + role: "base" as const, +}; describe("layout representative widths", () => { test("uses a phone width, activation midpoint, and desktop minimum", () => { @@ -22,30 +46,30 @@ describe("layout representative widths", () => { describe("projectBoardLayout", () => { test("returns exact saved positions without recursively re-projecting the active layout", () => { const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" }) + const container = new ContainerSectionMockBuilder({ id: "container" }) .addLayout({ layoutId: mobile.id, parentSectionId: rootSection.id, width: 3, height: 2 }) .build(); - const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, dynamicSection]).build(); + const board = new BoardMockBuilder().addLayout(mobile).addSections([rootSection, container]).build(); expect(projectBoardLayout(board, mobile, mobile)).toEqual([ - expect.objectContaining({ id: dynamicSection.id, sectionId: rootSection.id, width: 3, height: 2 }), + expect.objectContaining({ id: container.id, sectionId: rootSection.id, width: 3, height: 2 }), ]); }); - test("uses production ordering and sizing for items and dynamic sections", () => { + test("uses production ordering and sizing for items and containers", () => { const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const dynamicSection = new DynamicSectionMockBuilder({ id: "dynamic" }) + const container = new ContainerSectionMockBuilder({ id: "container" }) .addLayout({ layoutId: base.id, parentSectionId: rootSection.id, width: 6, height: 3, xOffset: 6, yOffset: 0 }) .build(); const rootItem = new ItemMockBuilder({ id: "root-item", kind: "clock" }) .addLayout({ layoutId: base.id, sectionId: rootSection.id, width: 5, height: 2, xOffset: 0, yOffset: 0 }) .build(); const nestedItem = new ItemMockBuilder({ id: "nested-item", kind: "clock" }) - .addLayout({ layoutId: base.id, sectionId: dynamicSection.id, width: 4, height: 1, xOffset: 0, yOffset: 0 }) + .addLayout({ layoutId: base.id, sectionId: container.id, width: 4, height: 1, xOffset: 0, yOffset: 0 }) .build(); const board = new BoardMockBuilder() .addLayout(mobile) - .addSections([rootSection, dynamicSection]) + .addSections([rootSection, container]) .addItems([rootItem, nestedItem]) .build(); @@ -59,7 +83,7 @@ describe("projectBoardLayout", () => { yOffset: 0, sectionId: rootSection.id, }); - expect(projected.find((element) => element.id === dynamicSection.id)).toMatchObject({ + expect(projected.find((element) => element.id === container.id)).toMatchObject({ type: "section", width: 3, xOffset: 0, @@ -69,7 +93,7 @@ describe("projectBoardLayout", () => { expect(projected.find((element) => element.id === nestedItem.id)).toMatchObject({ type: "item", width: 3, - sectionId: dynamicSection.id, + sectionId: container.id, }); }); diff --git a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts index 46da849d5..8e1d45fe8 100644 --- a/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts +++ b/apps/nextjs/src/app/[locale]/boards/_layout-utils.ts @@ -1,9 +1,12 @@ import { generateResponsiveGridFor } from "@homarr/common"; import type { GridAlgorithmItem } from "@homarr/common"; +import type { BoardLane } from "@homarr/definitions"; +import { boardLanes, getBoardLaneColumnCount, getRootSectionLane } from "@homarr/definitions"; import type { Board } from "./_types"; export type BoardLayout = Board["layouts"][number]; +type LayoutGeometry = Pick; export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: BoardLayout[]) => { if (layout.role === "mobile") return 390; @@ -17,28 +20,69 @@ export const getRepresentativeLayoutWidth = (layout: BoardLayout, allLayouts: Bo return nextLayout ? Math.round((layout.breakpoint + nextLayout.breakpoint) / 2) : Math.max(1280, layout.breakpoint); }; -export const projectBoardLayout = ( - board: Board, - sourceLayout: Pick, - targetLayout: Pick, -) => { +export const projectBoardLayout = (board: Board, sourceLayout: LayoutGeometry, targetLayout: LayoutGeometry) => { const elements = getElementsForLayout(board, sourceLayout.id); - if (sourceLayout.id === targetLayout.id && sourceLayout.columnCount === targetLayout.columnCount) { + if ( + sourceLayout.id === targetLayout.id && + sourceLayout.columnCount === targetLayout.columnCount && + sourceLayout.leftGutterColumnCount === targetLayout.leftGutterColumnCount && + sourceLayout.rightGutterColumnCount === targetLayout.rightGutterColumnCount + ) { return elements; } - return board.sections - .filter((section) => section.kind !== "dynamic") - .flatMap( - (section) => - generateResponsiveGridFor({ - items: elements, - previousWidth: sourceLayout.columnCount, - width: targetLayout.columnCount, - sectionId: section.id, - }).items, + const emptyRoots = board.sections + .filter((section) => section.kind === "empty") + .toSorted((first, second) => first.xOffset - second.xOffset || first.id.localeCompare(second.id)); + const rootByLane = new Map(); + for (const root of emptyRoots) { + const lane = getRootSectionLane(root.xOffset); + if (!rootByLane.has(lane)) rootByLane.set(lane, root); + } + + const mainRoot = rootByLane.get("main"); + if (!mainRoot) return []; + + const sourceRootById = new Map( + emptyRoots.map((section) => [section.id, getRootSectionLane(section.xOffset)] as const), + ); + const targetLaneBySourceLane = new Map( + boardLanes.map((lane) => [ + lane, + lane !== "main" && getBoardLaneColumnCount(targetLayout, lane) === 0 ? "main" : lane, + ]), + ); + const remappedElements = elements.map((element) => { + const sourceLane = sourceRootById.get(element.sectionId); + if (!sourceLane) return element; + const targetLane = targetLaneBySourceLane.get(sourceLane) ?? "main"; + return { ...element, sectionId: (rootByLane.get(targetLane) ?? mainRoot).id }; + }); + + return boardLanes.flatMap((lane) => { + const root = rootByLane.get(lane); + const width = getBoardLaneColumnCount(targetLayout, lane); + if (!root || width === 0) return []; + + const sourceLanes = boardLanes.filter( + (sourceLane) => + rootByLane.has(sourceLane) && + getBoardLaneColumnCount(sourceLayout, sourceLane) > 0 && + targetLaneBySourceLane.get(sourceLane) === lane, ); + const previousWidth = + sourceLanes.length === 1 + ? getBoardLaneColumnCount(sourceLayout, sourceLanes[0] ?? "main") + : Number.MAX_SAFE_INTEGER; + + return generateResponsiveGridFor({ + items: remappedElements, + previousWidth, + width, + sectionId: root.id, + }).items; + }); }; const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem[] => [ @@ -59,7 +103,7 @@ const getElementsForLayout = (board: Board, layoutId: string): GridAlgorithmItem : []; }), ...board.sections.flatMap((section) => { - if (section.kind !== "dynamic") return []; + if (section.kind !== "container") return []; const layout = section.layouts.find((candidate) => candidate.layoutId === layoutId); return layout?.parentSectionId ? [ diff --git a/apps/nextjs/src/app/[locale]/boards/_providers.tsx b/apps/nextjs/src/app/[locale]/boards/_providers.tsx index cdf917deb..c79644636 100644 --- a/apps/nextjs/src/app/[locale]/boards/_providers.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_providers.tsx @@ -11,10 +11,18 @@ import type { Board } from "./_types"; interface Props { initialBoard: Board; + initialLayoutId: string; + initialViewportWidth: number; canModify: boolean; } -export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithChildren) => { +export const BoardProviders = ({ + children, + initialBoard, + initialLayoutId, + initialViewportWidth, + canModify, +}: PropsWithChildren) => { const searchParams = useSearchParams(); const isDesktop = useMediaQuery("(min-width: 64em)"); const requestedLayoutId = searchParams.get("layout"); @@ -23,7 +31,12 @@ export const BoardProviders = ({ children, initialBoard, canModify }: PropsWithC const layoutOverrideId = isLayoutEditRequest && isDesktop ? requestedLayoutId : null; return ( - + {children} ); diff --git a/apps/nextjs/src/components/board/items/actions/empty-position.ts b/apps/nextjs/src/components/board/items/actions/empty-position.ts index fd91c98d2..2f3875627 100644 --- a/apps/nextjs/src/components/board/items/actions/empty-position.ts +++ b/apps/nextjs/src/components/board/items/actions/empty-position.ts @@ -7,7 +7,7 @@ export const getFirstEmptyPosition = ( size: { width: number; height: number } = { width: 1, height: 1 }, ) => { for (let yOffset = 0; yOffset < rowCount + 1 - size.height; yOffset++) { - for (let xOffset = 0; xOffset < columnCount; xOffset++) { + for (let xOffset = 0; xOffset < columnCount + 1 - size.width; xOffset++) { const isOccupied = elements.some( (element) => element.yOffset < yOffset + size.height && diff --git a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts index 72a8bc3f4..83058fd89 100644 --- a/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts +++ b/apps/nextjs/src/components/board/layout/test/board-lanes.spec.ts @@ -16,6 +16,7 @@ describe("board lanes", () => { leftGutterColumnCount: 2, rightGutterColumnCount: 3, breakpoint: 0, + role: "base", } satisfies Board["layouts"][number]; expect(getBoardLaneColumnCount(layout, "left")).toBe(2); @@ -31,6 +32,7 @@ describe("board lanes", () => { leftGutterColumnCount: 3, rightGutterColumnCount: 3, breakpoint: 0, + role: "base", } satisfies Board["layouts"][number]; expect(getBoardLaneColumnCount(layout, "left")).toBe(3); @@ -120,6 +122,7 @@ describe("board lanes", () => { leftGutterColumnCount: 1, rightGutterColumnCount: 0, breakpoint: 0, + role: "base", }, ]; diff --git a/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts b/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts deleted file mode 100644 index 3df5c1a38..000000000 --- a/apps/nextjs/src/components/board/sections/gridstack/gridstack.spec.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { describe, expect, test } from "vitest"; - -import { filterRecursiveInnerSections } from "./gridstack"; - -describe("filterRecursiveInnerSections", () => { - test("removes sections that would recursively render an ancestor", () => { - const innerSections = [{ id: "safe" }, { id: "parent" }, { id: "root" }]; - - expect(filterRecursiveInnerSections(innerSections, new Set(["root", "parent"]))).toEqual([{ id: "safe" }]); - }); -}); diff --git a/apps/nextjs/src/components/board/sections/section-actions.spec.ts b/apps/nextjs/src/components/board/sections/section-actions.spec.ts index 32c413f97..441e71ef7 100644 --- a/apps/nextjs/src/components/board/sections/section-actions.spec.ts +++ b/apps/nextjs/src/components/board/sections/section-actions.spec.ts @@ -1,18 +1,18 @@ import { describe, expect, test } from "vitest"; import { BoardMockBuilder } from "~/components/board/items/actions/test/mocks/board-mock"; -import { DynamicSectionMockBuilder } from "~/components/board/items/actions/test/mocks/dynamic-section-mock"; +import { ContainerSectionMockBuilder } from "~/components/board/items/actions/test/mocks/container-section-mock"; import { EmptySectionMockBuilder } from "~/components/board/items/actions/test/mocks/empty-section-mock"; import { wouldCreateSectionCycle } from "./section-actions"; describe("wouldCreateSectionCycle", () => { - test("rejects moving a dynamic section into itself or one of its descendants", () => { + test("rejects moving a container into itself or one of its descendants", () => { const layoutId = "layout"; const rootSection = new EmptySectionMockBuilder({ id: "root" }).build(); - const parentSection = new DynamicSectionMockBuilder({ id: "parent" }) + const parentSection = new ContainerSectionMockBuilder({ id: "parent" }) .addLayout({ layoutId, parentSectionId: rootSection.id }) .build(); - const childSection = new DynamicSectionMockBuilder({ id: "child" }) + const childSection = new ContainerSectionMockBuilder({ id: "child" }) .addLayout({ layoutId, parentSectionId: parentSection.id }) .build(); const board = new BoardMockBuilder().addSections([rootSection, parentSection, childSection]).build(); diff --git a/apps/nextjs/src/components/board/sections/section-actions.tsx b/apps/nextjs/src/components/board/sections/section-actions.tsx index f2cdac751..47813fc16 100644 --- a/apps/nextjs/src/components/board/sections/section-actions.tsx +++ b/apps/nextjs/src/components/board/sections/section-actions.tsx @@ -101,7 +101,7 @@ export const wouldCreateSectionCycle = ( visitedSectionIds.add(currentSectionId); const currentSection = board.sections.find((section) => section.id === currentSectionId); - if (currentSection?.kind !== "dynamic") return false; + if (currentSection?.kind !== "container") return false; currentSectionId = currentSection.layouts.find((layout) => layout.layoutId === layoutId)?.parentSectionId ?? null; } diff --git a/apps/nextjs/src/components/user-avatar-menu.tsx b/apps/nextjs/src/components/user-avatar-menu.tsx index 2cfed0b5b..9b2e34fea 100644 --- a/apps/nextjs/src/components/user-avatar-menu.tsx +++ b/apps/nextjs/src/components/user-avatar-menu.tsx @@ -21,17 +21,9 @@ interface UserAvatarMenuProps { children: ReactNode; availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"]; isDockerEnabled?: boolean; - opened: boolean; - onOpenChange: (opened: boolean) => void; } -export const UserAvatarMenu = ({ - children, - availableUpdates, - isDockerEnabled, - opened, - onOpenChange, -}: UserAvatarMenuProps) => { +export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => { const t = useScopedI18n("common.userAvatar.menu"); const session = useSession(); @@ -48,7 +40,7 @@ export const UserAvatarMenu = ({ return ( // We use keepMounted so we can add event listeners to prevent navigating away without saving the board - + }> diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 9db2d1ce8..625325dcf 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -1831,47 +1831,6 @@ describe("saveLayouts should save layout changes", () => { expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); }); - test("should project a new custom layout from the resized Base layout", async () => { - const db = createDb(); - const caller = boardRouter.createCaller({ db, deviceType: undefined, session: defaultSession }); - const { boardId, layoutId, sectionId, itemId } = await createFullBoardAsync(db, "default"); - const mobileLayoutId = await addMobileLayoutAsync(db, boardId); - const secondItemId = createId(); - - await db.update(itemLayouts).set({ width: 4, xOffset: 0 }).where(eq(itemLayouts.itemId, itemId)); - await db.insert(items).values({ - id: secondItemId, - kind: "clock", - boardId, - options: SuperJSON.stringify({ is24HourFormat: true }), - }); - await db.insert(itemLayouts).values({ - itemId: secondItemId, - layoutId, - sectionId, - width: 4, - height: 1, - xOffset: 6, - yOffset: 0, - }); - - const resizedBase = { ...createExistingLayout(layoutId), columnCount: 6 }; - const savedLayouts = await caller.saveLayouts({ - id: boardId, - layouts: [createMobileLayout(mobileLayoutId), createNewLayout(8), resizedBase], - }); - const customLayoutId = expectToBeDefined(savedLayouts.find((layout) => layout.role === "custom")).id; - - const basePosition = await db.query.itemLayouts.findFirst({ - where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, layoutId)), - }); - const customPosition = await db.query.itemLayouts.findFirst({ - where: and(eq(itemLayouts.itemId, secondItemId), eq(itemLayouts.layoutId, customLayoutId)), - }); - - expect(basePosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); - expect(customPosition).toMatchObject({ xOffset: 0, yOffset: 1, width: 4, height: 1, sectionId }); - }); test("should remove layout when not present in input", async () => { // Arrange const db = createDb(); diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index bdce2473f..261d3ba46 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,17 +1,47 @@ import { z } from "zod/v4"; +import type { IntegrationKindByCategory } from "@homarr/definitions"; import { getIntegrationKindsByCategory } from "@homarr/definitions"; +import type { DownloadClientJobsAndStatus } from "@homarr/integrations"; import { createIntegrationAsync, downloadClientItemSchema } from "@homarr/integrations"; import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"; import type { IntegrationAction } from "../../middlewares/integration"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc"; const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) => createManyIntegrationMiddleware(action, ...getIntegrationKindsByCategory("downloadClient")); +interface DownloadClientQueryResultBase { + integrationId: string; + integrationName: string; +} + +type DownloadClientQueryResult = DownloadClientQueryResultBase & + ( + | { + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"downloadClient">; + updatedAt: Date; + }; + data: DownloadClientJobsAndStatus; + error?: never; + } + | { + integration: { + id: string; + name: string; + kind: IntegrationKindByCategory<"downloadClient">; + }; + data: null; + error: string; + } + ); + export const downloadsRouter = createTRPCRouter({ getJobsAndStatuses: publicProcedure .meta({ @@ -24,14 +54,29 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); - const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, - data, - }; - }); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], DownloadClientQueryResult>( + ctx.integrations, + async (integration) => { + const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration }); + const { data, timestamp } = await innerHandler.getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp }, + data, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + integration: { id: integration.id, name: integration.name, kind: integration.kind }, + data: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 1957f459c..5c123ecb1 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,9 +1,10 @@ import { createIntegrationAsync } from "@homarr/integrations"; +import type { SystemHealthMonitoring } from "@homarr/integrations"; import { SynologyIntegration } from "@homarr/integrations"; import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring"; import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const healthMonitoringIntegrationKinds = [ @@ -16,6 +17,14 @@ const healthMonitoringIntegrationKinds = [ "mock", ] as const; +interface SystemHealthQueryResult { + integrationId: string; + integrationName: string; + healthInfo: SystemHealthMonitoring | null; + updatedAt?: Date; + error?: string; +} + export const healthMonitoringRouter = createTRPCRouter({ getSystemHealthStatus: publicProcedure .meta({ @@ -27,15 +36,27 @@ export const healthMonitoringRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - healthInfo: data, - updatedAt: timestamp, - }; - }); + return await settleIntegrationQueries<(typeof ctx.integrations)[number], SystemHealthQueryResult>( + ctx.integrations, + async (integration) => { + const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync(); + return { + integrationId: integration.id, + integrationName: integration.name, + healthInfo: data, + updatedAt: timestamp, + }; + }, + { + fallback: (integration) => ({ + integrationId: integration.id, + integrationName: integration.name, + healthInfo: null, + error: PUBLIC_INTEGRATION_ERROR, + }), + throwOnAllFailures: true, + }, + ); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index f05a46142..e1acb95b6 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,32 +2,21 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions"; import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; +import { getMediaReleaseResponse } from "./media-release-result"; export const mediaReleaseRouter = createTRPCRouter({ getMediaReleases: publicProcedure .concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease"))) .query(async ({ ctx }) => { - const results = await settleIntegrationQueries(ctx.integrations, async (integration) => { + return await getMediaReleaseResponse(ctx.integrations, async (integration) => { const innerHandler = mediaReleaseRequestHandler.handler(integration, {}); const { data, timestamp } = await innerHandler.getDataAsync(); return { - integration: { - id: integration.id, - name: integration.name, - kind: integration.kind, - updatedAt: timestamp, - }, releases: data, + updatedAt: timestamp, }; }); - return results.flatMap((result) => - result.releases.map((release) => ({ - ...release, - integration: result.integration, - })), - ); }), }); diff --git a/packages/api/src/router/widgets/uptime-kuma.ts b/packages/api/src/router/widgets/uptime-kuma.ts index 6df14090c..e3082c284 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,24 +1,39 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; export const uptimeKumaRouter = createTRPCRouter({ getDashboard: publicProcedure .concat(createManyIntegrationMiddleware("query", "uptimeKuma")) .query(async ({ ctx }) => { - return await settleIntegrationQueries(ctx.integrations, async (integration) => { - const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); - const { data, timestamp } = await innerHandler.getDataAsync(); + return await settleIntegrationQueries( + ctx.integrations, + async (integration) => { + const innerHandler = uptimeKumaRequestHandler.handler(integration, {}); + const { data, timestamp } = await innerHandler.getDataAsync(); - return { - integrationId: integration.id, - integrationName: integration.name, - integrationUrl: integration.url, - dashboard: data, - updatedAt: timestamp, - }; - }); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data as typeof data | null, + updatedAt: timestamp, + error: undefined as string | undefined, + }; + }, + { + fallback: (integration, error) => ({ + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: null, + updatedAt: new Date(0), + error: toPublicIntegrationError(error), + }), + throwOnAllFailures: true, + }, + ); }), }); diff --git a/packages/db/migrations/sqlite/0042_add_layout_role.sql b/packages/db/migrations/sqlite/0042_add_layout_role.sql deleted file mode 100644 index d0a800e0d..000000000 --- a/packages/db/migrations/sqlite/0042_add_layout_role.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `layout` ADD `role` text DEFAULT 'custom' NOT NULL; diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index bddeeca2c..a881cc7e8 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2356,6 +2356,12 @@ "noProjectSelected": "No project selected", "runnerStatus": "Runner: {status}", "updatedAt": "Updated {date}", + "projectId": "Project: {id}", + "sessionReceived": "Session received: {value}", + "sessionSent": "Session sent: {value}", + "sessionId": "Session: {id}", + "startedAt": "Started {date}", + "emptyItems": "No active or recent jobs in this snapshot", "metric": { "running": "Running", "completed": "Done", @@ -2757,6 +2763,7 @@ }, "advanced": { "entityId": "Entity ID: {id}", + "lastChanged": "Changed {date}", "lastUpdated": "Updated {date}", "attribute": { "unit": "Unit", @@ -2785,7 +2792,9 @@ }, "advanced": { "lastExecuted": "Last run at {time}", - "automationId": "Automation ID: {id}" + "automationId": "Automation ID: {id}", + "ready": "Ready to run", + "noPermission": "No permission to run this automation" }, "status": { "running": "Running automation", @@ -2798,6 +2807,14 @@ "stockPrice": { "name": "Stock Price", "description": "Displays the current stock price of a company", + "advanced": { + "currentPrice": "Current price", + "previousClose": "Previous close", + "change": "Change", + "changePercentage": "Change (%)", + "minimum": "Minimum", + "maximum": "Maximum" + }, "option": { "stock": { "label": "Stock symbol" @@ -2986,6 +3003,11 @@ }, "title": "Indexer manager", "testAll": "Test all", + "status": { + "healthy": "Healthy", + "unhealthy": "Unhealthy", + "disabled": "Disabled" + }, "error": { "internalServerError": "Failed to fetch indexers status" } @@ -3043,7 +3065,10 @@ "minutes": "{count} minutes", "used": "Used", "available": "Available", - "lastSeen": "Last status update: {lastSeen}" + "lastSeen": "Last status update: {lastSeen}", + "updatesAvailable": "{count, plural, one {# update available} other {# updates available}}", + "rebootRequired": "Reboot required", + "rebootNotRequired": "No reboot required" }, "memory": {}, "error": { @@ -3162,7 +3187,13 @@ }, "server": { "buildServer": "Build", - "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}" + "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}", + "reachability": "Reachability", + "usability": "Usability" + }, + "source": { + "url": "Source: {url}", + "unavailable": "{source}: unavailable" }, "instances": { "title": "Instances" @@ -3181,7 +3212,11 @@ "restarting": "Restarting", "unknown": "Unknown", "online": "Online", - "offline": "Offline" + "offline": "Offline", + "reachable": "Reachable", + "unreachable": "Unreachable", + "usable": "Usable", + "unusable": "Unusable" } }, "vpn": { @@ -3717,6 +3752,9 @@ "movies": "Missing Movies", "providers": "Provider Issues", "status": "Health Issues", + "sonarrSignalr": "Sonarr SignalR", + "radarrSignalr": "Radarr SignalR", + "announcements": "Announcements", "option": { "showMissingEpisodes": { "label": "Show missing episodes", @@ -4315,6 +4353,16 @@ }, "networkControllerSummary": { "option": {}, + "advanced": { + "updated": "Updated", + "metric": { + "latency": "Latency", + "ping": "Ping", + "uptime": "Uptime", + "users": "Users", + "guests": "Guests" + } + }, "card": { "vpn": { "countConnected": "{count, plural, one {# connected} other {# connected}}", @@ -4333,6 +4381,9 @@ "description": "Displays the summary of a Network Controller (like UniFi Controller)" }, "networkControllerStatus": { + "advanced": { + "updatedAt": "Updated {date}" + }, "card": { "variants": { "wired": { @@ -4464,7 +4515,8 @@ "range": { "twelveHours": "12h", "oneDay": "24h", - "all": "All" + "all": "All", + "lastThirty": "Last 30" } }, "uptimeKuma": { @@ -4679,7 +4731,9 @@ } }, "status": { - "unhealthy": "Unhealthy" + "unhealthy": "Unhealthy", + "smart": "SMART", + "free": "{percentage}% free" } }, "customApi": { @@ -4800,6 +4854,12 @@ }, "column": { "system": "System", + "source": "Source", + "hostname": "Hostname", + "os": "OS", + "cpuModel": "CPU model", + "cores": "Cores", + "memoryTotal": "Memory total", "cpu": "CPU", "memory": "Memory", "disk": "Disk", @@ -4860,6 +4920,14 @@ "empty": { "noSystems": "No systems found" }, + "metadata": { + "hostname": "Hostname", + "os": "OS", + "cpuModel": "CPU model", + "cores": "Cores", + "memoryTotal": "Memory total", + "source": "Source" + }, "metric": { "cpu": "CPU", "memory": "Memory", diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index acda12e12..193650b5c 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -124,7 +124,10 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget /> - {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( + {options.pingEnabled && + !settings.forceDisableStatus && + !board.disableStatus && + Boolean(app.pingUrl ?? app.href) ? ( }> diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 23243f35e..8990767f3 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -16,6 +16,7 @@ import { VisuallyHidden, } from "@mantine/core"; import { IconBellOff, IconCircleCheck, IconFlame, IconHistory } from "@tabler/icons-react"; +import { getQueryKey } from "@trpc/react-query"; import dayjs from "dayjs"; import relativeTime from "dayjs/plugin/relativeTime"; import type { LucideIcon } from "lucide-react"; @@ -27,6 +28,10 @@ import { useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; +import { useWidgetRuntimeQueries } from "../runtime-hooks"; +import { getBeszelAlertsQueryInput } from "./display"; +import { buildBeszelSystemNameMap, getBeszelSystemName } from "./system-name-map"; const alertIconMap: Record = { CPU: Cpu, @@ -50,43 +55,64 @@ export default function BeszelAlertsWidget({ integrationIds, isEditMode, height, + displayMode, + widgetRuntimeRef, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); + const isAdvanced = displayMode === "advanced"; const alertsInput = useMemo( - () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), - [integrationIds, options.showHistory, options.maxHistoryItems], + () => + getBeszelAlertsQueryInput( + integrationIds, + { showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, + isAdvanced, + ), + [integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced], ); const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); - const results = getUsableWidgetQueryData(alertsQuery) ?? []; + const runtimeQueries = useMemo( + () => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")], + [alertsInput], + ); + useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries); + const resultData = getUsableWidgetQueryData(alertsQuery); + const results = useMemo(() => resultData ?? [], [resultData]); const { isPending } = alertsQuery; - const systemNameMap = useMemo(() => { - const map: Record = {}; - for (const r of results) { - if (r.systemNameMap) { - Object.assign(map, r.systemNameMap); - } - } - return map; - }, [results]); + const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]); + const showIntegrationName = isAdvanced || results.length > 1; const alerts = useMemo( - () => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))), + () => + results.flatMap((r) => + r.alerts.map((a) => ({ + ...a, + integrationId: r.integrationId, + integrationName: r.integrationName, + key: `${r.integrationId}:${a.id}`, + })), + ), [results], ); const history = useMemo(() => { - const all = results.flatMap((r) => r.history.map((h) => ({ ...h, _key: `${r.integrationId}:${h.id}` }))); - return all - .toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()) - .slice(0, options.maxHistoryItems); - }, [results, options.maxHistoryItems]); + const all = results.flatMap((r) => + r.history.map((h) => ({ + ...h, + integrationId: r.integrationId, + integrationName: r.integrationName, + key: `${r.integrationId}:${h.id}`, + })), + ); + const sorted = all.toSorted((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime()); + return isAdvanced ? sorted : sorted.slice(0, options.maxHistoryItems); + }, [results, options.maxHistoryItems, isAdvanced]); const triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const showOkAlerts = triggeredAlerts.length === 0 || height >= 260; - const showHistory = options.showHistory && height >= 360; - const showAlertDescriptions = height >= 190; + const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260; + const showHistory = isAdvanced || (options.showHistory && height >= 360); + const showAlertDescriptions = isAdvanced || height >= 190; if (isPending) { return ( @@ -99,7 +125,10 @@ export default function BeszelAlertsWidget({ return ( - + + + + @@ -124,11 +153,12 @@ export default function BeszelAlertsWidget({ {triggeredAlerts.map((alert) => ( @@ -148,11 +178,12 @@ export default function BeszelAlertsWidget({ {okAlerts.map((alert) => ( @@ -171,11 +202,12 @@ export default function BeszelAlertsWidget({ {history.map((entry) => { - const systemName = systemNameMap[entry.system] ?? entry.system; + const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system); + const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName; const isResolved = !!entry.resolved; const HistoryIcon = alertIconMap[entry.name] ?? Server; return ( - + - {systemName} + {systemLabel} @@ -218,6 +250,7 @@ interface AlertRowProps { value: number; min: number; systemName: string; + integrationName?: string; triggered: boolean; showDescription: boolean; } @@ -243,7 +276,7 @@ function formatAlertDescription(name: string, value: number, min: number): strin return `exceeds ${value}${suffix} over ${min} min`; } -function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) { +function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) { const Icon = alertIconMap[name] ?? Server; const description = formatAlertDescription(name, value, min); return ( @@ -263,7 +296,7 @@ function AlertRow({ name, value, min, systemName, triggered, showDescription }: - {systemName} + {integrationName ? `${systemName} · ${integrationName}` : systemName} · diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index db1e791b2..e8a32796d 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,19 +1,22 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; +import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, - supportsAdvancedFocus: false, queryKey: [["widget", "beszel", "getAlerts"]], - queryMatcher: ({ input }, scope) => - widgetQueryInputMatches(input, { + queryMatcher(query, scope) { + const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts"); + if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope); + + return widgetQueryInputMatches(query.input, { integrationIds: scope.integrationIds, includeHistory: scope.options.showHistory, maxHistoryItems: scope.options.maxHistoryItems, - }), + }); + }, supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, createOptions() { diff --git a/packages/widgets/src/beszel-system-grid/component.tsx b/packages/widgets/src/beszel-system-grid/component.tsx index 66f32dc35..84f796fe2 100644 --- a/packages/widgets/src/beszel-system-grid/component.tsx +++ b/packages/widgets/src/beszel-system-grid/component.tsx @@ -1,7 +1,7 @@ "use client"; import type { MantineSize } from "@mantine/core"; -import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core"; +import { Badge, Box, Card, Center, Group, Loader, Progress, SimpleGrid, Stack, Text } from "@mantine/core"; import { Activity, Battery, @@ -19,6 +19,7 @@ import { IconServerOff } from "@tabler/icons-react"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; +import { formatBytes } from "@homarr/common"; import { useModalAction } from "@homarr/modals"; import { useScopedI18n } from "@homarr/translation/client"; @@ -38,8 +39,10 @@ import { import { useBeszelFilteredSystems } from "../beszel/_shared/hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal"; import { DiskUsage } from "../beszel/_shared/disk-usage"; +import { isBeszelGridMetricVisible } from "./display"; interface SizeConfig { iconSize: number; @@ -117,10 +120,12 @@ const getMaxVisibleMetrics = (cellHeight: number, size: SizeConfig): number => { const MIN_CELL_WIDTH = 140; const MIN_CELL_HEIGHT = 80; +const ADVANCED_MIN_CELL_WIDTH = 320; +const ADVANCED_MIN_CELL_HEIGHT = 420; -const getColCount = (width: number, _height: number, itemCount: number): number => { +const getColCount = (width: number, _height: number, itemCount: number, minCellWidth = MIN_CELL_WIDTH): number => { if (itemCount <= 1) return 1; - const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth))); let best = 1; for (let c = 1; c <= maxCols; c++) { @@ -168,12 +173,14 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( ); interface SystemCardProps { - system: BeszelSystemRow & { _key: string }; + system: BeszelSystemRow & { rowKey: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType>; size: SizeConfig; maxMetrics: number; itemRadius: MantineSize; + integrationName: string; + isAdvanced: boolean; onClick?: () => void; } @@ -190,7 +197,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showCpu, advanced), }, { key: "showMemory", @@ -204,7 +212,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showMemory, advanced), }, { key: "showDisk", @@ -217,7 +226,8 @@ const metricRenderers = [ ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showDisk, advanced), }, { key: "showGpu", @@ -231,7 +241,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0), }, { key: "showLoadAvg", @@ -244,7 +255,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null), }, { key: "showNet", @@ -257,7 +269,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showNet, advanced), }, { key: "showTemp", @@ -270,7 +283,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null), }, { key: "showBattery", @@ -287,7 +301,8 @@ const metricRenderers = [ /> ); }, - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null), }, { key: "showServices", @@ -300,7 +315,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showServices, advanced), }, { key: "showUptime", @@ -313,7 +329,8 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showUptime, advanced), }, { key: "showAgent", @@ -326,12 +343,23 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent, + visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => + isBeszelGridMetricVisible(o.showAgent, advanced), }, ] as const; -const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { - const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options)); +const SystemCard = ({ + system, + options, + t, + size, + maxMetrics, + itemRadius, + integrationName, + isAdvanced, + onClick, +}: SystemCardProps) => { + const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced)); const visibleMetrics = enabledMetrics.slice(0, maxMetrics); const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; @@ -368,6 +396,20 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick + {isAdvanced && ( + + + + + + 0 ? formatBytes(system.memoryTotal) : "—"} + /> + + + )} + {visibleMetrics.map((m) => m.render(system, t, size))} @@ -380,12 +422,24 @@ const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick ); }; +const SystemMetadata = ({ label, value }: { label: string; value: string }) => ( + + + {label} + + + {value} + + +); + export default function BeszelSystemGridWidget({ options, integrationIds, isEditMode, width, height, + displayMode, }: WidgetComponentProps<"beszelSystemGrid">) { const t = useScopedI18n("widget.beszelSystemGrid"); const board = useRequiredBoard(); @@ -394,6 +448,8 @@ export default function BeszelSystemGridWidget({ const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); const results = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending } = systemsQuery; + const isAdvanced = displayMode === "advanced"; + const integrationNames = new Map(results.map((result) => [result.integrationId, result.integrationName])); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); @@ -408,7 +464,10 @@ export default function BeszelSystemGridWidget({ if (filteredSystems.length === 0) { return ( - + + + +
@@ -421,19 +480,23 @@ export default function BeszelSystemGridWidget({ ); } - const cols = getColCount(width, height, filteredSystems.length); + const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH; + const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const minimumCellHeight = MIN_CELL_HEIGHT; + const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size); return ( - + + + + {filteredSystems.map((system) => { - const integrationId = system._key.split(":")[0] ?? ""; + const integrationId = system.rowKey.split(":")[0] ?? ""; + const integrationName = integrationNames.get(integrationId) ?? "—"; const handleClick = isEditMode ? undefined : () => openModal({ integrationId, systemId: system.id }, { title: system.name }); return ( ); diff --git a/packages/widgets/src/beszel-system-grid/index.ts b/packages/widgets/src/beszel-system-grid/index.ts index b3ef14806..9a5444e28 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelSystemGrid", { icon: IconLayoutGrid, - supportsAdvancedFocus: false, queryKey: [["widget", "beszel", "getSystems"]], supportedIntegrations: ["beszel", "mock"], integrationsRequired: true, diff --git a/packages/widgets/src/beszel-system-stats/component.tsx b/packages/widgets/src/beszel-system-stats/component.tsx index eef65b348..d776af04c 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -19,6 +19,7 @@ import { useWidgetRuntimeQueries } from "../runtime-hooks"; import type { BeszelTimePeriod } from "../beszel/_shared/chart"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { BeszelStatsView } from "../beszel/_shared/stats-view"; import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection"; @@ -46,7 +47,8 @@ export default function BeszelSystemStatsWidget({ }), }); const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; + const systemsData = getUsableWidgetQueryData(systemsQuery); + const systemsResult = useMemo(() => systemsData ?? [], [systemsData]); const { isPending: systemsPending } = systemsQuery; const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); @@ -140,7 +142,10 @@ export default function BeszelSystemStatsWidget({ if (systems.length === 0) { return ( - + + + +
@@ -177,7 +182,10 @@ export default function BeszelSystemStatsWidget({ return ( - + + + + = { asc: 1, desc: -1 }; -type SystemRowWithKey = BeszelSystemRow & { _key: string }; +type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string }; const columnAccessors = [ "name", + "integrationName", + "hostname", + "osName", + "cpuModel", + "cores", + "memoryTotal", "cpu", "memory", "disk", @@ -60,7 +69,7 @@ const columnAccessors = [ "services", "uptime", "agentVersion", -] as const satisfies readonly (keyof BeszelSystemRow)[]; +] as const satisfies readonly (keyof SystemRowWithKey)[]; interface SizeConfig { iconSize: number; @@ -97,6 +106,7 @@ export default function BeszelSystemTableWidget({ boardId, itemId, setOptions, + displayMode, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); const { openModal } = useModalAction(BeszelSystemStatsModal); @@ -104,26 +114,15 @@ export default function BeszelSystemTableWidget({ const { data: session } = useSession(); const hasChangeAccess = board ? constructBoardPermissions(board, session).hasChangeAccess : false; const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const results = getUsableWidgetQueryData(systemsQuery) ?? []; + const systemsData = getUsableWidgetQueryData(systemsQuery); + const results = useMemo(() => systemsData ?? [], [systemsData]); const { isPending } = systemsQuery; + const isAdvanced = displayMode === "advanced"; const size = useMemo(() => getSizeConfig(width), [width]); - const visibleMetricKeys = useMemo(() => { - const enabled = [ - "showCpu", - "showMemory", - "showDisk", - "showGpu", - "showLoadAvg", - "showNet", - "showTemp", - "showBattery", - "showServices", - "showUptime", - "showAgent", - ].filter((key) => options[key as keyof typeof options]) as string[]; - const budget = width < 360 ? 1 : width < 560 ? 2 : width < 760 ? 4 : enabled.length; - return new Set(enabled.slice(0, budget)); - }, [options, width]); + const visibleMetricKeys = useMemo( + () => getBeszelTableVisibleMetricKeys(options, width, isAdvanced), + [options, width, isAdvanced], + ); const { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ onError: () => @@ -141,6 +140,18 @@ export default function BeszelSystemTableWidget({ }); const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter); + const integrationNames = useMemo( + () => new Map(results.map((result) => [result.integrationId, result.integrationName])), + [results], + ); + const systemsWithSource = useMemo( + () => + filteredSystems.map((system) => ({ + ...system, + integrationName: integrationNames.get(system.rowKey.split(":")[0] ?? "") ?? "—", + })), + [filteredSystems, integrationNames], + ); const [sortStatus, setSortStatus] = useState>({ columnAccessor: options.sortBy, @@ -154,16 +165,16 @@ export default function BeszelSystemTableWidget({ }, [options.sortBy, options.sortDirection]); const sortedSystems = useMemo(() => { - const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow; + const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey; const dir = directionMultiplier[sortStatus.direction] ?? 1; - return [...filteredSystems].toSorted((a, b) => { + return [...systemsWithSource].toSorted((a, b) => { const aVal = a[accessor] ?? 0; const bVal = b[accessor] ?? 0; if (typeof aVal === "string" && typeof bVal === "string") return aVal.localeCompare(bVal) * dir; if (typeof aVal === "number" && typeof bVal === "number") return (aVal - bVal) * dir; return 0; }); - }, [filteredSystems, sortStatus]); + }, [systemsWithSource, sortStatus]); const PercentCell = ({ value }: { value: number }) => ( @@ -201,6 +212,54 @@ export default function BeszelSystemTableWidget({ ), }, + isAdvanced && { + accessor: "integrationName", + width: 140, + ellipsis: true, + title: t("column.source"), + sortable: true, + render: (record) => {record.integrationName}, + }, + isAdvanced && { + accessor: "hostname", + width: 150, + ellipsis: true, + title: t("column.hostname"), + sortable: true, + render: (record) => {record.hostname || "—"}, + }, + isAdvanced && { + accessor: "osName", + width: 160, + ellipsis: true, + title: t("column.os"), + sortable: true, + render: (record) => {record.osName || "—"}, + }, + isAdvanced && { + accessor: "cpuModel", + width: 220, + ellipsis: true, + title: t("column.cpuModel"), + sortable: true, + render: (record) => {record.cpuModel || "—"}, + }, + isAdvanced && { + accessor: "cores", + width: 80, + title: t("column.cores"), + sortable: true, + render: (record) => {record.cores}, + }, + isAdvanced && { + accessor: "memoryTotal", + width: 120, + title: t("column.memoryTotal"), + sortable: true, + render: (record) => ( + {record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"} + ), + }, visibleMetricKeys.has("showCpu") && { accessor: "cpu", width: 140, @@ -357,7 +416,7 @@ export default function BeszelSystemTableWidget({ ]; return cols.filter(Boolean) as DataTableColumn[]; - }, [t, size, visibleMetricKeys]); + }, [t, size, visibleMetricKeys, isAdvanced]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, @@ -371,7 +430,7 @@ export default function BeszelSystemTableWidget({ const handleRowClick = useCallback( ({ record }: { record: SystemRowWithKey }) => { - const integrationId = record._key.split(":")[0] ?? ""; + const integrationId = record.rowKey.split(":")[0] ?? ""; openModal({ integrationId, systemId: record.id }, { title: record.name }); }, [openModal], @@ -388,7 +447,10 @@ export default function BeszelSystemTableWidget({ const table = (
- + + + +
diff --git a/packages/widgets/src/beszel-system-table/index.ts b/packages/widgets/src/beszel-system-table/index.ts index 4007bebc0..76fb0264f 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -25,7 +25,6 @@ const sortDirectionOptions = [ export const { definition, componentLoader } = createWidgetDefinition("beszelSystemTable", { icon: IconTable, - supportsAdvancedFocus: false, queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]], queryMatcher: (query, scope) => query.path.at(-1) === "getSystems" diff --git a/packages/widgets/src/bookmarks/component.tsx b/packages/widgets/src/bookmarks/component.tsx index 13e211f53..d16e08ee4 100644 --- a/packages/widgets/src/bookmarks/component.tsx +++ b/packages/widgets/src/bookmarks/component.tsx @@ -111,7 +111,7 @@ export default function BookmarksWidget({ return ( {options.title.length > 0 && ( - + <Title order={4} px="0.25rem" lineClamp={displayMode === "advanced" ? undefined : 1}> {options.title} )} @@ -283,14 +283,14 @@ const AdvancedBookmarksLayout = ({ style={{ width: 40, height: 40, flex: "0 0 auto" }} /> - + {app.name} {getBookmarkHostname(app.href)} {app.description && ( - + {app.description} )} diff --git a/packages/widgets/src/calendar/component.tsx b/packages/widgets/src/calendar/component.tsx index 5cc0cfd58..27190ba6d 100644 --- a/packages/widgets/src/calendar/component.tsx +++ b/packages/widgets/src/calendar/component.tsx @@ -10,13 +10,13 @@ import dayjs from "dayjs"; import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; -import type { CalendarEvent } from "@homarr/integrations/types"; import { useSettings } from "@homarr/settings"; -import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; +import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { CalendarDay } from "./calender-day"; @@ -27,6 +27,7 @@ import { splitEvents, toCalendarDateKey, } from "./calendar-events"; +import type { CalendarEventWithSource } from "./calendar-event-list"; import { CalendarEventList } from "./calendar-event-list"; import classes from "./component.module.css"; @@ -53,6 +54,7 @@ export default function CalendarWidget(props: WidgetComponentProps<"calendar">) events={[]} failedIntegrations={[]} isPending={false} + queryError={undefined} month={month} setMonth={setMonth} /> @@ -79,7 +81,20 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d const data = getUsableWidgetQueryData(calendarQuery); const { isPending } = calendarQuery; - const events = useMemo(() => data?.flatMap((item) => item.events) ?? [], [data]); + const events = useMemo( + () => + data?.flatMap(({ events: integrationEvents, integration, error }) => + error + ? [] + : integrationEvents.map( + (event): CalendarEventWithSource => ({ + ...event, + source: { integrationId: integration.id, integrationName: integration.name }, + }), + ), + ) ?? [], + [data], + ); const failedIntegrations = data?.flatMap(({ integration, error }) => error ? [{ integrationId: integration.id, integrationName: integration.name, error }] : [], @@ -91,6 +106,7 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d events={events} failedIntegrations={failedIntegrations} isPending={isPending} + queryError={calendarQuery.error} month={month} setMonth={setMonth} options={options} @@ -101,9 +117,10 @@ const FetchCalendar = ({ month, setMonth, isEditMode, integrationIds, options, d interface CalendarBaseProps { isEditMode: boolean; - events: CalendarEvent[]; + events: CalendarEventWithSource[]; failedIntegrations: { integrationId: string; integrationName: string; error: string }[]; isPending: boolean; + queryError: unknown; month: Date; setMonth: (date: Date) => void; options: WidgetComponentProps<"calendar">["options"]; @@ -115,11 +132,13 @@ const CalendarBase = ({ events, failedIntegrations, isPending, + queryError, month, setMonth, options, displayMode, }: CalendarBaseProps) => { + const t = useI18n(); const locale = useCurrentIntlLocale(); const { firstDayOfWeek } = useSettings(); const board = useRequiredBoard(); @@ -151,6 +170,8 @@ const CalendarBase = ({ events={agendaEvents} failedIntegrations={failedIntegrations} isPending={isPending} + queryError={queryError} + queryErrorLabel={t("widget.calendar.name")} isEditMode={isEditMode} locale={locale} month={month} @@ -161,9 +182,10 @@ const CalendarBase = ({ return ( - {failedIntegrations.length > 0 && ( + {(failedIntegrations.length > 0 || Boolean(queryError)) && ( + )} - {failedIntegrations.length > 0 && } + + + + @@ -310,7 +339,7 @@ const CalendarAgenda = ({
) : events.length > 0 ? ( - + ) : ( diff --git a/packages/widgets/src/clock/index.ts b/packages/widgets/src/clock/index.ts index 45b040009..e7657b918 100644 --- a/packages/widgets/src/clock/index.ts +++ b/packages/widgets/src/clock/index.ts @@ -6,7 +6,6 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("clock", { icon: IconClock, - supportsAdvancedFocus: true, queryKey: [["widget", "weather", "atLocation"]], queryMatcher: ({ input }, scope) => { const location = scope.options.weatherLocation; diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index 83449f190..d9c688555 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -1,17 +1,26 @@ "use client"; -import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; -import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; -import { createWidgetKey } from "./coolify-utils"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; +import { WidgetEmptyState } from "../common/empty-state"; +import type { WidgetComponentProps } from "../definition"; +import { createWidgetKey, getCoolifySectionVisibility } from "./coolify-utils"; import { InstanceCard } from "./instance-card"; import { SingleInstanceLayout } from "./single-instance-layout"; +import type { InstanceData } from "./types"; -export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) { +export default function CoolifyWidget({ + options, + integrationIds, + width, + height, + displayMode, +}: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -22,7 +31,18 @@ export default function CoolifyWidget({ options, integrationIds, width, height } ); } - return ; + const isAdvanced = displayMode === "advanced"; + const sectionVisibility = getCoolifySectionVisibility(options, displayMode); + + return ( + + ); } interface CoolifyContentProps { @@ -30,19 +50,24 @@ interface CoolifyContentProps { options: WidgetComponentProps<"coolify">["options"]; width: number; height: number; + isAdvanced: boolean; } -function CoolifyContent({ integrationIds, options, width, height }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) { const t = useScopedI18n("common"); const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({ integrationIds, }); const instancesData = getUsableWidgetQueryData(instancesQuery) ?? []; const { isPending } = instancesQuery; + const successfulInstances = instancesData.filter( + (instance): instance is InstanceData => instance.instanceInfo !== null, + ); + const failedInstances = instancesData.filter((instance) => instance.instanceInfo === null); const isTiny = width < 256 || height < 144; const hideFooter = height < 112; - const [firstInstance] = instancesData; + const [firstInstance] = successfulInstances; const widgetKey = createWidgetKey(integrationIds); if (isPending) { @@ -54,33 +79,77 @@ function CoolifyContent({ integrationIds, options, width, height }: CoolifyConte ); } - - if (instancesData.length === 1 && firstInstance) { + if (successfulInstances.length === 0) { return ( - + + + + + + + ); + } + + if (successfulInstances.length === 1 && firstInstance) { + return ( + + +
+ +
+
); } return ( - - = 760 ? 2 : 1} spacing="sm" p="xs"> - {instancesData.map((instance) => ( - - ))} - - + + + + = 760 ? 2 : 1} spacing="sm" p="xs"> + {successfulInstances.map((instance) => ( + + ))} + + + ); } + +const FailedSourceBadges = ({ + sources, + queryError, +}: { + sources: { integrationId: string; integrationName: string }[]; + queryError: unknown; +}) => { + const t = useScopedI18n("widget.coolify"); + if (sources.length === 0 && !queryError) return null; + + return ( + + {sources.map((source) => ( + + {t("source.unavailable", { source: source.integrationName })} + + ))} + + + + + ); +}; diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index a085f0ef5..aa2a61a63 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -1,6 +1,7 @@ "use client"; -import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core"; +import { useState } from "react"; +import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { useTimeAgo } from "@homarr/common"; @@ -8,7 +9,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { ApplicationsSection } from "./applications-section"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils"; +import { buildServerResourceCounts, getBadgeColor, isCoolifyServerOnline, parseStatus } from "./coolify-utils"; import { ServersSection } from "./servers-section"; import { ServicesSection } from "./services-section"; import type { CoolifyOptions, InstanceData } from "./types"; @@ -18,11 +19,12 @@ interface InstanceCardProps { instance: InstanceData; options: CoolifyOptions; isTiny: boolean; + isAdvanced: boolean; widgetKey: string; hideFooter: boolean; } -export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const cardKey = `${widgetKey}-${instance.integrationId}`; @@ -34,6 +36,7 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter key: `coolify-sections-${cardKey}`, defaultValue: ["applications"], }); + const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]); const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, @@ -41,9 +44,10 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter ); const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? ""; + const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—"; const relativeTime = useTimeAgo(instance.updatedAt); - const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length; + const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length; const runningApps = instance.instanceInfo.applications.filter( (a) => parseStatus(a.status ?? "") === "running", ).length; @@ -76,20 +80,27 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }} > - + Coolify - - {instance.integrationName} - + + + {instance.integrationName} + + {isAdvanced && ( + + {t("source.url", { url: displayUrl })} + + )} + {isTiny && totalResources > 0 ? ( @@ -125,13 +136,20 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter - + {options.showServers && ( setShowIp((prev) => !prev)} /> @@ -140,7 +158,12 @@ export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index 2e79bfdf0..ebd1a2b58 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -1,20 +1,28 @@ "use client"; import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; -import { IconCircleCheckFilled, IconCircleXFilled, IconEye, IconEyeOff, IconServer } from "@tabler/icons-react"; +import { + IconCircleCheckFilled, + IconCircleFilled, + IconCircleXFilled, + IconEye, + IconEyeOff, + IconServer, +} from "@tabler/icons-react"; import type { CoolifyServer } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; import actionTargetClasses from "../common/action-target.module.css"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; -import { getBadgeColor } from "./coolify-utils"; +import { getBadgeColor, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils"; interface ServersSectionProps { servers: CoolifyServer[]; serverResourceCounts: Map; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; showIp: boolean; onToggleIp: () => void; } @@ -24,12 +32,13 @@ export function ServersSection({ serverResourceCounts, baseUrl, isTiny, + isAdvanced, showIp, onToggleIp, }: ServersSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); - const onlineServers = servers.filter((server) => server.is_reachable !== false).length; + const onlineServers = servers.filter(isCoolifyServerOnline).length; return ( @@ -67,6 +76,7 @@ export function ServersSection({ } baseUrl={baseUrl} isTiny={isTiny} + isAdvanced={isAdvanced} showIp={showIp} /> ))} @@ -86,23 +96,28 @@ interface ServerRowProps { counts: { apps: number; services: number }; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; showIp: boolean; } -function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { +function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) { const t = useScopedI18n("widget.coolify"); const isBuildServer = server.settings?.is_build_server === true; - const isOnline = server.is_reachable !== false; + const reachability = getCoolifyServerState(server, "is_reachable"); + const usability = getCoolifyServerState(server, "is_usable"); const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`); - const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled; + const status = reachability === true ? "online" : reachability === false ? "offline" : "unknown"; + const StatusIcon = + reachability === true ? IconCircleCheckFilled : reachability === false ? IconCircleXFilled : IconCircleFilled; + const statusColor = reachability === true ? "green" : reachability === false ? "red" : "gray"; return ( - {showIp ? server.ip : "***.***.***.***"} + {showIp ? (server.ip ?? "—") : "***.***.***.***"} + {isAdvanced && ( + + + + + )} ); } + +const ServerStateBadge = ({ field, value }: { field: "reachability" | "usability"; value: boolean | undefined }) => { + const t = useScopedI18n("widget.coolify"); + const status = + value === undefined + ? t("status.unknown") + : field === "reachability" + ? t(value ? "status.reachable" : "status.unreachable") + : t(value ? "status.usable" : "status.unusable"); + return ( + + {t(`server.${field}`)}: {status} + + ); +}; diff --git a/packages/widgets/src/coolify/services-section.tsx b/packages/widgets/src/coolify/services-section.tsx index c225db27d..1961077cc 100644 --- a/packages/widgets/src/coolify/services-section.tsx +++ b/packages/widgets/src/coolify/services-section.tsx @@ -1,21 +1,24 @@ "use client"; -import { Accordion, Badge, Group, Stack, Text } from "@mantine/core"; +import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; import { IconStack2 } from "@tabler/icons-react"; import type { CoolifyServiceWithContext } from "@homarr/integrations/types"; import { useScopedI18n } from "@homarr/translation/client"; -import { getBadgeColor, parseStatus } from "./coolify-utils"; +import actionTargetClasses from "../common/action-target.module.css"; +import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { cleanFqdn, getBadgeColor, getStatusColor, parseStatus } from "./coolify-utils"; import { ResourceRow } from "./resource-row"; interface ServicesSectionProps { services: CoolifyServiceWithContext[]; baseUrl: string; isTiny: boolean; + isAdvanced: boolean; } -export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { +export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: ServicesSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; @@ -34,7 +37,19 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr {services.length > 0 ? ( {services.map((service) => ( - + + + {isAdvanced && service.applications && service.applications.length > 0 && ( + + + {tCommon("applications")} + + {service.applications.map((application) => ( + + ))} + + )} + ))} ) : ( @@ -46,3 +61,45 @@ export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionPr ); } + +type ServiceApplication = NonNullable[number]; + +const ServiceApplicationRow = ({ application }: { application: ServiceApplication }) => { + const t = useScopedI18n("widget.coolify"); + const status = parseStatus(application.status ?? ""); + const publicUrl = getSafeApplicationUrl(cleanFqdn(application.fqdn)); + const statusLabel = + status === "running" || + status === "stopped" || + status === "exited" || + status === "starting" || + status === "restarting" + ? t(`status.${status}`) + : t("status.unknown"); + + return ( + + + {statusLabel} + + {publicUrl ? ( + + {application.name} + + ) : ( + + {application.name} + + )} + + ); +}; diff --git a/packages/widgets/src/coolify/single-instance-layout.tsx b/packages/widgets/src/coolify/single-instance-layout.tsx index fc3455b2d..b7dd45b60 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; @@ -18,11 +19,19 @@ interface SingleInstanceLayoutProps { instance: InstanceData; options: CoolifyOptions; isTiny: boolean; + isAdvanced: boolean; widgetKey: string; hideFooter: boolean; } -export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ + instance, + options, + isTiny, + isAdvanced, + widgetKey, + hideFooter, +}: SingleInstanceLayoutProps) { const t = useScopedI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -32,6 +41,7 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hid key: `coolify-sections-${widgetKey}`, defaultValue: ["applications"], }); + const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]); const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, @@ -52,27 +62,41 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hid oolify - - {displayUrl} - + + {isAdvanced && ( + + {instance.integrationName} + + )} + + {isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl} + + - + {options.showServers && ( setShowIp((prev) => !prev)} /> @@ -81,7 +105,12 @@ export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hid )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index 13977a5a0..d9e4b578e 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -6,6 +6,7 @@ import { useState } from "react"; import { ActionIcon, Badge, + Box, Button, Card, Flex, @@ -33,7 +34,9 @@ import { MaskedOrNormalImage } from "@homarr/ui"; import type { widgetKind } from "."; import type { WidgetComponentProps } from "../../definition"; +import { IntegrationErrorIndicator } from "../../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../../common/query-state"; +import { WidgetQueryErrorIndicator } from "../../common/query-state-indicator"; import actionTargetClasses from "../../common/action-target.module.css"; import classes from "./component.module.css"; import TimerModal from "./TimerModal"; @@ -41,6 +44,15 @@ import TimerModal from "./TimerModal"; const dnsLightStatus = (enabled: boolean | undefined) => `var(--mantine-color-${typeof enabled === "undefined" ? "blue" : enabled ? "green" : "red"}-6`; +type DnsSummaryResult = RouterOutputs["widget"]["dnsHole"]["summary"][number]; +type AvailableDnsSummaryResult = DnsSummaryResult & { + summary: NonNullable; + integration: DnsSummaryResult["integration"] & { updatedAt: Date }; +}; + +const isAvailableDnsSummaryResult = (result: DnsSummaryResult): result is AvailableDnsSummaryResult => + result.summary !== null && result.integration.updatedAt !== undefined; + export default function DnsHoleControlsWidget({ options, integrationIds, @@ -55,7 +67,8 @@ export default function DnsHoleControlsWidget({ .filter((id) => integrationIds.includes(id)); const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); - const summaries = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? []; + const summaries = summaryResults.filter(isAvailableDnsSummaryResult); const { isPending: isSummaryPending } = summaryQuery; const utils = clientApi.useUtils(); @@ -84,7 +97,7 @@ export default function DnsHoleControlsWidget({ if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId + item.integration.id === integrationId && item.summary ? { ...item, summary: { @@ -147,7 +160,14 @@ export default function DnsHoleControlsWidget({ p="sm" gap="sm" style={{ pointerEvents: isEditMode ? "none" : undefined }} + pos="relative" > + + + + + + {controlAllButtonsVisible && ( @@ -260,7 +280,7 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise; - data: RouterOutputs["widget"]["dnsHole"]["summary"][number]; + data: AvailableDnsSummaryResult; setSelectedIntegrationIds: (integrationId: string[]) => void; open: () => void; t: TranslationFunction; diff --git a/packages/widgets/src/downloads/component.tsx b/packages/widgets/src/downloads/component.tsx index 32ab31afa..dcfe40d29 100644 --- a/packages/widgets/src/downloads/component.tsx +++ b/packages/widgets/src/downloads/component.tsx @@ -53,11 +53,18 @@ import { showErrorNotification } from "@homarr/notifications"; import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"; import type { WidgetComponentProps } from "../definition"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { HomarrDataTable } from "../common/homarr-data-table"; import { formatLocalizedDateTime } from "../common/locale"; import { usePersistedTableLayout, useTableLayoutPersistence } from "../common/use-persisted-table-layout"; -import { filterDownloadItemsByStatus, getAvailableDownloadStates } from "./helpers"; +import { + DOWNLOAD_COLUMN_ACCESSORS, + filterDownloadItemsByStatus, + getAvailableDownloadStates, + getDownloadColumnAccessors, +} from "./helpers"; dayjs.extend(relativeTime); @@ -84,6 +91,7 @@ interface ColumnContext { hasTorrents: boolean; hasMultipleClients: boolean; hasMultipleTypes: boolean; + isAdvanced: boolean; size: SizeConfig; } @@ -143,8 +151,8 @@ const columnVisibilityChecks: Partial boo upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns, ratio: (ctx) => ctx.hasTorrents, sent: (ctx) => ctx.hasTorrents, - integration: (ctx) => ctx.hasMultipleClients, - type: (ctx) => ctx.hasMultipleTypes, + integration: (ctx) => ctx.isAdvanced || ctx.hasMultipleClients, + type: (ctx) => ctx.isAdvanced || ctx.hasMultipleTypes, downSpeed: (ctx) => ctx.size.showSpeedColumns, time: (ctx) => ctx.size.showTimeColumn, state: (ctx) => ctx.size.showStateColumn, @@ -171,24 +179,6 @@ function progressColor(state: DownloadState, progress: number): string { return "blue"; } -const columnAccessors = [ - "name", - "progress", - "size", - "downSpeed", - "upSpeed", - "time", - "state", - "added", - "ratio", - "received", - "sent", - "category", - "integration", - "index", - "type", -] as const; - function toCategoryArray(category: string | string[]): string[] { if (Array.isArray(category)) return category; return [category]; @@ -320,10 +310,17 @@ export default function DownloadClientsWidget({ integrationIds, limitPerIntegration: options.limitPerIntegration, }); - const currentItems = getUsableWidgetQueryData(downloadsQuery) ?? []; + const currentItems = getUsableWidgetQueryData(downloadsQuery); + const availableItems = useMemo(() => currentItems?.filter((item) => item.data !== null) ?? [], [currentItems]); const { isFetching } = downloadsQuery; const t = useScopedI18n("widget.downloads"); + const queryIndicators = ( + + + + + ); const [clientFilter, setClientFilter] = useState([]); const [statusFilter, setStatusFilter] = useState([]); @@ -370,7 +367,7 @@ export default function DownloadClientsWidget({ }, [options.defaultSort, defaultSortDirection]); const eligibleData = useMemo(() => { - return currentItems + return availableItems .filter(({ integration }) => integrationIds.includes(integration.id)) .flatMap((pair) => pair.data.items @@ -399,7 +396,7 @@ export default function DownloadClientsWidget({ }), ); }, [ - currentItems, + availableItems, integrationIds, integrationInteractSet, mutateDeleteItem, @@ -422,7 +419,7 @@ export default function DownloadClientsWidget({ }, [data, sortStatus]); const clients = useMemo(() => { - return currentItems + return availableItems .filter(({ integration }) => integrationIds.includes(integration.id)) .map(({ integration, data: clientData }): ExtendedClientStatus => { const interact = integrationInteractSet.has(integration.id); @@ -459,7 +456,7 @@ export default function DownloadClientsWidget({ }; }); }, [ - currentItems, + availableItems, integrationIds, integrationInteractSet, options.applyFilterToRatio, @@ -472,10 +469,13 @@ export default function DownloadClientsWidget({ const hasMultipleTypes = new Set(data.map(({ type }) => type)).size > 1; const size = useMemo(() => getSizeConfig(width, height, isAdvanced), [height, isAdvanced, width]); - const optionsColumnSet = useMemo(() => new Set(options.columns), [options.columns]); + const optionsColumnSet = useMemo( + () => new Set(getDownloadColumnAccessors(options.columns, isAdvanced)), + [isAdvanced, options.columns], + ); const columnContext = useMemo( - () => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size }), - [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, size], + () => ({ optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size }), + [optionsColumnSet, hasTorrents, hasMultipleClients, hasMultipleTypes, isAdvanced, size], ); let progressColumnWidth = 120; @@ -688,7 +688,7 @@ export default function DownloadClientsWidget({ const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, - columnAccessors, + columnAccessors: DOWNLOAD_COLUMN_ACCESSORS, columnOrder: options.columnOrder, columnWidths: options.columnWidths, itemId, @@ -733,11 +733,16 @@ export default function DownloadClientsWidget({ return { stateCounts, totalSize, completedSize, totalItems: data.length }; }, [data]); - if (options.columns.length === 0) { + if (!isAdvanced && options.columns.length === 0) { return ( -
- {t("errors.noColumns")} -
+ + + {queryIndicators} + +
+ {t("errors.noColumns")} +
+
); } @@ -759,10 +764,13 @@ export default function DownloadClientsWidget({ )} + + {queryIndicators} + ({ columns: factory.multiSelect({ defaultValue: ["name", "progress", "downSpeed", "time", "state"], - options: columnsList.map((value) => ({ + options: DOWNLOAD_COLUMN_ACCESSORS.map((value) => ({ value, label: (t) => t(`widget.downloads.items.${value}.columnTitle`), })), diff --git a/packages/widgets/src/filter-storage-volumes.ts b/packages/widgets/src/filter-storage-volumes.ts index 5e88584be..9c406c65e 100644 --- a/packages/widgets/src/filter-storage-volumes.ts +++ b/packages/widgets/src/filter-storage-volumes.ts @@ -35,11 +35,7 @@ const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string ); }; -const matchesVisibleStorageVolume = ( - visibleVolume: string, - integrationId: string, - deviceName: string, -): boolean => { +const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => { const separatorIndex = visibleVolume.indexOf(":"); if (separatorIndex === -1) { return storageDeviceNamesMatch(visibleVolume, deviceName); diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index f8cfafda0..79886384e 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -24,6 +24,8 @@ import { IconCpu2, IconFileReport, IconInfoCircle, + IconPackages, + IconRefreshAlert, IconServer, IconTemperature, IconVersions, @@ -41,7 +43,9 @@ import { useI18n } from "@homarr/translation/client"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { WidgetEmptyState } from "../common/empty-state"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; import { CpuRing } from "./rings/cpu-ring"; @@ -60,21 +64,46 @@ export const SystemHealthMonitoring = ({ withScrollArea = true, }: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => { const t = useI18n(); - const healthData = - getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ?? - []; + const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); + const healthResults = getUsableWidgetQueryData(healthQuery) ?? []; + const healthData = healthResults.filter( + (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, + ); const [openedIntegrationId, setOpenedIntegrationId] = useState(null); const board = useRequiredBoard(); const isAdvanced = displayMode === "advanced"; const isTiny = !isAdvanced && width < 256; + const showCpu = isAdvanced || options.cpu; + const showMemory = isAdvanced || options.memory; + const showGpu = isAdvanced || options.gpu; + const showFileSystem = isAdvanced || options.fileSystem; + const queryIndicators = ( + + + + + ); - if (healthData.length === 0) return ; + if (isInitialWidgetQueryPending(healthQuery)) return ; + if (healthData.length === 0) { + return ( + + + {queryIndicators} + + + + ); + } const Container = withScrollArea ? ScrollArea : Box; return ( - + + + {queryIndicators} + {healthData.map(({ integrationId, integrationName, healthInfo }) => { const filteredFileSystem = filterStorageVolumes( @@ -145,18 +174,18 @@ export const SystemHealthMonitoring = ({ - {options.cpu && } - {options.cpu && ( + {showCpu && } + {showCpu && ( )} - {options.memory && ( + {showMemory && ( )} - {options.gpu && + {showGpu && healthInfo.gpu.map((gpu) => ( ))} @@ -166,7 +195,7 @@ export const SystemHealthMonitoring = ({ )} - {options.fileSystem && + {showFileSystem && disksData.map((disk) => { return ( ; const SystemInformationList = ({ healthInfo, @@ -285,6 +316,14 @@ const SystemInformationList = ({ {healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}% )} + }> + {t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })} + + }> + {healthInfo.rebootRequired + ? t("widget.healthMonitoring.popover.rebootRequired") + : t("widget.healthMonitoring.popover.rebootNotRequired")} + ); diff --git a/packages/widgets/src/indexer-manager/index.ts b/packages/widgets/src/indexer-manager/index.ts index c61bd84c1..8cc1e0eed 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -13,7 +13,6 @@ const createOptions = () => })); export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { - supportsAdvancedFocus: false, icon: IconReportSearch, contextActions: ({ widgetRuntimeRef, context }) => [ { diff --git a/packages/widgets/src/media-missing/component.tsx b/packages/widgets/src/media-missing/component.tsx index be7366f3a..15ad851f2 100644 --- a/packages/widgets/src/media-missing/component.tsx +++ b/packages/widgets/src/media-missing/component.tsx @@ -28,6 +28,7 @@ import { useScopedI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { NoIntegrationDataError } from "../errors/no-data-integration"; @@ -45,18 +46,19 @@ export default function MediaMissingWidget({ }: WidgetComponentProps<"mediaMissing">) { const t = useScopedI18n("widget.mediaMissing"); const isAdvanced = displayMode === "advanced"; + const showMissing = isAdvanced || options.showMissing; + const showQueued = isAdvanced || options.showQueued; const pageSize = isAdvanced ? Math.max(Number(options.pageSize), 50) : Number(options.pageSize); const input = { integrationIds, pageSize }; useWidgetRuntimeQueries(widgetRuntimeRef, [getQueryKey(clientApi.widget.mediaOrganizer.getData, input, "query")]); - const data = getUsableWidgetQueryData( - clientApi.widget.mediaOrganizer.getData.useQuery(input, { - staleTime: 60 * 1000, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }), - ); - const [selectedTab, setSelectedTab] = useState(options.showMissing ? "missing" : "queued"); - const activeTab = resolveMediaMissingTab(selectedTab, options.showMissing, options.showQueued); + const mediaQuery = clientApi.widget.mediaOrganizer.getData.useQuery(input, { + staleTime: 60 * 1000, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); + const data = getUsableWidgetQueryData(mediaQuery); + const [selectedTab, setSelectedTab] = useState(showMissing ? "missing" : "queued"); + const activeTab = resolveMediaMissingTab(selectedTab, showMissing, showQueued); useEffect(() => { if (activeTab !== null && activeTab !== selectedTab) setSelectedTab(activeTab); @@ -64,7 +66,7 @@ export default function MediaMissingWidget({ if (!data) return ; if (data.length === 0) throw new NoIntegrationDataError(); - if (!options.showMissing && !options.showQueued) + if (!showMissing && !showQueued) return (
@@ -79,7 +81,7 @@ export default function MediaMissingWidget({ const queuedCount = data.reduce((sum, entry) => sum + entry.queuedCount, 0); const failedIntegrations = data.filter((entry): entry is typeof entry & { error: string } => Boolean(entry.error)); - const enabledPanelCount = Number(options.showMissing) + Number(options.showQueued); + const enabledPanelCount = Number(showMissing) + Number(showQueued); const panelWidth = isAdvanced && enabledPanelCount > 1 ? width / enabledPanelCount : width; const isThin = !isAdvanced && panelWidth > 0 && panelWidth < 160; const isShort = !isAdvanced && height > 0 && height < 180; @@ -102,7 +104,12 @@ export default function MediaMissingWidget({ ) : ( {entries.map(({ item, integrationId }) => ( - + ))} )} @@ -121,13 +128,17 @@ export default function MediaMissingWidget({ ))} ); + const queryFailure = ; if (isAdvanced) { return ( - {partialFailures} + + {queryFailure} + {partialFailures} + - {options.showMissing && ( + {showMissing && ( @@ -138,7 +149,7 @@ export default function MediaMissingWidget({ {renderPanel(missing, t("empty.missing"))} )} - {options.showQueued && ( + {showQueued && ( @@ -163,26 +174,29 @@ export default function MediaMissingWidget({ h="100%" style={{ display: "flex", flexDirection: "column" }} > - {partialFailures} + + {queryFailure} + {partialFailures} + - {options.showMissing && ( + {showMissing && ( }> {tabLabel(t("tab.missing"), missing.length, missingCount)} )} - {options.showQueued && ( + {showQueued && ( }> {tabLabel(t("tab.queued"), queued.length, queuedCount)} )} - {options.showMissing && ( + {showMissing && ( {renderPanel(missing, t("empty.missing"))} )} - {options.showQueued && ( + {showQueued && ( {renderPanel(queued, t("empty.queued"))} @@ -306,7 +320,15 @@ const CardShell = ({ ); }; -const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem; density: Density }) => { +const MediaCard = ({ + item, + density, + showQueueDetails, +}: { + item: MissingMediaItem | QueuedMediaItem; + density: Density; + showQueueDetails: boolean; +}) => { const isQueued = "percentComplete" in item; return ( @@ -323,6 +345,11 @@ const MediaCard = ({ item, density }: { item: MissingMediaItem | QueuedMediaItem {item.type === "episode" ? item.title : item.year} )} + {isQueued && showQueueDetails && ( + + {[item.status, item.timeLeft].filter(Boolean).join(" · ")} + + )} {isQueued && } diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 9893a91fb..1bb724044 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -31,6 +31,7 @@ import { OverflowBadge } from "@homarr/ui"; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import classes from "./component.module.css"; @@ -44,36 +45,43 @@ export default function MediaReleasesWidget({ }: WidgetComponentProps<"mediaReleases">) { const t = useI18n(); const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds }); - const releases = getUsableWidgetQueryData(releasesQuery); + const response = getUsableWidgetQueryData(releasesQuery); if (isInitialWidgetQueryPending(releasesQuery)) return ; - if (!releases || releases.length === 0) return ; + if (!response) return ; + + const { releases, failedIntegrations } = response; const isAdvanced = displayMode === "advanced"; return ( - - - {releases.map((item, index) => ( - - {!isAdvanced && index !== 0 && options.layout === "poster" && } - - - ))} - - - {releasesQuery.error && ( - + {releases.length === 0 ? ( + + ) : ( + + + {releases.map((item, index) => ( + + {!isAdvanced && index !== 0 && options.layout === "poster" && } + + + ))} + + + )} + {(failedIntegrations.length > 0 || releasesQuery.error) && ( + + - + )} ); } interface ItemProps { - item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"][number]; + item: RouterOutputs["widget"]["mediaRelease"]["getMediaReleases"]["releases"][number]; options: WidgetComponentProps<"mediaReleases">["options"]; isAdvanced: boolean; width: number; @@ -211,7 +219,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { /> )} {isAdvanced && item.description && ( - + {item.description} )} @@ -230,9 +238,17 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { )} - {(isAdvanced || options.showSource) && ( - - )} + {(isAdvanced || options.showSource) && + (isAdvanced ? ( + + + + {item.integration.name} + + + ) : ( + + ))} )} diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index f8406ef13..ea0043b53 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -37,6 +37,7 @@ import { useTranslatedMantineReactTable } from "@homarr/ui/hooks"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import classes from "./component.module.css"; type TranscodingDecision = NonNullable["metadata"]>["transcoding"]; @@ -74,16 +75,17 @@ export default function MediaServerWidget({ isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { - const currentStreams = - getUsableWidgetQueryData( - clientApi.widget.mediaServer.getCurrentStreams.useQuery({ - integrationIds, - showOnlyPlaying: options.showOnlyPlaying, - }), - ) ?? []; + const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ + integrationIds, + showOnlyPlaying: options.showOnlyPlaying, + }); + const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery); + const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]); const t = useScopedI18n("widget.mediaServer"); const isAdvanced = displayMode === "advanced"; + const showLocation = isAdvanced || options.showLocation; + const showBitrate = isAdvanced || options.showBitrate; const [selectedRowId, setSelectedRowId] = useState(null); const columns = useMemo[]>(() => { const result: MRT_ColumnDef[] = [ @@ -142,8 +144,8 @@ export default function MediaServerWidget({ if (!currentlyPlaying) return null; const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding); - const location = options.showLocation ? currentlyPlaying.location : null; - const bitrateLabel = options.showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; + const location = showLocation ? currentlyPlaying.location : null; + const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; return ( @@ -175,7 +177,7 @@ export default function MediaServerWidget({ : []), ]; return result; - }, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, t, width]); + }, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]); // Only render the flat list of sessions when the currentStreams change // Otherwise it will always create a new array reference and cause the table to re-render @@ -285,7 +287,7 @@ export default function MediaServerWidget({ (sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0), 0, ); - const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null; + const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null; const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error)); return ( @@ -300,12 +302,14 @@ export default function MediaServerWidget({ {t("summary.transcoding", { count: transcodingCount })} + )} - {!isAdvanced && hasFailedIntegrations && ( + {!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && ( + )} diff --git a/packages/widgets/src/media-transcoding/component.tsx b/packages/widgets/src/media-transcoding/component.tsx index eddf8adea..a651bbf73 100644 --- a/packages/widgets/src/media-transcoding/component.tsx +++ b/packages/widgets/src/media-transcoding/component.tsx @@ -4,6 +4,7 @@ import { useEffect, useState } from "react"; import type { ReactNode } from "react"; import { getQueryKey } from "@trpc/react-query"; import { + Box, Center, Divider, Group, @@ -26,6 +27,7 @@ import { views } from "."; import { WidgetEmptyState } from "../common/empty-state"; import type { WidgetComponentProps } from "../definition"; import { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { useWidgetRuntimeQueries } from "../runtime-hooks"; import { HealthCheckStatus } from "./health-check-status"; import { QueuePanel } from "./panels/queue.panel"; @@ -66,7 +68,8 @@ export default function MediaTranscodingWidget({ useWidgetRuntimeQueries(widgetRuntimeRef, [ getQueryKey(clientApi.widget.mediaTranscoding.getDataAsync, input, "query"), ]); - const transcodingData = getUsableWidgetQueryData(clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input)); + const transcodingQuery = clientApi.widget.mediaTranscoding.getDataAsync.useQuery(input); + const transcodingData = getUsableWidgetQueryData(transcodingQuery); const [view, setView] = useState(options.defaultView); const t = useI18n("widget.mediaTranscoding"); @@ -94,10 +97,16 @@ export default function MediaTranscodingWidget({ const queuePageCount = totalQueuePages ?? 1; const isTiny = !isAdvanced && (width < 280 || height < 140); const footerLayout = getTranscodingFooterLayout(width, height); + const queryIndicator = ( + + + + ); if (isAdvanced) { return ( - + + {queryIndicator} = 1100 ? 3 : width >= 700 ? 2 : 1} spacing="sm"> @@ -127,7 +136,8 @@ export default function MediaTranscodingWidget({ } return ( - + + {queryIndicator} {view === "workers" ? ( ) : view === "queue" ? ( diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 6ecaae51b..b190d2a82 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -70,6 +70,7 @@ import type { TablerIcon } from "@homarr/ui"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; import { createReadOnlyTaskItemTransaction, ReadOnlyTaskItem } from "./read-only-task-item"; +import { getNotebookDisplay } from "./display"; import { NotebookTextDirection, setTextDirection } from "./text-direction"; import "@mantine/tiptap/styles.css"; @@ -98,6 +99,7 @@ export function Notebook({ boardId, itemId, height, + displayMode = "compact", }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(options.content); @@ -339,6 +341,13 @@ export function Notebook({ characters: documentText.length, words: documentText.length === 0 ? 0 : documentText.split(/\s+/).length, }; + const display = getNotebookDisplay({ + height, + isAdvanced: displayMode === "advanced", + isEditing, + isSaving, + showToolbar: options.showToolbar, + }); return ( @@ -375,9 +384,9 @@ export function Notebook({ > @@ -505,7 +514,7 @@ export function Notebook({ - {(saveError || height >= 180) && ( + {(saveError || display.showDocumentStats) && ( {saveError && ( @@ -521,7 +530,7 @@ export function Notebook({ )} - {height >= 180 && ( + {display.showDocumentStats && ( {t("widget.notebook.documentStats", documentStats)} @@ -873,9 +882,18 @@ function ListIndentIncrease() { editor?.chain().focus().sinkListItem(itemType).run(); }, [editor, itemType]); - editor?.on("selectionUpdate", ({ editor }) => { - setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); - }); + useEffect(() => { + if (!editor) return; + + const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => { + setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem"); + }; + + editor.on("selectionUpdate", handleSelectionUpdate); + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + }; + }, [editor]); return ( { - setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); - }); + useEffect(() => { + if (!editor) return; + + const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => { + setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem"); + }; + + editor.on("selectionUpdate", handleSelectionUpdate); + return () => { + editor.off("selectionUpdate", handleSelectionUpdate); + }; + }, [editor]); return ( ) { const notificationsQuery = clientApi.widget.notifications.getNotifications.useQuery({ ...options, integrationIds, }); - const notificationIntegrations = getUsableWidgetQueryData(notificationsQuery) ?? []; + const notificationData = getUsableWidgetQueryData(notificationsQuery); + const notificationIntegrations = useMemo(() => notificationData ?? [], [notificationData]); const { isPending } = notificationsQuery; const t = useI18n(); @@ -49,12 +53,23 @@ export default function NotificationsWidget({ const isDense = width < 280 || height < 180; const isRoomy = width >= 360 && height >= 220; const bodyLineClamp = height < 112 ? 1 : isDense ? 2 : height >= 300 ? 8 : 4; + const notificationDisplay = getNotificationDisplay({ + displayMode, + hideLogos: options.hideLogos, + isRoomy, + bodyLineClamp, + }); const columns = width >= 720 ? 2 : 1; const spacing = isRoomy ? "sm" : "xs"; return ( + {notificationsQuery.error && ( + + + + )} {failedIntegrations.length > 0 && ( {failedIntegrations.map((integration) => ( @@ -95,7 +110,7 @@ export default function NotificationsWidget({ }} > - {!options.hideLogos && notification.source?.iconUrl && ( + {notificationDisplay.showLogos && notification.source?.iconUrl && ( {notification.body} @@ -121,7 +136,11 @@ export default function NotificationsWidget({ diff --git a/packages/widgets/src/notifications/index.ts b/packages/widgets/src/notifications/index.ts index 6358d8303..60bdc8758 100644 --- a/packages/widgets/src/notifications/index.ts +++ b/packages/widgets/src/notifications/index.ts @@ -6,7 +6,7 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("notifications", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconMessage, queryKey: [["widget", "notifications", "getNotifications"]], queryMatcher: ({ input }, scope) => diff --git a/packages/widgets/src/patchmon/index.ts b/packages/widgets/src/patchmon/index.ts index 2bd52e383..d715e90ac 100644 --- a/packages/widgets/src/patchmon/index.ts +++ b/packages/widgets/src/patchmon/index.ts @@ -190,18 +190,42 @@ export const { definition, componentLoader } = createWidgetDefinition("patchmon" useCustomThresholds: { shouldHide: (options) => !options.enableThresholdColors, }, - hostsNeedingUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - hostsNeedingUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - hostsNeedingUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - securityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - securityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - securityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - hostsWithSecurityUpdatesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - hostsWithSecurityUpdatesWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - hostsWithSecurityUpdatesCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - upToDateHostsThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - upToDateHostsWarningAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, - upToDateHostsCriticalAt: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds }, + hostsNeedingUpdatesThresholdMode: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + hostsNeedingUpdatesWarningAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + hostsNeedingUpdatesCriticalAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + securityUpdatesThresholdMode: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + securityUpdatesWarningAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + securityUpdatesCriticalAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + hostsWithSecurityUpdatesThresholdMode: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + hostsWithSecurityUpdatesWarningAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + hostsWithSecurityUpdatesCriticalAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + upToDateHostsThresholdMode: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + upToDateHostsWarningAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, + upToDateHostsCriticalAt: { + shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds, + }, totalOutdatedPackagesThresholdMode: { shouldHide: (options) => !options.enableThresholdColors || !options.useCustomThresholds || !options.showTotalOutdatedPackages, diff --git a/packages/widgets/src/releases/index.ts b/packages/widgets/src/releases/index.ts index 0507e8f42..75a42f203 100644 --- a/packages/widgets/src/releases/index.ts +++ b/packages/widgets/src/releases/index.ts @@ -13,7 +13,7 @@ const relativeDateSchema = z .or(z.literal("")); export const { definition, componentLoader } = createWidgetDefinition("releases", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconRocket, queryKey: [["widget", "releases", "getLatest"]], queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }), diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index 5bcd3bfd3..db8a31641 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -9,8 +9,10 @@ import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useScopedI18n } from "@homarr/translation/client"; -import type { WidgetComponentProps } from "../definition"; import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; +import { WidgetEmptyState } from "../common/empty-state"; +import { WidgetQueryLoadingState } from "../common/query-state-indicator"; +import type { WidgetComponentProps } from "../definition"; import { BaseWidgetError } from "../errors/base-component"; import classes from "./component.module.scss"; @@ -27,6 +29,7 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { hasError: false as const, entries: normalizedData, failedFeedCount: 0, + isPending: query.isPending, isStale: Boolean(query.error), }; } @@ -35,11 +38,12 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { hasError: false as const, entries: normalizedData?.entries ?? [], failedFeedCount: normalizedData?.failedFeedCount ?? 0, + isPending: query.isPending, isStale: Boolean(query.error), }; }; -export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width, height, displayMode }: WidgetComponentProps<"rssFeed">) { const feed = useLiveFeed({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, @@ -57,6 +61,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps /> ); } + if (feed.isPending) return ; const warning = feed.isStale ? t("warning.stale") @@ -64,13 +69,35 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps ? t("warning.partial", { count: feed.failedFeedCount }) : undefined; + if (feed.entries.length === 0) { + return ( + + {warning && ( + } p="xs"> + {warning} + + )} + + + ); + } + const languageDir = options.enableRtl ? "RTL" : "LTR"; const isDense = width < 420 || height < 180; const isTiny = width < 260 || height < 110; const isRoomy = width >= 420 && height >= 240; + const isAdvanced = displayMode === "advanced"; const columns = width >= 720 && height >= 260 ? 2 : 1; const descriptionLines = isRoomy ? Math.max(options.textLinesClamp, 6) : isDense ? 1 : options.textLinesClamp; + const entryDisplay = getRssEntryDisplay({ + isAdvanced, + isDense, + isTiny, + hideDescription: options.hideDescription, + showPosterImage: options.showPosterImage, + descriptionLines, + }); const spacing = isRoomy ? "sm" : "xs"; return ( @@ -97,7 +124,7 @@ export default function RssFeed({ options, width, height }: WidgetComponentProps title={feedEntry.title} > - {feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && ( + {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( - + {feedEntry.title} - {!options.hideDescription && feedEntry.description && !isTiny && ( - + {entryDisplay.showDescription && feedEntry.description && ( + {feedDescriptionToText(feedEntry.description)} )} @@ -151,15 +189,44 @@ export const getHostname = (url: string): string => { } }; -const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => ( - +interface RssEntryDisplayInput { + isAdvanced: boolean; + isDense: boolean; + isTiny: boolean; + hideDescription: boolean; + showPosterImage: boolean; + descriptionLines: number; +} + +export const getRssEntryDisplay = ({ + isAdvanced, + isDense, + isTiny, + hideDescription, + showPosterImage, + descriptionLines, +}: RssEntryDisplayInput) => ({ + showDescription: isAdvanced || (!hideDescription && !isTiny), + showImage: isAdvanced || (showPosterImage && !isTiny), + showSource: isAdvanced || !isDense, + descriptionLineClamp: isAdvanced ? undefined : descriptionLines, +}); + +const InfoDisplay = ({ date, timestamp, source }: { date?: string; timestamp?: string; source?: string }) => ( + {date && } {date && ( {date} )} - {date && source && •} + {timestamp && •} + {timestamp && ( + + {timestamp} + + )} + {(date || timestamp) && source && •} {source && ( {source} diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index 683dd07ef..abf728aa0 100644 --- a/packages/widgets/src/rssFeed/index.ts +++ b/packages/widgets/src/rssFeed/index.ts @@ -11,7 +11,7 @@ import { optionsBuilder } from "../options"; * - https://www.jsonfeed.org/version/1.1/ */ export const { definition, componentLoader } = createWidgetDefinition("rssFeed", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconRss, queryKey: [["widget", "rssFeed", "getFeeds"]], queryMatcher: ({ input }, scope) => diff --git a/packages/widgets/src/stocks/index.ts b/packages/widgets/src/stocks/index.ts index 2b6e637e6..ea6398180 100644 --- a/packages/widgets/src/stocks/index.ts +++ b/packages/widgets/src/stocks/index.ts @@ -12,7 +12,7 @@ const timeRangeOptions = stockPriceTimeFrames.range; const timeIntervalOptions = stockPriceTimeFrames.interval; export const { definition, componentLoader } = createWidgetDefinition("stockPrice", { - supportsAdvancedFocus: false, + supportsAdvancedFocus: true, icon: IconBuildingBank, queryKey: [["widget", "stockPrice", "getPriceHistory"]], queryMatcher: ({ input }, scope) => diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx index 80e0dd1e8..f9b8cac37 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -9,16 +9,21 @@ import { formatBytesPair } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { getUsableWidgetQueryData } from "../common/query-state"; +import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; +import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; +import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; import type { WidgetComponentProps } from "../definition"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { NoIntegrationDataError } from "../errors/no-data-integration"; -type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; +type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; + +export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); export const getDisplayText = ( item: { used: string; available: string; percentage: number }, - displayMode: DisplayMode, + displayMode: DiskDisplayMode, + translatedFreeText?: string, ) => { switch (displayMode) { case "percentage": @@ -27,27 +32,26 @@ export const getDisplayText = ( const usedInBytes = Number(item.used); const availableInBytes = Number(item.available); if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) { - const { used, available } = formatBytesPair(usedInBytes, availableInBytes); - return `${used} / ${available}`; + const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes); + return `${used} / ${total}`; } return `${item.used} / ${item.available}`; } case "free": - return `${Math.round(100 - item.percentage)}% free`; + return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`; default: return `${Math.round(item.percentage)}%`; } }; -export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); - -const getAbsoluteText = (item: { used: string; available: string }) => { - const usedInBytes = Number(item.used); - const availableInBytes = Number(item.available); - if (!Number.isFinite(usedInBytes) || !Number.isFinite(availableInBytes)) return `${item.used} / ${item.available}`; - const { used, available } = formatBytesPair(usedInBytes, availableInBytes); - return `${used} / ${available}`; -}; +export const getAdvancedDisplayTexts = ( + item: { used: string; available: string; percentage: number }, + translatedFreeText?: string, +) => ({ + percentage: getDisplayText(item, "percentage"), + absolute: getDisplayText(item, "absolute"), + free: getDisplayText(item, "free", translatedFreeText), +}); interface SystemDiskCardProps { deviceName: string; @@ -58,6 +62,9 @@ interface SystemDiskCardProps { showBackgroundBar: boolean; integrationName?: string; secondaryText?: string; + freeText?: string; + smartStatus?: string; + isAdvanced: boolean; showSecondaryText: boolean; showTemperature: boolean; } @@ -71,6 +78,9 @@ const SystemDiskCard = ({ showBackgroundBar, integrationName, secondaryText, + freeText, + smartStatus, + isAdvanced, showSecondaryText, showTemperature, }: SystemDiskCardProps) => { @@ -98,7 +108,9 @@ const SystemDiskCard = ({ }, [displayText, healthy]); const unhealthyLabel = t("widget.systemDisks.status.unhealthy"); - const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature; + const hasTemperature = temperature !== null && temperature !== undefined; + const temperatureText = hasTemperature ? `${temperature}°C` : "—°C"; + const hasHiddenTemperature = hasTemperature && !showTemperature; const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText); const tooltipLabel = [ healthy ? displayText : `${displayText} (${unhealthyLabel})`, @@ -140,11 +152,19 @@ const SystemDiskCard = ({ {secondaryText} )} + {isAdvanced && freeText && freeText !== displayText && ( + + {freeText} + + )} + {isAdvanced && ( + + {t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"} + + )}
- {showTemperature && temperature !== null && temperature !== undefined ? ( - {temperature}°C - ) : null} + {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null}
) { + const t = useI18n(); const queryInput = { integrationIds }; - const data = - getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? []; + const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput); + const results = getUsableWidgetQueryData(healthQuery) ?? []; + const data = results.filter( + (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, + ); + const queryIndicators = ( + + + + + ); + const emptyState = ( + + + {queryIndicators} + + + + ); - if (data.length === 0) return ; + if (isInitialWidgetQueryPending(healthQuery)) return ; + if (data.length === 0) return emptyState; const disks = data.flatMap((entry) => { const fileSystem = filterStorageVolumes( @@ -193,32 +233,52 @@ export default function SystemResources({ })); }); - if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError(); - if (disks.length === 0) return ; + const hasNoFileSystems = data.every((entry) => entry.healthInfo.fileSystem.length === 0); + const hasFailedSource = results.some((entry) => Boolean(entry.error)); + if (hasNoFileSystems && !hasFailedSource && !healthQuery.error) throw new NoIntegrationDataError(); + if (hasNoFileSystems || disks.length === 0) return emptyState; + const isAdvanced = displayMode === "advanced"; const minimumCardWidth = 260; const columns = Math.max(1, Math.min(disks.length, Math.floor(width / minimumCardWidth))); const cellWidth = width / columns; return ( - - - {disks.map(({ integrationId, integrationName, item, smartItem }) => ( - 1 ? integrationName : undefined} - secondaryText={getAbsoluteText(item)} - showSecondaryText={height >= 160 && cellWidth >= 260} - showTemperature={height >= 100 && cellWidth >= 220} - /> - ))} - - + + + {queryIndicators} + + + + {disks.map(({ integrationId, integrationName, item, smartItem }) => { + const freeText = t("widget.systemDisks.status.free", { + percentage: String(Math.round(100 - clampPercentage(item.percentage))), + }); + const advancedDisplayTexts = getAdvancedDisplayTexts(item, freeText); + + return ( + 1 ? integrationName : undefined} + secondaryText={advancedDisplayTexts.absolute} + freeText={advancedDisplayTexts.free} + smartStatus={smartItem?.overallStatus} + isAdvanced={isAdvanced} + showSecondaryText={isAdvanced || (height >= 160 && cellWidth >= 260)} + showTemperature={isAdvanced || (height >= 100 && cellWidth >= 220)} + /> + ); + })} + + + ); } diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index 018a3388f..87f0984dc 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -4,10 +4,13 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { Box, Center, Group, Loader, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; +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 { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { CombinedNetworkTrafficChart } from "./chart/combined-network-traffic"; import { SystemResourceCPUChart } from "./chart/cpu-chart"; import { SystemResourceGPUChart } from "./chart/gpu-chart"; @@ -16,6 +19,9 @@ import { NetworkTrafficChart } from "./chart/network-traffic"; const COMPACT_HISTORY_SIZE = 15; const ADVANCED_HISTORY_SIZE = 60; +const ALL_SYSTEM_CHARTS = ["cpu", "memory", "gpu", "network"] as const; + +type SystemChart = WidgetComponentProps<"systemResources">["options"]["visibleCharts"][number]; type ChartItem = { cpu: number; @@ -51,6 +57,26 @@ export const getCompactChartBudget = (height: number): number => { return 4; }; +export const getVisibleSystemCharts = ({ + configuredCharts, + hasGpu, + hasNetwork, + height, + isAdvanced, +}: { + configuredCharts: readonly SystemChart[]; + hasGpu: boolean; + hasNetwork: boolean; + height: number; + isAdvanced: boolean; +}): SystemChart[] => { + const selectedCharts = isAdvanced ? ALL_SYSTEM_CHARTS : configuredCharts; + const availableCharts = selectedCharts.filter( + (chart) => (chart !== "gpu" || !isAdvanced || hasGpu) && (chart !== "network" || hasNetwork), + ); + return isAdvanced ? [...availableCharts] : availableCharts.slice(0, getCompactChartBudget(height)); +}; + export default function SystemResources({ integrationIds, options, @@ -58,13 +84,23 @@ export default function SystemResources({ height, displayMode, }: WidgetComponentProps<"systemResources">) { + const t = useScopedI18n("widget.systemResources"); const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); - const data = getUsableWidgetQueryData(healthQuery) ?? []; + const results = getUsableWidgetQueryData(healthQuery) ?? []; + const data = results.filter( + (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, + ); const { dataUpdatedAt, isPending } = healthQuery; const isAdvanced = displayMode === "advanced"; const integrationKey = useMemo(() => integrationIds.join("\u0000"), [integrationIds]); const [historyByIntegration, setHistoryByIntegration] = useState>({}); const previousIntegrationKey = useRef(integrationKey); + const queryIndicators = ( + + + + + ); useEffect(() => { if (previousIntegrationKey.current === integrationKey) return; @@ -102,6 +138,7 @@ export default function SystemResources({ key={entry.integrationId} integrationName={entry.integrationName} items={history} + hasGpu={entry.healthInfo.gpu.length > 0} memoryCapacityInBytes={entry.healthInfo.memAvailableInBytes + entry.healthInfo.memUsedInBytes} options={options} width={availableWidth} @@ -120,11 +157,23 @@ export default function SystemResources({ ); } - if (data.length === 0) return ; + if (data.length === 0) { + return ( + + + {queryIndicators} + + + + ); + } if (!isAdvanced && data.length === 1 && data[0]) { return ( - + + + {queryIndicators} + {renderCharts(data[0], width, Math.max(0, height - 20))} ); @@ -133,17 +182,23 @@ export default function SystemResources({ const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2))); return ( - - - {data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))} - - + + + {queryIndicators} + + + + {data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))} + + + ); } interface SystemChartsProps { integrationName: string; items: ChartItem[]; + hasGpu: boolean; memoryCapacityInBytes: number; options: WidgetComponentProps<"systemResources">["options"]; width: number; @@ -155,6 +210,7 @@ interface SystemChartsProps { const SystemCharts = ({ integrationName, items, + hasGpu, memoryCapacityInBytes, options, width, @@ -163,9 +219,15 @@ const SystemCharts = ({ showTitle, }: SystemChartsProps) => { const networkItems = getNetworkHistory(items); - const availableCharts = options.visibleCharts.filter((chart) => chart !== "network" || networkItems.length > 0); - const visibleCharts = isAdvanced ? availableCharts : availableCharts.slice(0, getCompactChartBudget(height)); + const visibleCharts = getVisibleSystemCharts({ + configuredCharts: options.visibleCharts, + hasGpu, + hasNetwork: networkItems.length > 0, + height, + isAdvanced, + }); const showNetwork = visibleCharts.includes("network"); + const labelDisplayMode = isAdvanced ? "textWithIcon" : options.labelDisplayMode; const chartCount = visibleCharts.length; const chartColumns = isAdvanced && width >= 560 ? 2 : 1; const chartHeight = isAdvanced @@ -192,7 +254,7 @@ const SystemCharts = ({ item.cpu)} hasShadow={options.hasShadow} - labelDisplayMode={options.labelDisplayMode} + labelDisplayMode={labelDisplayMode} /> )} @@ -202,7 +264,7 @@ const SystemCharts = ({ memoryUsageOverTime={items.map((item) => item.memory)} totalCapacityInBytes={memoryCapacityInBytes} hasShadow={options.hasShadow} - labelDisplayMode={options.labelDisplayMode} + labelDisplayMode={labelDisplayMode} /> )} @@ -211,7 +273,7 @@ const SystemCharts = ({ item.gpu)} hasShadow={options.hasShadow} - labelDisplayMode={options.labelDisplayMode} + labelDisplayMode={labelDisplayMode} /> )} @@ -222,13 +284,13 @@ const SystemCharts = ({ usageOverTime={networkItems.map((network) => network.down)} isUp={false} hasShadow={options.hasShadow} - labelDisplayMode={options.labelDisplayMode} + labelDisplayMode={labelDisplayMode} /> network.up)} isUp hasShadow={options.hasShadow} - labelDisplayMode={options.labelDisplayMode} + labelDisplayMode={labelDisplayMode} />
) : ( @@ -236,7 +298,7 @@ const SystemCharts = ({ ))} diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index 6dccbbb11..c7c803898 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -9,8 +9,10 @@ import { formatNumber } from "@homarr/common"; 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 { getUsableWidgetQueryData } from "../common/query-state"; +import { WidgetQueryErrorIndicator } from "../common/query-state-indicator"; import { NoIntegrationDataError } from "../errors/no-data-integration"; import classes from "./component.module.css"; import { aggregateUptimeKumaDashboards } from "./aggregate"; @@ -128,12 +130,16 @@ interface UptimeKumaContentProps { function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const dashboardData = getUsableWidgetQueryData(clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds })); + const dashboardQuery = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); + const dashboardData = getUsableWidgetQueryData(dashboardQuery); if (!dashboardData) return ; + const successfulDashboardData = dashboardData.flatMap((item) => + item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }], + ); - const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard)); - const monitors = dashboardData.flatMap((result) => + const combined = aggregateUptimeKumaDashboards(successfulDashboardData.map((item) => item.dashboard)); + const monitors = successfulDashboardData.flatMap((result) => result.dashboard.monitors.map((monitor) => ({ ...monitor, key: `${result.integrationId}:${monitor.id}`, @@ -145,25 +151,22 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode const ringSize = getRingSize(width); const iconSize = getIconSize(width); const gridCols = getGridCols(width); + const isAdvanced = displayMode === "advanced"; const enabledStatKeys = Object.entries(statVisibilityByOption) - .filter(([optionKey]) => options[optionKey as keyof typeof options]) + .filter(([optionKey]) => isAdvanced || options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - const showHero = options.showAverageUptime; - const showRing = options.showUptimeRing && (displayMode === "advanced" || (width >= 170 && height >= 110)); - const prioritizedStatKeys = - displayMode === "advanced" - ? enabledStatKeys - : enabledStatKeys.toSorted( - (left, right) => - getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), - ); + const showHero = isAdvanced || options.showAverageUptime; + const showRing = isAdvanced || (options.showUptimeRing && width >= 170 && height >= 110); + const prioritizedStatKeys = isAdvanced + ? enabledStatKeys + : enabledStatKeys.toSorted( + (left, right) => getCompactStatPriority(left, combined[left]) - getCompactStatPriority(right, combined[right]), + ); const visibleStatKeys = prioritizedStatKeys.slice( 0, - displayMode === "advanced" - ? prioritizedStatKeys.length - : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), + isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), ); const hasContent = showHero || visibleStatKeys.length > 0; @@ -245,7 +248,17 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode ); - if (displayMode === "compact") return summaryContent; + if (displayMode === "compact") { + return ( + + + + + + {summaryContent} + + ); + } const monitorList = ( @@ -257,7 +270,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode {monitor.name} - {dashboardData.length > 1 && ( + {(isAdvanced || successfulDashboardData.length > 1) && ( {monitor.integrationName} @@ -274,20 +287,32 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode if (width < 760) { return ( - - - {summaryContent} - {monitorList} - - + + + + + + + + {summaryContent} + {monitorList} + + + ); } return ( - - {summaryContent} - {monitorList} - + + + + + + + {summaryContent} + {monitorList} + + ); }