diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx index 12b9ad2e9..4390f9acc 100644 --- a/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_creator.tsx @@ -38,7 +38,6 @@ export const createBoardContentPage = >( headerActions: , getInitialBoardAsync: getInitialBoard, withTour: true, - mobileProfileInActions: true, }), // eslint-disable-next-line no-restricted-syntax page: async ({ params }: { params: Promise }) => { diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts new file mode 100644 index 000000000..448119508 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.spec.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "vitest"; + +import { shouldPrefetchWidgetForRequest } from "./_prefetch-policy"; + +describe("shouldPrefetchWidgetForRequest", () => { + test.each(["mobile", "tablet"])("defers downloads for %s requests", (deviceType) => { + expect(shouldPrefetchWidgetForRequest("downloads", deviceType)).toBe(false); + }); + + test("keeps downloads prefetched for desktop requests", () => { + expect(shouldPrefetchWidgetForRequest("downloads", undefined)).toBe(true); + }); + + test.each(["app", "bookmarks"] as const)("keeps eager %s data prefetched on mobile", (kind) => { + expect(shouldPrefetchWidgetForRequest(kind, "mobile")).toBe(true); + }); +}); diff --git a/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts new file mode 100644 index 000000000..c5a89a754 --- /dev/null +++ b/apps/nextjs/src/app/[locale]/boards/(content)/_prefetch-policy.ts @@ -0,0 +1,7 @@ +import type { WidgetKind } from "@homarr/definitions"; + +export const shouldPrefetchWidgetForRequest = (kind: WidgetKind, deviceType: string | undefined) => { + const isMobileDevice = deviceType === "mobile" || deviceType === "tablet"; + + return kind !== "downloads" || !isMobileDevice; +}; diff --git a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx index f28a04489..7b4551ff5 100644 --- a/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx +++ b/apps/nextjs/src/app/[locale]/boards/_board-switcher-logo.tsx @@ -8,12 +8,22 @@ import { useRequiredBoard } from "@homarr/boards/context"; import { useI18n } from "@homarr/translation/client"; import { Link } from "@homarr/ui"; -import { BoardLogo } from "~/components/layout/logo/board-logo"; +import { useIsMobileBoard } from "~/components/board/use-mobile-board"; +import { BoardLogo, BoardLogoWithTitle } from "~/components/layout/logo/board-logo"; export const BoardSwitcherLogo = () => { const board = useRequiredBoard(); - const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(); const t = useI18n(); + const isMobileBoard = useIsMobileBoard(); + const { data: boards = [] } = clientApi.board.getAllBoards.useQuery(undefined, { enabled: isMobileBoard }); + + if (!isMobileBoard) { + return ( + + + + ); + } return ( @@ -26,7 +36,7 @@ export const BoardSwitcherLogo = () => { > - + {board.name} @@ -46,7 +56,9 @@ export const BoardSwitcherLogo = () => { leftSection={isCurrent ? : } aria-current={isCurrent ? "page" : undefined} > - {availableBoard.name} + + {availableBoard.name} + ); })} diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts new file mode 100644 index 000000000..956bce38c --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, test } from "vitest"; + +import { getMobilePreloadRootMargin, mobilePreloadViewportMultiplier } from "./deferred-mobile-item"; + +describe("getMobilePreloadRootMargin", () => { + test.each([ + [568, "1136px 0px"], + [844, "1688px 0px"], + [932, "1864px 0px"], + ])("preloads %i px viewports using vertical pixels", (viewportHeight, expected) => { + expect(getMobilePreloadRootMargin(viewportHeight)).toBe(expected); + }); + + test("keeps the preload policy explicit and prevents negative margins", () => { + expect(mobilePreloadViewportMultiplier).toBe(2); + expect(getMobilePreloadRootMargin(-100)).toBe("0px 0px"); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx index ea142a886..de0fa9d98 100644 --- a/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx +++ b/apps/nextjs/src/components/board/mobile/deferred-mobile-item.tsx @@ -8,14 +8,25 @@ import classes from "./mobile-board.module.css"; interface DeferredMobileItemProps extends PropsWithChildren { eager: boolean; + unmountWhenOffscreen: boolean; } -export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) => { +export const mobilePreloadViewportMultiplier = 2; + +export const getMobilePreloadRootMargin = (viewportHeight: number) => + `${Math.max(0, Math.round(viewportHeight * mobilePreloadViewportMultiplier))}px 0px`; + +export const DeferredMobileItem = ({ eager, unmountWhenOffscreen, children }: DeferredMobileItemProps) => { const [hasMounted, setHasMounted] = useState(eager); const wrapperRef = useRef(null); + const hasMountedOnceRef = useRef(eager); useEffect(() => { - if (eager || hasMounted) return; + if (eager) { + hasMountedOnceRef.current = true; + setHasMounted(true); + return; + } const element = wrapperRef.current; if (!element || typeof IntersectionObserver === "undefined") { @@ -23,17 +34,37 @@ export const DeferredMobileItem = ({ eager, children }: DeferredMobileItemProps) return; } - const observer = new IntersectionObserver( - ([entry]) => { - if (!entry?.isIntersecting) return; - setHasMounted(true); - observer.disconnect(); - }, - { rootMargin: "200% 0px" }, - ); - observer.observe(element); - return () => observer.disconnect(); - }, [eager, hasMounted]); + let observer: IntersectionObserver | null = null; + const observe = () => { + if (hasMountedOnceRef.current && !unmountWhenOffscreen) return; + observer?.disconnect(); + observer = new IntersectionObserver( + ([entry]) => { + const isNearViewport = entry?.isIntersecting ?? false; + + if (isNearViewport) { + hasMountedOnceRef.current = true; + setHasMounted(true); + if (!unmountWhenOffscreen) observer?.disconnect(); + return; + } + + if (unmountWhenOffscreen && !element.contains(document.activeElement)) { + setHasMounted(false); + } + }, + { rootMargin: getMobilePreloadRootMargin(window.innerHeight) }, + ); + observer.observe(element); + }; + + observe(); + window.addEventListener("resize", observe); + return () => { + window.removeEventListener("resize", observe); + observer?.disconnect(); + }; + }, [eager, unmountWhenOffscreen]); return (
diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.module.css b/apps/nextjs/src/components/board/mobile/mobile-board.module.css index fae316cc7..78026b2f1 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.module.css +++ b/apps/nextjs/src/components/board/mobile/mobile-board.module.css @@ -6,6 +6,18 @@ overflow-x: clip; } +.boardTitle { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .grid { display: grid; grid-template-columns: repeat(var(--mobile-column-count), minmax(0, 1fr)); @@ -18,8 +30,6 @@ position: relative; min-width: 0; min-height: 0; - content-visibility: auto; - contain-intrinsic-size: auto 12rem; } .deferredItem { @@ -34,9 +44,18 @@ } .sectionHeading { + width: fit-content; + max-width: 100%; scroll-margin-top: calc(var(--app-shell-header-height) + var(--mantine-spacing-md)); - padding-block: var(--mantine-spacing-xs); + padding: var(--mantine-spacing-xs) var(--mantine-spacing-sm); + color: var(--mantine-color-text); + background: var(--mantine-color-body); + border: 1px solid var(--mantine-color-default-border); + border-radius: var(--mantine-radius-md); + box-shadow: var(--mantine-shadow-xs); outline-offset: 4px; + overflow-wrap: anywhere; + text-wrap: pretty; } .empty { @@ -48,14 +67,21 @@ position: absolute; z-index: 11; top: 4px; - right: 4px; + inset-inline-end: 4px; border: 1px solid color-mix(in srgb, var(--mantine-color-white) 30%, transparent); box-shadow: var(--mantine-shadow-sm); } .detailContent, .detailContent > :global(.grid-stack-item-content) { + position: relative; width: 100%; height: 100%; min-height: 24rem; } + +.detailContent :global(.mantine-ActionIcon-root), +.detailContent :global(.mantine-Button-root) { + min-width: 44px; + min-height: 44px; +} diff --git a/apps/nextjs/src/components/board/mobile/mobile-board.tsx b/apps/nextjs/src/components/board/mobile/mobile-board.tsx index 918ea6217..6f67e977f 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-board.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-board.tsx @@ -13,6 +13,7 @@ import { widgetImports } from "@homarr/widgets"; import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; +import { useBoardPermissions } from "../permissions/client"; import { SectionProvider } from "../sections/section-context"; import { DeferredMobileItem } from "./deferred-mobile-item"; import classes from "./mobile-board.module.css"; @@ -70,19 +71,16 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { data-mobile-board-item={item.id} data-mobile-display-mode={presentation.displayMode} > - + - + ); }; @@ -90,6 +88,7 @@ const MobileBoardItem = ({ item }: { item: SectionItem }) => { export const MobileBoard = () => { const board = useRequiredBoard(); const t = useI18n(); + const { hasChangeAccess } = useBoardPermissions(board); const desktopLayout = getDesktopLayout(board); const items = useMemo(() => createMobileBoardItems(board, desktopLayout.id), [board, desktopLayout.id]); const elements = useMemo(() => createMobileBoardElements(board, desktopLayout.id), [board, desktopLayout.id]); @@ -110,6 +109,9 @@ export const MobileBoard = () => { }} > + + {board.name} + {elements.length === 0 ? (
@@ -120,7 +122,7 @@ export const MobileBoard = () => { {t("board.mobile.empty.title")} - {t("board.mobile.empty.description")} + {t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
@@ -130,10 +132,12 @@ export const MobileBoard = () => { {block.title} diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts new file mode 100644 index 000000000..374944e99 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.spec.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "vitest"; + +import { hasOpenDialogOutside } from "./mobile-dialog-stack"; + +describe("hasOpenDialogOutside", () => { + test("detects an open child dialog outside the details portal", () => { + const root = document.createElement("div"); + const details = document.createElement("div"); + const child = document.createElement("div"); + details.setAttribute("role", "dialog"); + child.setAttribute("role", "dialog"); + root.append(details, child); + + expect(hasOpenDialogOutside(details, root)).toBe(true); + }); + + test("ignores hidden and closed dialogs", () => { + const root = document.createElement("div"); + const details = document.createElement("div"); + const hiddenChild = document.createElement("div"); + details.setAttribute("role", "dialog"); + hiddenChild.setAttribute("role", "dialog"); + hiddenChild.setAttribute("aria-hidden", "true"); + root.append(details, hiddenChild); + + expect(hasOpenDialogOutside(details, root)).toBe(false); + }); + + test("does not treat the current details dialog as nested", () => { + const details = document.createElement("div"); + details.setAttribute("role", "dialog"); + + expect(hasOpenDialogOutside(details, details)).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts new file mode 100644 index 000000000..b46eaf9b3 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-dialog-stack.ts @@ -0,0 +1,47 @@ +"use client"; + +import type { RefObject } from "react"; +import { useEffect, useState } from "react"; + +const getDialogElement = (element: HTMLElement | null) => { + if (!element) return null; + if (element.matches('[role="dialog"]')) return element; + return element.closest('[role="dialog"]') ?? element.querySelector('[role="dialog"]'); +}; + +const isOpenDialog = (element: Element) => + element.getAttribute("aria-hidden") !== "true" && + element.getAttribute("data-hidden") !== "true" && + element.getAttribute("data-state") !== "closed" && + !element.hasAttribute("hidden"); + +export const hasOpenDialogOutside = (element: HTMLElement | null, root: ParentNode = document) => { + const currentDialog = getDialogElement(element); + + return Array.from(root.querySelectorAll('[role="dialog"]')).some( + (dialog) => dialog !== currentDialog && isOpenDialog(dialog), + ); +}; + +export const useHasNestedDialog = (opened: boolean, dialogRef: RefObject) => { + const [hasNestedDialog, setHasNestedDialog] = useState(false); + + useEffect(() => { + if (!opened || typeof MutationObserver === "undefined") return; + + const update = () => setHasNestedDialog(hasOpenDialogOutside(dialogRef.current)); + update(); + + const observer = new MutationObserver(update); + observer.observe(document.body, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["aria-hidden", "data-hidden", "data-state", "hidden"], + }); + + return () => observer.disconnect(); + }, [dialogRef, opened]); + + return opened && hasNestedDialog; +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts index f617694d5..1a917d8fb 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.spec.ts @@ -147,7 +147,10 @@ describe("createMobileBoardItems", () => { type: "sectionHeading", sectionId: category.id, anchorId: getMobileSectionAnchorId(category.id), + headingLevel: 2, }); + expect(result[2]).toMatchObject({ type: "sectionHeading", headingLevel: 3 }); + expect(result[4]).toMatchObject({ type: "sectionHeading", headingLevel: 4 }); }); test("omits headings for empty and unnamed sections while preserving their items", () => { @@ -180,4 +183,31 @@ describe("createMobileBoardItems", () => { expect(result.map((element) => element.type)).toEqual(["item", "item"]); expect(result.map((element) => element.id)).toEqual(["inside-unnamed", "orphan"]); }); + + test("does not skip heading levels when unnamed sections are flattened", () => { + const board = new BoardMockBuilder().build(); + const desktopLayoutId = board.layouts.at(0)?.id; + if (!desktopLayoutId) throw new Error("Expected a desktop layout"); + + const unnamedCategory = new CategorySectionMockBuilder({ id: "category", name: " " }).build(); + const namedDynamicSection = new DynamicSectionMockBuilder({ + id: "dynamic", + options: { title: "Services", borderColor: "", customCssClasses: [] }, + }) + .addLayout({ layoutId: desktopLayoutId, parentSectionId: unnamedCategory.id }) + .build(); + + board.sections.push(unnamedCategory, namedDynamicSection); + board.items.push( + new ItemMockBuilder({ id: "item" }) + .addLayout({ layoutId: desktopLayoutId, sectionId: namedDynamicSection.id }) + .build(), + ); + + expect(createMobileBoardElements(board, desktopLayoutId)[0]).toMatchObject({ + type: "sectionHeading", + title: "Services", + headingLevel: 2, + }); + }); }); diff --git a/apps/nextjs/src/components/board/mobile/mobile-layout.ts b/apps/nextjs/src/components/board/mobile/mobile-layout.ts index 1017856a4..f394647d7 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-layout.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-layout.ts @@ -19,6 +19,7 @@ export type MobileBoardSectionHeading = { sectionId: string; title: string; anchorId: string; + headingLevel: 2 | 3 | 4 | 5 | 6; }; export type MobileBoardElement = SectionItem | MobileBoardSectionHeading; @@ -51,7 +52,10 @@ const getLayout = ( (layoutA, layoutB) => (layoutPriority.get(layoutB.layoutId) ?? -1) - (layoutPriority.get(layoutA.layoutId) ?? -1), )[0]; -const createSectionHeading = (section: CategorySection | DynamicSectionItem): MobileBoardSectionHeading | null => { +const createSectionHeading = ( + section: CategorySection | DynamicSectionItem, + headingDepth: number, +): MobileBoardSectionHeading | null => { const title = (section.kind === "category" ? section.name : section.options.title).trim(); if (title.length === 0) return null; @@ -61,6 +65,7 @@ const createSectionHeading = (section: CategorySection | DynamicSectionItem): Mo sectionId: section.id, title, anchorId: getMobileSectionAnchorId(section.id), + headingLevel: Math.min(headingDepth + 2, 6) as MobileBoardSectionHeading["headingLevel"], }; }; @@ -112,16 +117,18 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) const createSectionElements = ( section: CategorySection | DynamicSectionItem | (typeof rootSections)[number], + headingDepth = 0, ): { elements: MobileBoardElement[]; items: SectionItem[] } => { if (visitedSections.has(section.id)) return { elements: [], items: [] }; const sectionId = section.id; visitedSections.add(sectionId); + const heading = section.kind === "empty" ? null : createSectionHeading(section, headingDepth); const sectionElements: MobileBoardElement[] = []; const sectionItems: SectionItem[] = []; for (const element of (elementsBySection.get(sectionId) ?? []).toSorted(comparePosition)) { if (element.type === "section") { - const nestedResult = createSectionElements(element); + const nestedResult = createSectionElements(element, heading ? headingDepth + 1 : headingDepth); sectionElements.push(...nestedResult.elements); sectionItems.push(...nestedResult.items); continue; @@ -136,7 +143,6 @@ export const createMobileBoardElements = (board: Board, desktopLayoutId: string) if (sectionItems.length === 0) return { elements: [], items: [] }; - const heading = section.kind === "empty" ? null : createSectionHeading(section); return { elements: heading ? [heading, ...sectionElements] : sectionElements, items: sectionItems, diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts index c2e6c1197..a145bc20b 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "vitest"; -import { resolveMobileItemPresentation } from "./mobile-presentation"; +import { + isMobileContextActionVisible, + resolveMobileItemPresentation, + shouldRenderMobileWidgetActions, +} from "./mobile-presentation"; describe("resolveMobileItemPresentation", () => { test("renders apps as eager compact tiles", () => { @@ -9,19 +13,51 @@ describe("resolveMobileItemPresentation", () => { height: 1, eager: true, supportsDetails: false, + unmountWhenOffscreen: false, }); }); - test("defaults widgets to full width and clamps desktop height", () => { + test("defaults widgets to full width without inventing a detail view", () => { expect(resolveMobileItemPresentation({ kind: "calendar", height: 8 }, undefined)).toMatchObject({ width: 2, height: 3, eager: false, + supportsDetails: false, + unmountWhenOffscreen: false, + }); + }); + + test("enables details only when the widget explicitly supports them", () => { + expect( + resolveMobileItemPresentation( + { kind: "calendar", height: 2 }, + { + width: 2, + height: 1, + supportsDetailView: true, + }, + ), + ).toMatchObject({ supportsDetails: true, }); }); - test("prefers explicit presentation metadata", () => { + test("keeps automatic footprint defaults when only details are configured", () => { + expect( + resolveMobileItemPresentation( + { kind: "calendar", height: 7 }, + { + supportsDetailView: true, + }, + ), + ).toMatchObject({ + width: 2, + height: 3, + supportsDetails: true, + }); + }); + + test("prefers explicit presentation metadata while eager mounting takes precedence", () => { expect( resolveMobileItemPresentation( { kind: "clock", height: 3 }, @@ -31,6 +67,7 @@ describe("resolveMobileItemPresentation", () => { supportsCompactSummary: true, supportsDetailView: false, eager: true, + unmountWhenOffscreen: true, }, ), ).toEqual({ @@ -39,6 +76,37 @@ describe("resolveMobileItemPresentation", () => { displayMode: "mobileSummary", supportsDetails: false, eager: true, + unmountWhenOffscreen: false, }); }); }); + +describe("shouldRenderMobileWidgetActions", () => { + test("does not render an action trigger without an explicit capability", () => { + expect( + shouldRenderMobileWidgetActions({ + supportsDetails: false, + supportsRefresh: false, + visibleContextActionCount: 0, + }), + ).toBe(false); + }); + + test.each([ + { supportsDetails: true, supportsRefresh: false, visibleContextActionCount: 0 }, + { supportsDetails: false, supportsRefresh: true, visibleContextActionCount: 0 }, + { supportsDetails: false, supportsRefresh: false, visibleContextActionCount: 1 }, + ])("renders an action trigger for an explicit capability", (capabilities) => { + expect(shouldRenderMobileWidgetActions(capabilities)).toBe(true); + }); +}); + +describe("isMobileContextActionVisible", () => { + test("requires actions to opt into the read-only mobile surface", () => { + const action = { key: "configure", label: "Configure", onClick: () => undefined }; + + expect(isMobileContextActionVisible(action)).toBe(false); + expect(isMobileContextActionVisible({ ...action, mobileVisible: true })).toBe(true); + expect(isMobileContextActionVisible({ ...action, mobileVisible: true, hidden: true })).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts index 62dbd65aa..41917e4a1 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-presentation.ts +++ b/apps/nextjs/src/components/board/mobile/mobile-presentation.ts @@ -1,5 +1,5 @@ import type { WidgetKind } from "@homarr/definitions"; -import type { WidgetMobilePresentation } from "@homarr/widgets"; +import type { WidgetContextMenuAction, WidgetMobilePresentation } from "@homarr/widgets"; export const resolveMobileItemPresentation = ( item: { kind: WidgetKind; height: number }, @@ -8,6 +8,20 @@ export const resolveMobileItemPresentation = ( width: mobile?.width ?? (item.kind === "app" ? 1 : 2), height: mobile?.height ?? (item.kind === "app" ? 1 : Math.max(1, Math.min(item.height, 3))), displayMode: mobile?.supportsCompactSummary ? ("mobileSummary" as const) : ("default" as const), - supportsDetails: mobile?.supportsDetailView ?? item.kind !== "app", + supportsDetails: mobile?.supportsDetailView === true, eager: mobile?.eager ?? (item.kind === "app" || item.kind === "bookmarks"), + unmountWhenOffscreen: mobile?.eager === true ? false : (mobile?.unmountWhenOffscreen ?? false), }); + +export const shouldRenderMobileWidgetActions = ({ + supportsDetails, + supportsRefresh, + visibleContextActionCount, +}: { + supportsDetails: boolean; + supportsRefresh: boolean; + visibleContextActionCount: number; +}) => supportsDetails || supportsRefresh || visibleContextActionCount > 0; + +export const isMobileContextActionVisible = (action: WidgetContextMenuAction) => + action.mobileVisible === true && !action.hidden; diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts new file mode 100644 index 000000000..180699fbf --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.spec.ts @@ -0,0 +1,28 @@ +import { afterEach, describe, expect, test, vi } from "vitest"; + +import { focusMobileBoardSection } from "./mobile-section-navigation"; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe("focusMobileBoardSection", () => { + test.each([ + [false, "smooth"], + [true, "auto"], + ] as const)("scrolls to and focuses the heading (reduced motion: %s)", (reduceMotion, behavior) => { + const heading = document.createElement("h2"); + heading.id = "section"; + heading.tabIndex = -1; + heading.scrollIntoView = vi.fn(); + document.body.append(heading); + + expect(focusMobileBoardSection({ anchorId: heading.id, reduceMotion })).toBe(true); + expect(heading.scrollIntoView).toHaveBeenCalledWith({ behavior, block: "start" }); + expect(document.activeElement).toBe(heading); + }); + + test("does nothing when the section no longer exists", () => { + expect(focusMobileBoardSection({ anchorId: "missing", reduceMotion: false })).toBe(false); + }); +}); diff --git a/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts new file mode 100644 index 000000000..ad426b5c5 --- /dev/null +++ b/apps/nextjs/src/components/board/mobile/mobile-section-navigation.ts @@ -0,0 +1,13 @@ +interface FocusMobileBoardSectionOptions { + anchorId: string; + reduceMotion: boolean; +} + +export const focusMobileBoardSection = ({ anchorId, reduceMotion }: FocusMobileBoardSectionOptions): boolean => { + const heading = document.getElementById(anchorId); + if (!heading) return false; + + heading.scrollIntoView({ behavior: reduceMotion ? "auto" : "smooth", block: "start" }); + heading.focus({ preventScroll: true }); + return true; +}; diff --git a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx index d05b1ebcf..60f50c234 100644 --- a/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx +++ b/apps/nextjs/src/components/board/mobile/mobile-widget-actions.tsx @@ -1,7 +1,7 @@ "use client"; import type { MutableRefObject } from "react"; -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { ActionIcon, Box, Button, Drawer, Loader, Modal, Stack } from "@mantine/core"; import { useDisclosure } from "@mantine/hooks"; import { IconDotsVertical, IconMaximize, IconRefresh } from "@tabler/icons-react"; @@ -18,8 +18,9 @@ import { reduceWidgetOptionsWithDefaultValues, widgetImports } from "@homarr/wid import type { SectionItem } from "~/app/[locale]/boards/_types"; import { BoardItemContent } from "../items/item-content"; -import { useItemActions } from "../items/item-actions"; +import { useHasNestedDialog } from "./mobile-dialog-stack"; import classes from "./mobile-board.module.css"; +import { isMobileContextActionVisible, shouldRenderMobileWidgetActions } from "./mobile-presentation"; interface MobileWidgetActionsProps { item: SectionItem; @@ -30,18 +31,16 @@ interface MobileWidgetActionsProps { export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: MobileWidgetActionsProps) => { const [actionsOpened, actionsDisclosure] = useDisclosure(false); const [detailsOpened, detailsDisclosure] = useDisclosure(false); + const actionTriggerRef = useRef(null); + const detailsDialogRef = useRef(null); + const openDetailsAfterActionsRef = useRef(false); + const hasNestedDialog = useHasNestedDialog(detailsOpened, detailsDialogRef); const t = useI18n(); const settings = useSettings(); const board = useRequiredBoard(); const [isEditMode] = useEditMode(); - const { updateItemOptions } = useItemActions(); const definition = widgetImports[item.kind].definition; - const queryClient = useQueryClient(); - const queryKey = useMemo( - () => ("queryKey" in definition ? (definition.queryKey as QueryKey) : [["widget", item.kind]]), - [definition, item.kind], - ); - const isFetching = useIsFetching({ queryKey }) > 0; + const queryKey = "queryKey" in definition ? (definition.queryKey as QueryKey | undefined) : undefined; const options = useMemo( () => reduceWidgetOptionsWithDefaultValues(item.kind, settings, item.options) as Record, [item.kind, item.options, settings], @@ -51,36 +50,32 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M return definition.contextActions({ options, - setOptions: (partial: Record) => - updateItemOptions({ - itemId: item.id, - newOptions: { ...options, ...partial }, - }), + setOptions: () => undefined, integrationIds: item.integrationIds, context: { isEditMode, boardId: board.id, itemId: item.id }, widgetStateRef, } as never) as WidgetContextMenuAction[]; - }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, updateItemOptions, widgetStateRef]); - const visibleContextActions = contextActions.filter((action) => !action.hidden); + }, [board.id, definition, isEditMode, item.id, item.integrationIds, options, widgetStateRef]); + const visibleContextActions = contextActions.filter(isMobileContextActionVisible); const title = item.advancedOptions.title?.trim() || t(`widget.${item.kind}.name`); - const hasActions = item.kind !== "app" || supportsDetails || visibleContextActions.length > 0; + const hasActions = shouldRenderMobileWidgetActions({ + supportsDetails, + supportsRefresh: queryKey !== undefined, + visibleContextActionCount: visibleContextActions.length, + }); if (!hasActions) return null; - const refresh = () => { - void queryClient.refetchQueries({ queryKey, type: "all" }); - actionsDisclosure.close(); - }; - return ( <> @@ -94,6 +89,21 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="auto" padding="md" overlayProps={{ backgroundOpacity: 0.45, blur: 2 }} + styles={{ + content: { + paddingLeft: "env(safe-area-inset-left)", + paddingRight: "env(safe-area-inset-right)", + }, + }} + onExitTransitionEnd={() => { + if (!openDetailsAfterActionsRef.current) return; + openDetailsAfterActionsRef.current = false; + detailsDisclosure.open(); + }} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} > {supportsDetails && ( @@ -103,8 +113,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M size="lg" leftSection={} onClick={() => { + openDetailsAfterActionsRef.current = true; actionsDisclosure.close(); - detailsDisclosure.open(); }} > {t("board.mobile.details")} @@ -130,37 +140,74 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M ); })} - + {queryKey && } actionTriggerRef.current?.focus()} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} styles={{ + content: { + display: "flex", + flexDirection: "column", + height: "100dvh", + paddingTop: "env(safe-area-inset-top)", + paddingLeft: "env(safe-area-inset-left)", + paddingRight: "env(safe-area-inset-right)", + }, body: { - height: "calc(100dvh - 4rem)", + flex: "1 1 auto", + minHeight: 0, overflowY: "auto", paddingBottom: "calc(var(--mantine-spacing-md) + env(safe-area-inset-bottom))", }, }} > - + ); }; + +const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => { + const t = useI18n(); + const queryClient = useQueryClient(); + const isFetching = useIsFetching({ queryKey }) > 0; + + return ( + + ); +}; diff --git a/e2e/mobile-board.spec.ts b/e2e/mobile-board.spec.ts new file mode 100644 index 000000000..ffe381f61 --- /dev/null +++ b/e2e/mobile-board.spec.ts @@ -0,0 +1,243 @@ +import { chromium, devices, expect } from "@playwright/test"; +import { createId } from "@paralleldrive/cuid2"; +import SuperJSON from "superjson"; +import { describe, test } from "vitest"; + +import { eq } from "@homarr/db"; +import * as sqliteSchema from "@homarr/db/schema/sqlite"; + +import { createHomarrContainer } from "./shared/create-homarr-container"; +import { createSqliteDbFileAsync } from "./shared/e2e-db"; +import type { SqliteDatabase } from "./shared/e2e-db"; +import { seedAdminUserAsync } from "./shared/seed-admin-user"; + +const adminCredentials = { + username: "admin", + password: "Comp(exP4sswOrd", +}; + +const boardName = "mobile-e2e"; + +const seedMobileBoardAsync = async (db: SqliteDatabase, userId: string) => { + const boardId = createId(); + const layoutId = createId(); + const firstSectionId = createId(); + const secondSectionId = createId(); + + await db.insert(sqliteSchema.boards).values({ + id: boardId, + name: boardName, + creatorId: userId, + }); + await db.insert(sqliteSchema.layouts).values({ + id: layoutId, + name: "Desktop", + boardId, + columnCount: 12, + breakpoint: 0, + }); + await db.insert(sqliteSchema.sections).values([ + { + id: firstSectionId, + boardId, + kind: "category", + name: "First section", + xOffset: 0, + yOffset: 0, + }, + { + id: secondSectionId, + boardId, + kind: "category", + name: "Second section", + xOffset: 0, + yOffset: 20, + }, + ]); + + const itemRows = Array.from({ length: 36 }, (_, index) => { + const id = createId(); + return { + id, + boardId, + kind: index === 0 ? ("notebook" as const) : ("clock" as const), + options: SuperJSON.stringify(index === 0 ? { content: "

Mobile notes

" } : {}), + advancedOptions: SuperJSON.stringify({}), + }; + }); + await db.insert(sqliteSchema.items).values(itemRows); + await db.insert(sqliteSchema.itemLayouts).values( + itemRows.map((item, index) => ({ + itemId: item.id, + layoutId, + sectionId: index < 18 ? firstSectionId : secondSectionId, + xOffset: (index % 6) * 2, + yOffset: Math.floor((index % 18) / 6) * 2, + width: 2, + height: 2, + })), + ); + await db + .update(sqliteSchema.users) + .set({ + homeBoardId: boardId, + mobileHomeBoardId: boardId, + completedBoardTour: true, + }) + .where(eq(sqliteSchema.users.id, userId)); +}; + +describe("Automatic mobile board", () => { + test("keeps phones and narrow tablets read-only, two-column, accessible, and overflow-free", async () => { + const { db, localMountPath } = await createSqliteDbFileAsync(); + const { userId } = await seedAdminUserAsync(db, adminCredentials); + await seedMobileBoardAsync(db, userId); + + const homarrContainer = await createHomarrContainer({ + environment: { + AUTH_PROVIDERS: "credentials", + }, + mounts: { + "/appdata": localMountPath, + }, + }).start(); + const baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`; + const browser = await chromium.launch(); + const loginContext = await browser.newContext(); + + try { + const loginPage = await loginContext.newPage(); + await loginPage.goto(`${baseUrl}/auth/login`); + await loginPage.getByLabel("Username").fill(adminCredentials.username); + await loginPage.locator("#password").fill(adminCredentials.password); + await loginPage.locator("button[type='submit']").click(); + await loginPage.waitForURL((url) => !url.pathname.includes("/auth/login"), { timeout: 15_000 }); + const storageState = await loginContext.storageState(); + + const mobileViewports = [ + { name: "small phone", width: 320, height: 568, userAgent: devices["iPhone 13"].userAgent, phone: true }, + { name: "phone", width: 390, height: 844, userAgent: devices["iPhone 13"].userAgent, phone: true }, + { name: "large phone", width: 430, height: 932, userAgent: devices["iPhone 13"].userAgent, phone: true }, + { + name: "phone landscape", + width: 844, + height: 390, + userAgent: devices["iPhone 13"].userAgent, + phone: true, + }, + { + name: "narrow tablet", + width: 768, + height: 1024, + userAgent: devices["iPad (gen 7)"].userAgent, + phone: false, + }, + ]; + + for (const viewport of mobileViewports) { + const context = await browser.newContext({ + storageState, + viewport: { width: viewport.width, height: viewport.height }, + userAgent: viewport.userAgent, + hasTouch: true, + isMobile: viewport.phone, + reducedMotion: viewport.width === 390 ? "reduce" : "no-preference", + }); + const page = await context.newPage(); + await page.goto(`${baseUrl}/boards/${boardName}`); + + await expect(page.locator("[data-mobile-board]"), viewport.name).toBeVisible({ timeout: 15_000 }); + await expect(page.locator("[data-mobile-board-item]"), viewport.name).toHaveCount(36); + await expect(page.locator(".grid-stack-item"), viewport.name).toHaveCount(0); + await expect(page.getByRole("button", { name: /search/i }), viewport.name).toBeVisible(); + + const layoutMetrics = await page.evaluate(() => { + const grids = Array.from(document.querySelectorAll("[data-mobile-board] > div")); + const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid"); + const items = Array.from(document.querySelectorAll("[data-mobile-board-item]")); + const focusable = Array.from( + document.querySelectorAll( + '[data-mobile-board] a[href], [data-mobile-board] button, [data-mobile-board] input, [data-mobile-board] select, [data-mobile-board] textarea, [data-mobile-board] [tabindex]:not([tabindex="-1"])', + ), + ); + + return { + columns: twoColumnGrids.map( + (grid) => getComputedStyle(grid).gridTemplateColumns.split(" ").filter(Boolean).length, + ), + documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth, + overflowingItems: items.filter((item) => item.scrollWidth > item.clientWidth + 1).length, + outOfBoundsControls: focusable.filter((element) => { + const rect = element.getBoundingClientRect(); + return rect.left < -1 || rect.right > window.innerWidth + 1; + }).length, + layoutControls: document.querySelectorAll( + '[data-testid*="layout"], button[aria-label*="resize" i], button[aria-label*="move" i]', + ).length, + }; + }); + + expect( + layoutMetrics.columns.length > 0 && layoutMetrics.columns.every((count) => count === 2), + viewport.name, + ).toBe(true); + expect(layoutMetrics.documentOverflow, viewport.name).toBeLessThanOrEqual(0); + expect(layoutMetrics.overflowingItems, viewport.name).toBe(0); + expect(layoutMetrics.outOfBoundsControls, viewport.name).toBe(0); + expect(layoutMetrics.layoutControls, viewport.name).toBe(0); + + if (viewport.width === 320) { + await page.evaluate(() => { + document.documentElement.style.fontSize = "200%"; + }); + const zoomOverflow = await page.evaluate(() => ({ + document: document.documentElement.scrollWidth - document.documentElement.clientWidth, + items: Array.from(document.querySelectorAll("[data-mobile-board-item]")).filter( + (item) => item.scrollWidth > item.clientWidth + 1, + ).length, + })); + expect(zoomOverflow.document).toBeLessThanOrEqual(0); + expect(zoomOverflow.items).toBe(0); + } + + if (viewport.width === 390) { + await page.getByRole("button", { name: "More" }).click(); + const moreDrawer = page.getByRole("dialog", { name: "More" }); + await expect(moreDrawer.getByRole("button", { name: "Second section" })).toBeVisible(); + await moreDrawer.getByRole("button", { name: "Second section" }).click(); + await expect(page.getByRole("heading", { name: "Second section" })).toBeFocused(); + + const notebookActions = page.getByRole("button", { name: /Actions for Notebook/i }); + await notebookActions.click(); + await page.getByRole("button", { name: "Open widget details" }).click(); + const details = page.getByRole("dialog", { name: "Notebook" }); + await expect(details).toBeVisible(); + await details.getByRole("button", { name: "Close" }).click(); + await expect(notebookActions).toBeFocused(); + + const rtlOverflow = await page.evaluate(() => { + document.documentElement.dir = "rtl"; + return document.documentElement.scrollWidth - document.documentElement.clientWidth; + }); + expect(rtlOverflow).toBeLessThanOrEqual(0); + } + + await context.close(); + } + + const desktopContext = await browser.newContext({ + storageState, + viewport: { width: 800, height: 900 }, + }); + const desktopPage = await desktopContext.newPage(); + await desktopPage.goto(`${baseUrl}/boards/${boardName}`); + await expect(desktopPage.locator("[data-mobile-board]")).toHaveCount(0); + await expect(desktopPage.locator(".grid-stack-item")).toHaveCount(36); + await desktopContext.close(); + } finally { + await loginContext.close(); + await browser.close(); + await homarrContainer.stop(); + } + }, 180_000); +}); diff --git a/packages/api/src/router/board.ts b/packages/api/src/router/board.ts index 9795dc937..05bc30ae7 100644 --- a/packages/api/src/router/board.ts +++ b/packages/api/src/router/board.ts @@ -1913,6 +1913,9 @@ const getHomeIdBoardAsync = async ( user: InferSelectModel | null, deviceType: DeviceType, ) => { + // Mobile home-board selection remains device-based because the server does + // not know the resolved CSS viewport. Tablet rendering is resolved against + // 48em on the client and must not be guessed here. const settingKey = deviceType === "mobile" ? "mobileHomeBoardId" : "homeBoardId"; if (!user) { @@ -1934,8 +1937,7 @@ const getHomeIdBoardAsync = async ( and( eq(groupMembers.userId, user.id), not(eq(groups.name, everyoneGroup)), - not(isNull(groups[settingKey])), - not(isNull(groups.homeBoardId)), + or(not(isNull(groups[settingKey])), not(isNull(groups.homeBoardId))), ), ) .orderBy(asc(groups.position)) diff --git a/packages/api/src/router/test/board.spec.ts b/packages/api/src/router/test/board.spec.ts index 625325dcf..23aca55d0 100644 --- a/packages/api/src/router/test/board.spec.ts +++ b/packages/api/src/router/test/board.spec.ts @@ -549,6 +549,64 @@ describe("getHomeBoard should return home board", () => { }); expect(spy).toHaveBeenCalledWith(expect.anything(), expect.anything(), "view"); }); + test.each([ + { deviceType: "mobile", expectedBoard: "mobile-home" }, + { deviceType: "tablet", expectedBoard: "desktop-home" }, + ] as const)( + "should use the $expectedBoard preference for a $deviceType device", + async ({ deviceType, expectedBoard }) => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType, session: defaultSession }); + + const mobileBoardProps = await createFullBoardAsync(db, "mobile-home"); + const desktopBoardProps = await createFullBoardAsync(db, "desktop-home"); + await db + .update(users) + .set({ + homeBoardId: desktopBoardProps.boardId, + mobileHomeBoardId: mobileBoardProps.boardId, + }) + .where(eq(users.id, defaultCreatorId)); + + // Act + const result = await caller.getHomeBoard(); + + // Assert + expectInputToBeFullBoardWithName(result, { + ...(expectedBoard === "mobile-home" ? mobileBoardProps : desktopBoardProps), + name: expectedBoard, + }); + }, + ); + test("should return a mobile-only group home board", async () => { + // Arrange + const db = createDb(); + const caller = boardRouter.createCaller({ db, deviceType: "mobile", session: defaultSession }); + + const mobileBoardProps = await createFullBoardAsync(db, "group-mobile-home"); + const groupId = createId(); + await db.insert(groups).values({ + id: groupId, + name: "mobile group", + position: 1, + homeBoardId: null, + mobileHomeBoardId: mobileBoardProps.boardId, + }); + await db.insert(groupMembers).values({ + userId: defaultCreatorId, + groupId, + }); + + // Act + const result = await caller.getHomeBoard(); + + // Assert + expectInputToBeFullBoardWithName(result, { + name: "group-mobile-home", + ...mobileBoardProps, + }); + }); test("should return global home board when user doesn't have one", async () => { // Arrange const spy = vi.spyOn(boardAccess, "throwIfActionForbiddenAsync"); @@ -2019,9 +2077,12 @@ const expectInputToBeFullBoardWithName = ( }; const createFullBoardAsync = async (db: Database, name: string) => { - await db.insert(users).values({ - id: defaultCreatorId, - }); + await db + .insert(users) + .values({ + id: defaultCreatorId, + }) + .onConflictDoNothing(); const boardId = createId(); await db.insert(boards).values({ diff --git a/packages/api/src/router/widgets/downloads.ts b/packages/api/src/router/widgets/downloads.ts index 261d3ba46..bdce2473f 100644 --- a/packages/api/src/router/widgets/downloads.ts +++ b/packages/api/src/router/widgets/downloads.ts @@ -1,47 +1,17 @@ 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 { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; +import { 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({ @@ -54,29 +24,14 @@ export const downloadsRouter = createTRPCRouter({ .concat(createDownloadClientIntegrationMiddleware("query")) .input(z.object({ limitPerIntegration: z.number().default(50) })) .query(async ({ ctx, input }) => { - 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, - }, - ); + 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, + }; + }); }), pause: protectedProcedure .meta({ diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts index 5c123ecb1..1957f459c 100644 --- a/packages/api/src/router/widgets/health-monitoring.ts +++ b/packages/api/src/router/widgets/health-monitoring.ts @@ -1,10 +1,9 @@ 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 { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations"; +import { settleIntegrationQueries } from "../../settle-integrations"; import { createTRPCRouter, publicProcedure } from "../../trpc"; const healthMonitoringIntegrationKinds = [ @@ -17,14 +16,6 @@ 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({ @@ -36,27 +27,15 @@ export const healthMonitoringRouter = createTRPCRouter({ }) .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds)) .query(async ({ ctx }) => { - 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, - }, - ); + 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, + }; + }); }), listStorageVolumes: publicProcedure .meta({ diff --git a/packages/api/src/router/widgets/media-release.ts b/packages/api/src/router/widgets/media-release.ts index e1acb95b6..f05a46142 100644 --- a/packages/api/src/router/widgets/media-release.ts +++ b/packages/api/src/router/widgets/media-release.ts @@ -2,21 +2,32 @@ 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 }) => { - return await getMediaReleaseResponse(ctx.integrations, async (integration) => { + const results = await settleIntegrationQueries(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 e3082c284..6df14090c 100644 --- a/packages/api/src/router/widgets/uptime-kuma.ts +++ b/packages/api/src/router/widgets/uptime-kuma.ts @@ -1,39 +1,24 @@ import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma"; import { createManyIntegrationMiddleware } from "../../middlewares/integration"; -import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations"; +import { settleIntegrationQueries } 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 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, - }, - ); + return { + integrationId: integration.id, + integrationName: integration.name, + integrationUrl: integration.url, + dashboard: data, + updatedAt: timestamp, + }; + }); }), }); diff --git a/packages/api/src/settle-integrations.spec.ts b/packages/api/src/settle-integrations.spec.ts new file mode 100644 index 000000000..fe3b460d5 --- /dev/null +++ b/packages/api/src/settle-integrations.spec.ts @@ -0,0 +1,43 @@ +// @vitest-environment node + +import { describe, expect, it, vi } from "vitest"; + +import { settleIntegrationQueries } from "./settle-integrations"; + +const integrations = [ + { id: "first", name: "First", kind: "mock" }, + { id: "second", name: "Second", kind: "mock" }, +]; + +describe("settleIntegrationQueries", () => { + it("retains successful integration results when another integration fails", async () => { + const results = await settleIntegrationQueries( + integrations, + vi.fn(async (integration) => { + if (integration.id === "first") throw new Error("offline"); + return integration.name; + }), + { throwOnAllFailure: true }, + ); + + expect(results).toEqual(["Second"]); + }); + + it("throws when every integration fails and throwOnAllFailure is enabled", async () => { + await expect( + settleIntegrationQueries( + integrations, + vi.fn(async () => { + throw new Error("offline"); + }), + { throwOnAllFailure: true }, + ), + ).rejects.toThrow("offline"); + }); + + it("does not treat an empty integration list as a failure", async () => { + await expect( + settleIntegrationQueries([], vi.fn(async () => "unused"), { throwOnAllFailure: true }), + ).resolves.toEqual([]); + }); +}); diff --git a/packages/custom-widgets/src/jsx/interactive-components.tsx b/packages/custom-widgets/src/jsx/interactive-components.tsx index 2b18342a3..1fe2d2cff 100644 --- a/packages/custom-widgets/src/jsx/interactive-components.tsx +++ b/packages/custom-widgets/src/jsx/interactive-components.tsx @@ -2,15 +2,19 @@ import type { ReactNode } from "react"; import { Children, isValidElement, useEffect, useRef, useState } from "react"; -import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton } from "@mantine/core"; +import { ActionIcon, Badge, Collapse, Group, Stack, Tabs, Text, UnstyledButton, useDirection } from "@mantine/core"; import { IconChevronDown, IconChevronLeft, IconChevronRight, IconChevronUp } from "@tabler/icons-react"; +import { useScopedI18n } from "@homarr/translation/client"; + interface PaginatedListProps { children: ReactNode; pageSize?: number; } export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { + const tCommon = useScopedI18n("common"); + const { dir } = useDirection(); const items = Children.toArray(children); const totalPages = Math.max(1, Math.ceil(items.length / pageSize)); const [page, setPage] = useState(0); @@ -26,6 +30,8 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { const clampedPage = Math.min(page, totalPages - 1); const start = clampedPage * pageSize; const visible = items.slice(start, start + pageSize); + const PreviousIcon = dir === "rtl" ? IconChevronRight : IconChevronLeft; + const NextIcon = dir === "rtl" ? IconChevronLeft : IconChevronRight; return ( @@ -34,22 +40,24 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) { setPage((p) => Math.max(0, p - 1))} > - + {clampedPage + 1} / {totalPages} = totalPages - 1} + aria-label={tCommon("action.next")} onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))} > - + )} diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 27abdf835..a4cd8c729 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -2338,12 +2338,6 @@ "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", @@ -2745,7 +2739,6 @@ }, "advanced": { "entityId": "Entity ID: {id}", - "lastChanged": "Changed {date}", "lastUpdated": "Updated {date}", "attribute": { "unit": "Unit", @@ -2774,9 +2767,7 @@ }, "advanced": { "lastExecuted": "Last run at {time}", - "automationId": "Automation ID: {id}", - "ready": "Ready to run", - "noPermission": "No permission to run this automation" + "automationId": "Automation ID: {id}" }, "status": { "running": "Running automation", @@ -2789,14 +2780,6 @@ "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" @@ -2985,11 +2968,6 @@ }, "title": "Indexer manager", "testAll": "Test all", - "status": { - "healthy": "Healthy", - "unhealthy": "Unhealthy", - "disabled": "Disabled" - }, "error": { "internalServerError": "Failed to fetch indexers status" } @@ -3047,10 +3025,7 @@ "minutes": "{count} minutes", "used": "Used", "available": "Available", - "lastSeen": "Last status update: {lastSeen}", - "updatesAvailable": "{count, plural, one {# update available} other {# updates available}}", - "rebootRequired": "Reboot required", - "rebootNotRequired": "No reboot required" + "lastSeen": "Last status update: {lastSeen}" }, "memory": {}, "error": { @@ -3169,13 +3144,7 @@ }, "server": { "buildServer": "Build", - "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}", - "reachability": "Reachability", - "usability": "Usability" - }, - "source": { - "url": "Source: {url}", - "unavailable": "{source}: unavailable" + "resourceCounts": "{applications, plural, one {# app} other {# apps}} / {services, plural, one {# service} other {# services}}" }, "instances": { "title": "Instances" @@ -3194,11 +3163,7 @@ "restarting": "Restarting", "unknown": "Unknown", "online": "Online", - "offline": "Offline", - "reachable": "Reachable", - "unreachable": "Unreachable", - "usable": "Usable", - "unusable": "Unusable" + "offline": "Offline" } }, "vpn": { @@ -3734,9 +3699,6 @@ "movies": "Missing Movies", "providers": "Provider Issues", "status": "Health Issues", - "sonarrSignalr": "Sonarr SignalR", - "radarrSignalr": "Radarr SignalR", - "announcements": "Announcements", "option": { "showMissingEpisodes": { "label": "Show missing episodes", @@ -4335,16 +4297,6 @@ }, "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}}", @@ -4363,9 +4315,6 @@ "description": "Displays the summary of a Network Controller (like UniFi Controller)" }, "networkControllerStatus": { - "advanced": { - "updatedAt": "Updated {date}" - }, "card": { "variants": { "wired": { @@ -4497,8 +4446,7 @@ "range": { "twelveHours": "12h", "oneDay": "24h", - "all": "All", - "lastThirty": "Last 30" + "all": "All" } }, "uptimeKuma": { @@ -4713,9 +4661,7 @@ } }, "status": { - "unhealthy": "Unhealthy", - "smart": "SMART", - "free": "{percentage}% free" + "unhealthy": "Unhealthy" } }, "customApi": { @@ -4836,12 +4782,6 @@ }, "column": { "system": "System", - "source": "Source", - "hostname": "Hostname", - "os": "OS", - "cpuModel": "CPU model", - "cores": "Cores", - "memoryTotal": "Memory total", "cpu": "CPU", "memory": "Memory", "disk": "Disk", @@ -4902,14 +4842,6 @@ "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", @@ -5061,18 +4993,6 @@ } }, "board": { - "mobile": { - "actions": "Widget actions", - "details": "Open widget details", - "empty": { - "title": "This board is empty", - "description": "Add and arrange items from a desktop or larger screen. Mobile boards use the desktop order automatically." - }, - "more": "More", - "profile": "Profile and account", - "sections": "Jump to section", - "currentBoard": "Current board" - }, "action": { "addContent": "Add board content", "settings": "Board settings", @@ -5312,12 +5232,46 @@ }, "layout": { "title": "Layout", - "desktop": { - "title": "Desktop layout", - "description": "Configure the layout used on desktop and larger screens." + "responsive": { + "title": "Responsive layouts", + "description": "Each layout keeps its own widget positions and sizes. Mobile and Base are always available; add custom layouts for widths in between.", + "action": { + "add": "Add layout" + } + }, + "role": { + "mobile": "Mobile", + "base": "Base", + "custom": "Custom" + }, + "custom": { + "defaultName": "Custom layout" }, "mobile": { - "description": "On mobile, items are automatically arranged in two columns and cannot be moved or resized. Named categories and dynamic sections appear as headings, never as layout containers." + "breakpointDescription": "Mobile is always used from 0px until the next layout breakpoint." + }, + "preview": { + "columns": "columns", + "empty": "This layout is empty", + "dynamicSection": "Dynamic section", + "editorWidthLabel": "{layoutName} · {width}px" + }, + "reset": { + "action": "Reset from Base", + "confirm": { + "title": "Reset {layoutName}?", + "message": "This replaces every saved position and size in this layout with a new layout generated from Base. This cannot be undone." + } + }, + "edit": { + "action": "Edit layout", + "saveAndEdit": "Save and edit", + "save": "Save layout", + "cancel": { + "action": "Cancel layout editing", + "title": "Discard layout changes?", + "message": "Your unsaved changes to this layout will be discarded." + } } }, "background": { diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 193650b5c..acda12e12 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -124,10 +124,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget /> - {options.pingEnabled && - !settings.forceDisableStatus && - !board.disableStatus && - Boolean(app.pingUrl ?? app.href) ? ( + {options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? ( }> diff --git a/packages/widgets/src/bazarr/index.ts b/packages/widgets/src/bazarr/index.ts index ff5bb0d85..7bd79bf76 100644 --- a/packages/widgets/src/bazarr/index.ts +++ b/packages/widgets/src/bazarr/index.ts @@ -6,6 +6,7 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("bazarr", { supportsAdvancedFocus: true, icon: IconBadgeCc, + queryKey: [["widget", "bazarr"]], supportedIntegrations: ["bazarr"], integrationsRequired: true, maxIntegrations: 1, diff --git a/packages/widgets/src/beszel-alerts/component.tsx b/packages/widgets/src/beszel-alerts/component.tsx index 8990767f3..23243f35e 100644 --- a/packages/widgets/src/beszel-alerts/component.tsx +++ b/packages/widgets/src/beszel-alerts/component.tsx @@ -16,7 +16,6 @@ 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"; @@ -28,10 +27,6 @@ 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, @@ -55,64 +50,43 @@ export default function BeszelAlertsWidget({ integrationIds, isEditMode, height, - displayMode, - widgetRuntimeRef, }: WidgetComponentProps<"beszelAlerts">) { const t = useScopedI18n("widget.beszelAlerts"); - const isAdvanced = displayMode === "advanced"; const alertsInput = useMemo( - () => - getBeszelAlertsQueryInput( - integrationIds, - { showHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }, - isAdvanced, - ), - [integrationIds, options.showHistory, options.maxHistoryItems, isAdvanced], + () => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }), + [integrationIds, options.showHistory, options.maxHistoryItems], ); const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput); - const runtimeQueries = useMemo( - () => [getQueryKey(clientApi.widget.beszel.getAlerts, alertsInput, "query")], - [alertsInput], - ); - useWidgetRuntimeQueries(widgetRuntimeRef, runtimeQueries); - const resultData = getUsableWidgetQueryData(alertsQuery); - const results = useMemo(() => resultData ?? [], [resultData]); + const results = getUsableWidgetQueryData(alertsQuery) ?? []; const { isPending } = alertsQuery; - const systemNameMap = useMemo(() => buildBeszelSystemNameMap(results), [results]); - const showIntegrationName = isAdvanced || results.length > 1; + const systemNameMap = useMemo(() => { + const map: Record = {}; + for (const r of results) { + if (r.systemNameMap) { + Object.assign(map, r.systemNameMap); + } + } + return map; + }, [results]); const alerts = useMemo( - () => - results.flatMap((r) => - r.alerts.map((a) => ({ - ...a, - integrationId: r.integrationId, - integrationName: r.integrationName, - key: `${r.integrationId}:${a.id}`, - })), - ), + () => results.flatMap((r) => r.alerts.map((a) => ({ ...a, _key: `${r.integrationId}:${a.id}` }))), [results], ); const history = useMemo(() => { - 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 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 triggeredAlerts = alerts.filter((a) => a.triggered); const okAlerts = alerts.filter((a) => !a.triggered); - const showOkAlerts = isAdvanced || triggeredAlerts.length === 0 || height >= 260; - const showHistory = isAdvanced || (options.showHistory && height >= 360); - const showAlertDescriptions = isAdvanced || height >= 190; + const showOkAlerts = triggeredAlerts.length === 0 || height >= 260; + const showHistory = options.showHistory && height >= 360; + const showAlertDescriptions = height >= 190; if (isPending) { return ( @@ -125,10 +99,7 @@ export default function BeszelAlertsWidget({ return ( - - - - + @@ -153,12 +124,11 @@ export default function BeszelAlertsWidget({ {triggeredAlerts.map((alert) => ( @@ -178,12 +148,11 @@ export default function BeszelAlertsWidget({ {okAlerts.map((alert) => ( @@ -202,12 +171,11 @@ export default function BeszelAlertsWidget({ {history.map((entry) => { - const systemName = getBeszelSystemName(systemNameMap, entry.integrationId, entry.system); - const systemLabel = showIntegrationName ? `${systemName} · ${entry.integrationName}` : systemName; + const systemName = systemNameMap[entry.system] ?? entry.system; const isResolved = !!entry.resolved; const HistoryIcon = alertIconMap[entry.name] ?? Server; return ( - + - {systemLabel} + {systemName} @@ -250,7 +218,6 @@ interface AlertRowProps { value: number; min: number; systemName: string; - integrationName?: string; triggered: boolean; showDescription: boolean; } @@ -276,7 +243,7 @@ function formatAlertDescription(name: string, value: number, min: number): strin return `exceeds ${value}${suffix} over ${min} min`; } -function AlertRow({ name, value, min, systemName, integrationName, triggered, showDescription }: AlertRowProps) { +function AlertRow({ name, value, min, systemName, triggered, showDescription }: AlertRowProps) { const Icon = alertIconMap[name] ?? Server; const description = formatAlertDescription(name, value, min); return ( @@ -296,7 +263,7 @@ function AlertRow({ name, value, min, systemName, integrationName, triggered, sh - {integrationName ? `${systemName} · ${integrationName}` : systemName} + {systemName} · diff --git a/packages/widgets/src/beszel-alerts/index.ts b/packages/widgets/src/beszel-alerts/index.ts index e8a32796d..db1e791b2 100644 --- a/packages/widgets/src/beszel-alerts/index.ts +++ b/packages/widgets/src/beszel-alerts/index.ts @@ -1,22 +1,19 @@ import { IconBell, IconServerOff } from "@tabler/icons-react"; import { z } from "zod/v4"; -import { createWidgetDefinition, matchesWidgetRuntimeQuery, widgetQueryInputMatches } from "../definition"; +import { createWidgetDefinition, widgetQueryInputMatches } from "../definition"; import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("beszelAlerts", { icon: IconBell, + supportsAdvancedFocus: false, queryKey: [["widget", "beszel", "getAlerts"]], - queryMatcher(query, scope) { - const hasRuntimeAlertsQuery = scope.runtimeQueries.some(({ path }) => path.at(-1) === "getAlerts"); - if (hasRuntimeAlertsQuery) return matchesWidgetRuntimeQuery(query, scope); - - return widgetQueryInputMatches(query.input, { + queryMatcher: ({ input }, scope) => + widgetQueryInputMatches(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 84f796fe2..66f32dc35 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, SimpleGrid, Stack, Text } from "@mantine/core"; +import { Badge, Box, Card, Center, Group, Loader, Progress, Text, Stack } from "@mantine/core"; import { Activity, Battery, @@ -19,7 +19,6 @@ 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"; @@ -39,10 +38,8 @@ 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; @@ -120,12 +117,10 @@ 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, minCellWidth = MIN_CELL_WIDTH): number => { +const getColCount = (width: number, _height: number, itemCount: number): number => { if (itemCount <= 1) return 1; - const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / minCellWidth))); + const maxCols = Math.min(itemCount, Math.max(1, Math.floor(width / MIN_CELL_WIDTH))); let best = 1; for (let c = 1; c <= maxCols; c++) { @@ -173,14 +168,12 @@ const MetricRow = ({ icon, label, value, progress, size }: MetricRowProps) => ( ); interface SystemCardProps { - system: BeszelSystemRow & { rowKey: string }; + system: BeszelSystemRow & { _key: string }; options: WidgetComponentProps<"beszelSystemGrid">["options"]; t: ReturnType>; size: SizeConfig; maxMetrics: number; itemRadius: MantineSize; - integrationName: string; - isAdvanced: boolean; onClick?: () => void; } @@ -197,8 +190,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showCpu, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showCpu, }, { key: "showMemory", @@ -212,8 +204,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showMemory, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showMemory, }, { key: "showDisk", @@ -226,8 +217,7 @@ const metricRenderers = [ ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showDisk, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showDisk, }, { key: "showGpu", @@ -241,8 +231,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showGpu, advanced, s.gpu > 0), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showGpu && s.gpu > 0, }, { key: "showLoadAvg", @@ -255,8 +244,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showLoadAvg, advanced, s.loadAvg !== null), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showLoadAvg && s.loadAvg !== null, }, { key: "showNet", @@ -269,8 +257,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showNet, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showNet, }, { key: "showTemp", @@ -283,8 +270,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showTemp, advanced, s.temp !== null), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showTemp && s.temp !== null, }, { key: "showBattery", @@ -301,8 +287,7 @@ const metricRenderers = [ /> ); }, - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showBattery, advanced, s.battery !== null), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showBattery && s.battery !== null, }, { key: "showServices", @@ -315,8 +300,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showServices, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showServices, }, { key: "showUptime", @@ -329,8 +313,7 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showUptime, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showUptime, }, { key: "showAgent", @@ -343,23 +326,12 @@ const metricRenderers = [ size={sz} /> ), - visible: (s: BeszelSystemRow, o: SystemCardProps["options"], advanced: boolean) => - isBeszelGridMetricVisible(o.showAgent, advanced), + visible: (s: BeszelSystemRow, o: SystemCardProps["options"]) => o.showAgent, }, ] as const; -const SystemCard = ({ - system, - options, - t, - size, - maxMetrics, - itemRadius, - integrationName, - isAdvanced, - onClick, -}: SystemCardProps) => { - const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options, isAdvanced)); +const SystemCard = ({ system, options, t, size, maxMetrics, itemRadius, onClick }: SystemCardProps) => { + const enabledMetrics = metricRenderers.filter((metric) => metric.visible(system, options)); const visibleMetrics = enabledMetrics.slice(0, maxMetrics); const hiddenMetricCount = enabledMetrics.length - visibleMetrics.length; @@ -396,20 +368,6 @@ const SystemCard = ({ - {isAdvanced && ( - - - - - - 0 ? formatBytes(system.memoryTotal) : "—"} - /> - - - )} - {visibleMetrics.map((m) => m.render(system, t, size))} @@ -422,24 +380,12 @@ const SystemCard = ({ ); }; -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(); @@ -448,8 +394,6 @@ 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); @@ -464,10 +408,7 @@ export default function BeszelSystemGridWidget({ if (filteredSystems.length === 0) { return ( - - - - +
@@ -480,23 +421,19 @@ export default function BeszelSystemGridWidget({ ); } - const minimumCellWidth = isAdvanced ? ADVANCED_MIN_CELL_WIDTH : MIN_CELL_WIDTH; - const cols = getColCount(width, height, filteredSystems.length, minimumCellWidth); + const cols = getColCount(width, height, filteredSystems.length); const rows = Math.ceil(filteredSystems.length / cols) || 1; const rawCellHeight = height / rows; - const minimumCellHeight = isAdvanced ? ADVANCED_MIN_CELL_HEIGHT : MIN_CELL_HEIGHT; + const minimumCellHeight = MIN_CELL_HEIGHT; const scrollEnabled = rawCellHeight < minimumCellHeight; const effectiveCellHeight = scrollEnabled ? minimumCellHeight : rawCellHeight; const cellWidth = width / cols; const size = getSizeConfig(cellWidth, effectiveCellHeight); - const maxMetrics = isAdvanced ? Number.POSITIVE_INFINITY : getMaxVisibleMetrics(effectiveCellHeight, size); + const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size); return ( - - - - + {filteredSystems.map((system) => { - const integrationId = system.rowKey.split(":")[0] ?? ""; - const integrationName = integrationNames.get(integrationId) ?? "—"; + const integrationId = system._key.split(":")[0] ?? ""; 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 9a5444e28..b3ef14806 100644 --- a/packages/widgets/src/beszel-system-grid/index.ts +++ b/packages/widgets/src/beszel-system-grid/index.ts @@ -6,6 +6,7 @@ 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 4f2e96d56..eef65b348 100644 --- a/packages/widgets/src/beszel-system-stats/component.tsx +++ b/packages/widgets/src/beszel-system-stats/component.tsx @@ -3,6 +3,7 @@ import { useCallback, useMemo, useRef } from "react"; import { Box, Button, Center, Group, Loader, Menu, ScrollArea, Select, Stack, Text } from "@mantine/core"; import { IconQuestionMark, IconServer, IconServerOff } from "@tabler/icons-react"; +import { getQueryKey } from "@trpc/react-query"; import { clientApi } from "@homarr/api/client"; import { useSession } from "@homarr/auth/client"; @@ -18,7 +19,6 @@ 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,8 +46,7 @@ export default function BeszelSystemStatsWidget({ }), }); const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); - const systemsData = getUsableWidgetQueryData(systemsQuery); - const systemsResult = useMemo(() => systemsData ?? [], [systemsData]); + const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending: systemsPending } = systemsQuery; const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]); @@ -68,15 +67,16 @@ export default function BeszelSystemStatsWidget({ widgetRuntimeRef, selectedSystem && options.timePeriod !== "1m" ? [ - { - path: ["widget", "beszel", "getSystemStats"], - input: { + getQueryKey( + clientApi.widget.beszel.getSystemStats, + { integrationIds: [selectedSystem.integrationId], systemId: selectedSystem.systemId, - timePeriod: options.timePeriod, + timePeriod: options.timePeriod as BeszelTimePeriod, includeDocker, }, - }, + "query", + ), ] : [], ); @@ -140,10 +140,7 @@ export default function BeszelSystemStatsWidget({ if (systems.length === 0) { return ( - - - - +
@@ -180,10 +177,7 @@ export default function BeszelSystemStatsWidget({ return ( - - - - + = { asc: 1, desc: -1 }; -type SystemRowWithKey = BeszelSystemRow & { rowKey: string; integrationName: string }; +type SystemRowWithKey = BeszelSystemRow & { _key: string }; const columnAccessors = [ "name", - "integrationName", - "hostname", - "osName", - "cpuModel", - "cores", - "memoryTotal", "cpu", "memory", "disk", @@ -69,7 +60,7 @@ const columnAccessors = [ "services", "uptime", "agentVersion", -] as const satisfies readonly (keyof SystemRowWithKey)[]; +] as const satisfies readonly (keyof BeszelSystemRow)[]; interface SizeConfig { iconSize: number; @@ -106,7 +97,6 @@ export default function BeszelSystemTableWidget({ boardId, itemId, setOptions, - displayMode, }: WidgetComponentProps<"beszelSystemTable">) { const t = useScopedI18n("widget.beszelSystemTable"); const { openModal } = useModalAction(BeszelSystemStatsModal); @@ -114,15 +104,26 @@ 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 systemsData = getUsableWidgetQueryData(systemsQuery); - const results = useMemo(() => systemsData ?? [], [systemsData]); + const results = getUsableWidgetQueryData(systemsQuery) ?? []; const { isPending } = systemsQuery; - const isAdvanced = displayMode === "advanced"; const size = useMemo(() => getSizeConfig(width), [width]); - const visibleMetricKeys = useMemo( - () => getBeszelTableVisibleMetricKeys(options, width, isAdvanced), - [options, width, isAdvanced], - ); + 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 { mutate: saveItemOptions } = clientApi.widget.options.saveItemOptions.useMutation({ onError: () => @@ -140,18 +141,6 @@ 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, @@ -165,16 +154,16 @@ export default function BeszelSystemTableWidget({ }, [options.sortBy, options.sortDirection]); const sortedSystems = useMemo(() => { - const accessor = sortStatus.columnAccessor as keyof SystemRowWithKey; + const accessor = sortStatus.columnAccessor as keyof BeszelSystemRow; const dir = directionMultiplier[sortStatus.direction] ?? 1; - return [...systemsWithSource].toSorted((a, b) => { + return [...filteredSystems].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; }); - }, [systemsWithSource, sortStatus]); + }, [filteredSystems, sortStatus]); const PercentCell = ({ value }: { value: number }) => ( @@ -212,54 +201,6 @@ 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, @@ -416,7 +357,7 @@ export default function BeszelSystemTableWidget({ ]; return cols.filter(Boolean) as DataTableColumn[]; - }, [t, size, visibleMetricKeys, isAdvanced]); + }, [t, size, visibleMetricKeys]); const { effectiveColumns, storeKey } = usePersistedTableLayout({ columns, @@ -430,7 +371,7 @@ export default function BeszelSystemTableWidget({ const handleRowClick = useCallback( ({ record }: { record: SystemRowWithKey }) => { - const integrationId = record.rowKey.split(":")[0] ?? ""; + const integrationId = record._key.split(":")[0] ?? ""; openModal({ integrationId, systemId: record.id }, { title: record.name }); }, [openModal], @@ -447,10 +388,7 @@ 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 76fb0264f..4007bebc0 100644 --- a/packages/widgets/src/beszel-system-table/index.ts +++ b/packages/widgets/src/beszel-system-table/index.ts @@ -25,6 +25,7 @@ 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/beszel/_shared/mobile-summary.tsx b/packages/widgets/src/beszel/_shared/mobile-summary.tsx new file mode 100644 index 000000000..7f1caa6a0 --- /dev/null +++ b/packages/widgets/src/beszel/_shared/mobile-summary.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { clientApi } from "@homarr/api/client"; +import { useI18n } from "@homarr/translation/client"; + +import { WidgetMobileLoading, WidgetMobileSummary } from "../../common/mobile-summary"; +import { useBeszelFilteredSystems } from "./hooks"; + +interface BeszelSystemsMobileSummaryProps { + integrationIds: string[]; + label: string; + statusFilter: string; +} + +export const BeszelSystemsMobileSummary = ({ + integrationIds, + label, + statusFilter, +}: BeszelSystemsMobileSummaryProps) => { + const t = useI18n(); + const { data: results = [], error, isPending } = clientApi.widget.beszel.getSystems.useQuery({ integrationIds }); + const systems = useBeszelFilteredSystems(results, statusFilter); + const failedResults = results.filter((result) => "error" in result); + + if (isPending) return ; + if (error) throw error; + if (results.length > 0 && failedResults.length === results.length) { + throw new Error(String(failedResults[0]?.error ?? "Unable to connect to Beszel")); + } + + const up = systems.filter((system) => system.status === "up").length; + + return ( + 0} + /> + ); +}; diff --git a/packages/widgets/src/common/mobile-summary.tsx b/packages/widgets/src/common/mobile-summary.tsx index d3f6e9bf1..4a8af6270 100644 --- a/packages/widgets/src/common/mobile-summary.tsx +++ b/packages/widgets/src/common/mobile-summary.tsx @@ -1,29 +1,57 @@ -import { Center, Group, Stack, Text } from "@mantine/core"; +"use client"; + +import { Center, Group, Loader, Stack, Text } from "@mantine/core"; +import { IconAlertTriangle } from "@tabler/icons-react"; + +import { useI18n } from "@homarr/translation/client"; interface WidgetMobileSummaryProps { value: number | string; label: string; description?: string; + isStale?: boolean; } -export function WidgetMobileSummary({ value, label, description }: WidgetMobileSummaryProps) { +export function WidgetMobileSummary({ value, label, description, isStale = false }: WidgetMobileSummaryProps) { + const t = useI18n(); + return (
- - - {value} - - - {label} - - + + {value} + + + {label} + {description !== undefined && ( - + {description} )} + {isStale && ( + + + + {t("board.mobile.dataWarning")} + + + )}
); } + +export function WidgetMobileLoading() { + const t = useI18n(); + + return ( +
+ +
+ ); +} diff --git a/packages/widgets/src/coolify/applications-section.tsx b/packages/widgets/src/coolify/applications-section.tsx index bbc52c662..e0ae5691f 100644 --- a/packages/widgets/src/coolify/applications-section.tsx +++ b/packages/widgets/src/coolify/applications-section.tsx @@ -13,9 +13,10 @@ interface ApplicationsSectionProps { applications: CoolifyApplicationWithContext[]; baseUrl: string; isTiny: boolean; + isMobileDetail: boolean; } -export function ApplicationsSection({ applications, baseUrl, isTiny }: ApplicationsSectionProps) { +export function ApplicationsSection({ applications, baseUrl, isTiny, isMobileDetail }: ApplicationsSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningApps = applications.filter((app) => parseStatus(app.status ?? "") === "running").length; @@ -34,7 +35,14 @@ export function ApplicationsSection({ applications, baseUrl, isTiny }: Applicati {applications.length > 0 ? ( {applications.map((app) => ( - + ))} ) : ( diff --git a/packages/widgets/src/coolify/component.tsx b/packages/widgets/src/coolify/component.tsx index d9c688555..83449f190 100644 --- a/packages/widgets/src/coolify/component.tsx +++ b/packages/widgets/src/coolify/component.tsx @@ -1,26 +1,17 @@ "use client"; -import { Badge, Box, Group, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; +import { ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core"; import { clientApi } from "@homarr/api/client"; import { useScopedI18n } from "@homarr/translation/client"; -import { getUsableWidgetQueryData } from "../common/query-state"; -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 { getUsableWidgetQueryData } from "../common/query-state"; +import { createWidgetKey } 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, - displayMode, -}: WidgetComponentProps<"coolify">) { +export default function CoolifyWidget({ options, integrationIds, width, height }: WidgetComponentProps<"coolify">) { const t = useScopedI18n("widget.coolify"); if (integrationIds.length === 0) { @@ -31,18 +22,7 @@ export default function CoolifyWidget({ ); } - const isAdvanced = displayMode === "advanced"; - const sectionVisibility = getCoolifySectionVisibility(options, displayMode); - - return ( - - ); + return ; } interface CoolifyContentProps { @@ -50,24 +30,19 @@ interface CoolifyContentProps { options: WidgetComponentProps<"coolify">["options"]; width: number; height: number; - isAdvanced: boolean; } -function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: CoolifyContentProps) { +function CoolifyContent({ integrationIds, options, width, height }: 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] = successfulInstances; + const [firstInstance] = instancesData; const widgetKey = createWidgetKey(integrationIds); if (isPending) { @@ -79,77 +54,33 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }: ); } - if (successfulInstances.length === 0) { - return ( - - - - - - - ); - } - if (successfulInstances.length === 1 && firstInstance) { + if (instancesData.length === 1 && firstInstance) { return ( - - -
- -
-
+ ); } return ( - - - - = 760 ? 2 : 1} spacing="sm" p="xs"> - {successfulInstances.map((instance) => ( - - ))} - - - + + = 760 ? 2 : 1} spacing="sm" p="xs"> + {instancesData.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/index.ts b/packages/widgets/src/coolify/index.ts index cfc96bc9a..f9bf28ede 100644 --- a/packages/widgets/src/coolify/index.ts +++ b/packages/widgets/src/coolify/index.ts @@ -6,6 +6,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("coolify", { supportsAdvancedFocus: true, icon: IconCloud, + mobile: { supportsDetailView: true }, + queryKey: [["widget", "coolify"]], createOptions() { return optionsBuilder.from((factory) => ({ showServers: factory.switch({ diff --git a/packages/widgets/src/coolify/instance-card.tsx b/packages/widgets/src/coolify/instance-card.tsx index aa2a61a63..a085f0ef5 100644 --- a/packages/widgets/src/coolify/instance-card.tsx +++ b/packages/widgets/src/coolify/instance-card.tsx @@ -1,7 +1,6 @@ "use client"; -import { useState } from "react"; -import { Accordion, Anchor, Badge, Card, Group, Image, Stack, Text, Tooltip } from "@mantine/core"; +import { Accordion, Anchor, Badge, Card, Group, Image, Text, Tooltip } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; import { useTimeAgo } from "@homarr/common"; @@ -9,7 +8,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, isCoolifyServerOnline, parseStatus } from "./coolify-utils"; +import { buildServerResourceCounts, getBadgeColor, parseStatus } from "./coolify-utils"; import { ServersSection } from "./servers-section"; import { ServicesSection } from "./services-section"; import type { CoolifyOptions, InstanceData } from "./types"; @@ -19,12 +18,11 @@ interface InstanceCardProps { instance: InstanceData; options: CoolifyOptions; isTiny: boolean; - isAdvanced: boolean; widgetKey: string; hideFooter: boolean; } -export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, hideFooter }: InstanceCardProps) { +export function InstanceCard({ instance, options, isTiny, widgetKey, hideFooter }: InstanceCardProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const cardKey = `${widgetKey}-${instance.integrationId}`; @@ -36,7 +34,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, key: `coolify-sections-${cardKey}`, defaultValue: ["applications"], }); - const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]); const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, @@ -44,10 +41,9 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, ); const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? ""; - const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—"; const relativeTime = useTimeAgo(instance.updatedAt); - const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length; + const onlineServers = instance.instanceInfo.servers.filter((s) => s.is_reachable !== false).length; const runningApps = instance.instanceInfo.applications.filter( (a) => parseStatus(a.status ?? "") === "running", ).length; @@ -80,27 +76,20 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, wrap="nowrap" style={{ borderBottom: "1px solid var(--mantine-color-dark-4)" }} > - + Coolify - - - {instance.integrationName} - - {isAdvanced && ( - - {t("source.url", { url: displayUrl })} - - )} - + + {instance.integrationName} + {isTiny && totalResources > 0 ? ( @@ -136,20 +125,13 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, - + {options.showServers && ( setShowIp((prev) => !prev)} /> @@ -158,12 +140,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey, )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/coolify/servers-section.tsx b/packages/widgets/src/coolify/servers-section.tsx index ebd1a2b58..2e79bfdf0 100644 --- a/packages/widgets/src/coolify/servers-section.tsx +++ b/packages/widgets/src/coolify/servers-section.tsx @@ -1,28 +1,20 @@ "use client"; import { Accordion, ActionIcon, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; -import { - IconCircleCheckFilled, - IconCircleFilled, - IconCircleXFilled, - IconEye, - IconEyeOff, - IconServer, -} from "@tabler/icons-react"; +import { IconCircleCheckFilled, 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, getCoolifyServerState, isCoolifyServerOnline } from "./coolify-utils"; +import { getBadgeColor } from "./coolify-utils"; interface ServersSectionProps { servers: CoolifyServer[]; serverResourceCounts: Map; baseUrl: string; isTiny: boolean; - isAdvanced: boolean; showIp: boolean; onToggleIp: () => void; } @@ -32,13 +24,12 @@ export function ServersSection({ serverResourceCounts, baseUrl, isTiny, - isAdvanced, showIp, onToggleIp, }: ServersSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); - const onlineServers = servers.filter(isCoolifyServerOnline).length; + const onlineServers = servers.filter((server) => server.is_reachable !== false).length; return ( @@ -76,7 +67,6 @@ export function ServersSection({ } baseUrl={baseUrl} isTiny={isTiny} - isAdvanced={isAdvanced} showIp={showIp} /> ))} @@ -96,28 +86,23 @@ interface ServerRowProps { counts: { apps: number; services: number }; baseUrl: string; isTiny: boolean; - isAdvanced: boolean; showIp: boolean; } -function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: ServerRowProps) { +function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps) { const t = useScopedI18n("widget.coolify"); const isBuildServer = server.settings?.is_build_server === true; - const reachability = getCoolifyServerState(server, "is_reachable"); - const usability = getCoolifyServerState(server, "is_usable"); + const isOnline = server.is_reachable !== false; const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`); - 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"; + const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled; 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 1961077cc..c225db27d 100644 --- a/packages/widgets/src/coolify/services-section.tsx +++ b/packages/widgets/src/coolify/services-section.tsx @@ -1,24 +1,21 @@ "use client"; -import { Accordion, Anchor, Badge, Group, Stack, Text } from "@mantine/core"; +import { Accordion, 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 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 { getBadgeColor, 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, isAdvanced }: ServicesSectionProps) { +export function ServicesSection({ services, baseUrl, isTiny }: ServicesSectionProps) { const t = useScopedI18n("widget.coolify"); const tCommon = useScopedI18n("common"); const runningServices = services.filter((svc) => parseStatus(svc.status ?? "") === "running").length; @@ -37,19 +34,7 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi {services.length > 0 ? ( {services.map((service) => ( - - - {isAdvanced && service.applications && service.applications.length > 0 && ( - - - {tCommon("applications")} - - {service.applications.map((application) => ( - - ))} - - )} - + ))} ) : ( @@ -61,45 +46,3 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi ); } - -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 b7dd45b60..fc3455b2d 100644 --- a/packages/widgets/src/coolify/single-instance-layout.tsx +++ b/packages/widgets/src/coolify/single-instance-layout.tsx @@ -1,6 +1,5 @@ "use client"; -import { useState } from "react"; import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core"; import { useLocalStorage } from "@mantine/hooks"; @@ -19,19 +18,11 @@ interface SingleInstanceLayoutProps { instance: InstanceData; options: CoolifyOptions; isTiny: boolean; - isAdvanced: boolean; widgetKey: string; hideFooter: boolean; } -export function SingleInstanceLayout({ - instance, - options, - isTiny, - isAdvanced, - widgetKey, - hideFooter, -}: SingleInstanceLayoutProps) { +export function SingleInstanceLayout({ instance, options, isTiny, widgetKey, hideFooter }: SingleInstanceLayoutProps) { const t = useScopedI18n("widget.coolify"); const [showIp, setShowIp] = useLocalStorage({ key: `coolify-show-ip-${widgetKey}`, @@ -41,7 +32,6 @@ export function SingleInstanceLayout({ key: `coolify-sections-${widgetKey}`, defaultValue: ["applications"], }); - const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]); const serverResourceCounts = buildServerResourceCounts( instance.instanceInfo.servers, instance.instanceInfo.applications, @@ -62,41 +52,27 @@ export function SingleInstanceLayout({ oolify - - {isAdvanced && ( - - {instance.integrationName} - - )} - - {isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl} - - + + {displayUrl} + - + {options.showServers && ( setShowIp((prev) => !prev)} /> @@ -105,12 +81,7 @@ export function SingleInstanceLayout({ )} {options.showServices && ( - + )} diff --git a/packages/widgets/src/custom-api/index.ts b/packages/widgets/src/custom-api/index.ts index 5ca4b6f1f..a1fcf531d 100644 --- a/packages/widgets/src/custom-api/index.ts +++ b/packages/widgets/src/custom-api/index.ts @@ -6,6 +6,8 @@ import { optionsBuilder } from "../options"; export const { definition, componentLoader } = createWidgetDefinition("customApi", { icon: IconApi, + mobile: { supportsDetailView: true }, + queryKey: [["widget", "customApi"]], createOptions() { return optionsBuilder.from((factory) => ({ definitionId: factory.customWidgetSelect({ defaultValue: "" }), diff --git a/packages/widgets/src/dns-hole/controls/TimerModal.tsx b/packages/widgets/src/dns-hole/controls/TimerModal.tsx index 0a04779fa..3aa68a9b1 100644 --- a/packages/widgets/src/dns-hole/controls/TimerModal.tsx +++ b/packages/widgets/src/dns-hole/controls/TimerModal.tsx @@ -42,12 +42,21 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer setMinutes(0); }} title={t("widget.dnsHoleControls.controls.setTimer")} + closeButtonProps={{ + "aria-label": t("common.action.close"), + size: 44, + }} > {t("widget.dnsHoleControls.controls.hours")} - hoursHandlers.current?.decrement()}> + hoursHandlers.current?.decrement()} + > – - hoursHandlers.current?.increment()}> + hoursHandlers.current?.increment()} + > + {t("widget.dnsHoleControls.controls.minutes")} - minutesHandlers.current?.decrement()}> + minutesHandlers.current?.decrement()} + > – - minutesHandlers.current?.increment()}> + minutesHandlers.current?.increment()} + > + @@ -91,7 +117,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer variant="light" color="red" leftSection={} - h="2rem" + mih={44} w="12rem" onClick={handleSetTimer} > diff --git a/packages/widgets/src/dns-hole/controls/component.tsx b/packages/widgets/src/dns-hole/controls/component.tsx index d9e4b578e..13977a5a0 100644 --- a/packages/widgets/src/dns-hole/controls/component.tsx +++ b/packages/widgets/src/dns-hole/controls/component.tsx @@ -6,7 +6,6 @@ import { useState } from "react"; import { ActionIcon, Badge, - Box, Button, Card, Flex, @@ -34,9 +33,7 @@ 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"; @@ -44,15 +41,6 @@ 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, @@ -67,8 +55,7 @@ export default function DnsHoleControlsWidget({ .filter((id) => integrationIds.includes(id)); const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds }); - const summaryResults = getUsableWidgetQueryData(summaryQuery) ?? []; - const summaries = summaryResults.filter(isAvailableDnsSummaryResult); + const summaries = getUsableWidgetQueryData(summaryQuery) ?? []; const { isPending: isSummaryPending } = summaryQuery; const utils = clientApi.useUtils(); @@ -97,7 +84,7 @@ export default function DnsHoleControlsWidget({ if (!prevData) return []; return prevData.map((item) => - item.integration.id === integrationId && item.summary + item.integration.id === integrationId ? { ...item, summary: { @@ -160,14 +147,7 @@ export default function DnsHoleControlsWidget({ p="sm" gap="sm" style={{ pointerEvents: isEditMode ? "none" : undefined }} - pos="relative" > - - - - - - {controlAllButtonsVisible && ( @@ -280,7 +260,7 @@ export default function DnsHoleControlsWidget({ interface ControlsCardProps { integrationsWithInteractions: string[]; toggleDns: (integrationId: string) => Promise; - data: AvailableDnsSummaryResult; + data: RouterOutputs["widget"]["dnsHole"]["summary"][number]; setSelectedIntegrationIds: (integrationId: string[]) => void; open: () => void; t: TranslationFunction; diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 8e5a3ee02..11d52c999 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -10,6 +10,7 @@ export const widgetKind = "dnsHoleControls"; export const { definition, componentLoader } = createWidgetDefinition(widgetKind, { supportsAdvancedFocus: false, icon: IconDeviceGamepad, + mobile: { supportsDetailView: true }, queryKey: [["widget", "dnsHole"]], refetchInterval: 5, createOptions() { diff --git a/packages/widgets/src/docker/index.ts b/packages/widgets/src/docker/index.ts index bc229fed5..686a3dd02 100644 --- a/packages/widgets/src/docker/index.ts +++ b/packages/widgets/src/docker/index.ts @@ -25,6 +25,13 @@ const columnTranslationKeyMap = { export const { definition, componentLoader } = createWidgetDefinition("dockerContainers", { icon: IconBrandDocker, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + unmountWhenOffscreen: true, + }, queryKey: [["docker", "getContainers"]], refetchInterval: 30, createOptions() { diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx index 79886384e..f8cfafda0 100644 --- a/packages/widgets/src/health-monitoring/system-health.tsx +++ b/packages/widgets/src/health-monitoring/system-health.tsx @@ -24,8 +24,6 @@ import { IconCpu2, IconFileReport, IconInfoCircle, - IconPackages, - IconRefreshAlert, IconServer, IconTemperature, IconVersions, @@ -43,9 +41,7 @@ import { useI18n } from "@homarr/translation/client"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { WidgetEmptyState } from "../common/empty-state"; -import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; -import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; -import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import actionTargetClasses from "../common/action-target.module.css"; import { CpuRing } from "./rings/cpu-ring"; @@ -64,46 +60,21 @@ export const SystemHealthMonitoring = ({ withScrollArea = true, }: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => { const t = useI18n(); - 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 healthData = + getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ?? + []; 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 (isInitialWidgetQueryPending(healthQuery)) return ; - if (healthData.length === 0) { - return ( - - - {queryIndicators} - - - - ); - } + if (healthData.length === 0) return ; const Container = withScrollArea ? ScrollArea : Box; return ( - - - {queryIndicators} - + {healthData.map(({ integrationId, integrationName, healthInfo }) => { const filteredFileSystem = filterStorageVolumes( @@ -174,18 +145,18 @@ export const SystemHealthMonitoring = ({ - {showCpu && } - {showCpu && ( + {options.cpu && } + {options.cpu && ( )} - {showMemory && ( + {options.memory && ( )} - {showGpu && + {options.gpu && healthInfo.gpu.map((gpu) => ( ))} @@ -195,7 +166,7 @@ export const SystemHealthMonitoring = ({ )} - {showFileSystem && + {options.fileSystem && disksData.map((disk) => { return ( ; +type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"]; const SystemInformationList = ({ healthInfo, @@ -316,14 +285,6 @@ 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 8cc1e0eed..c61bd84c1 100644 --- a/packages/widgets/src/indexer-manager/index.ts +++ b/packages/widgets/src/indexer-manager/index.ts @@ -13,6 +13,7 @@ const createOptions = () => })); export const { definition, componentLoader } = createWidgetDefinition("indexerManager", { + supportsAdvancedFocus: false, icon: IconReportSearch, contextActions: ({ widgetRuntimeRef, context }) => [ { diff --git a/packages/widgets/src/media-server/component.tsx b/packages/widgets/src/media-server/component.tsx index ea0043b53..f8406ef13 100644 --- a/packages/widgets/src/media-server/component.tsx +++ b/packages/widgets/src/media-server/component.tsx @@ -37,7 +37,6 @@ 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"]; @@ -75,17 +74,16 @@ export default function MediaServerWidget({ isEditMode, displayMode, }: WidgetComponentProps<"mediaServer">) { - const currentStreamsQuery = clientApi.widget.mediaServer.getCurrentStreams.useQuery({ - integrationIds, - showOnlyPlaying: options.showOnlyPlaying, - }); - const currentStreamsData = getUsableWidgetQueryData(currentStreamsQuery); - const currentStreams = useMemo(() => currentStreamsData ?? [], [currentStreamsData]); + const currentStreams = + getUsableWidgetQueryData( + clientApi.widget.mediaServer.getCurrentStreams.useQuery({ + integrationIds, + showOnlyPlaying: options.showOnlyPlaying, + }), + ) ?? []; 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[] = [ @@ -144,8 +142,8 @@ export default function MediaServerWidget({ if (!currentlyPlaying) return null; const status = getPlaybackStatus(currentlyPlaying.metadata?.transcoding); - const location = showLocation ? currentlyPlaying.location : null; - const bitrateLabel = showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; + const location = options.showLocation ? currentlyPlaying.location : null; + const bitrateLabel = options.showBitrate ? formatBitrate(currentlyPlaying.metadata?.bitrateKbps) : null; return ( @@ -177,7 +175,7 @@ export default function MediaServerWidget({ : []), ]; return result; - }, [isAdvanced, selectedRowId, showBitrate, showLocation, t, width]); + }, [isAdvanced, options.showBitrate, options.showLocation, selectedRowId, 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 @@ -287,7 +285,7 @@ export default function MediaServerWidget({ (sum, session) => sum + (session.currentlyPlaying?.metadata?.bitrateKbps ?? 0), 0, ); - const totalBitrateLabel = showBitrate ? formatBitrate(totalBitrateKbps) : null; + const totalBitrateLabel = options.showBitrate ? formatBitrate(totalBitrateKbps) : null; const hasFailedIntegrations = currentStreams.some(({ error }) => Boolean(error)); return ( @@ -302,14 +300,12 @@ export default function MediaServerWidget({ {t("summary.transcoding", { count: transcodingCount })} - )} - {!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && ( + {!isAdvanced && hasFailedIntegrations && ( - )} diff --git a/packages/widgets/src/notebook/component.tsx b/packages/widgets/src/notebook/component.tsx index 84f3b1085..e8dc77498 100644 --- a/packages/widgets/src/notebook/component.tsx +++ b/packages/widgets/src/notebook/component.tsx @@ -2,6 +2,9 @@ import dynamic from "next/dynamic"; +import { useScopedI18n } from "@homarr/translation/client"; + +import { WidgetMobileSummary } from "../common/mobile-summary"; import type { WidgetComponentProps } from "../definition"; const Notebook = dynamic(() => import("./notebook").then((module) => module.Notebook), { @@ -9,5 +12,23 @@ const Notebook = dynamic(() => import("./notebook").then((module) => module.Note }); export default function NotebookWidget(props: WidgetComponentProps<"notebook">) { + const t = useScopedI18n("widget.notebook"); + + if (props.displayMode === "mobileSummary") { + const excerpt = props.options.content + .replace(/]*>[\s\S]*?<\/style>/gi, " ") + .replace(/]*>[\s\S]*?<\/script>/gi, " ") + .replace(/<[^>]+>/g, " ") + .replace(/&(?:nbsp|#160);/gi, " ") + .replace(/&(?:amp|#38);/gi, "&") + .replace(/&(?:lt|#60);/gi, "<") + .replace(/&(?:gt|#62);/gi, ">") + .replace(/&(?:quot|#34);/gi, '"') + .replace(/\s+/g, " ") + .trim(); + + return ; + } + return ; } diff --git a/packages/widgets/src/notebook/index.ts b/packages/widgets/src/notebook/index.ts index 6b85f3e33..f4ae6840b 100644 --- a/packages/widgets/src/notebook/index.ts +++ b/packages/widgets/src/notebook/index.ts @@ -6,6 +6,12 @@ import { defaultContent } from "./default-content"; export const { definition, componentLoader } = createWidgetDefinition("notebook", { icon: IconNotes, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + }, createOptions() { return optionsBuilder.from( (factory) => ({ diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index b190d2a82..6ecaae51b 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -70,7 +70,6 @@ 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"; @@ -99,7 +98,6 @@ export function Notebook({ boardId, itemId, height, - displayMode = "compact", }: WidgetComponentProps<"notebook">) { const [content, setContent] = useState(options.content); const previousContentRef = useRef(options.content); @@ -341,13 +339,6 @@ 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 ( @@ -384,9 +375,9 @@ export function Notebook({ > @@ -514,7 +505,7 @@ export function Notebook({ - {(saveError || display.showDocumentStats) && ( + {(saveError || height >= 180) && ( {saveError && ( @@ -530,7 +521,7 @@ export function Notebook({ )} - {display.showDocumentStats && ( + {height >= 180 && ( {t("widget.notebook.documentStats", documentStats)} @@ -882,18 +873,9 @@ function ListIndentIncrease() { editor?.chain().focus().sinkListItem(itemType).run(); }, [editor, itemType]); - 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]); + editor?.on("selectionUpdate", ({ editor }) => { + setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); + }); return ( { - 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]); + editor?.on("selectionUpdate", ({ editor }) => { + setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem"); + }); return ( widgetQueryInputMatches(input, { itemId: scope.itemId }), diff --git a/packages/widgets/src/rssFeed/component.tsx b/packages/widgets/src/rssFeed/component.tsx index db8a31641..5bcd3bfd3 100644 --- a/packages/widgets/src/rssFeed/component.tsx +++ b/packages/widgets/src/rssFeed/component.tsx @@ -9,10 +9,8 @@ import { clientApi } from "@homarr/api/client"; import { useRequiredBoard } from "@homarr/boards/context"; import { useScopedI18n } from "@homarr/translation/client"; -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 { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url"; import { BaseWidgetError } from "../errors/base-component"; import classes from "./component.module.scss"; @@ -29,7 +27,6 @@ const useLiveFeed = (input: RouterInputs["widget"]["rssFeed"]["getFeeds"]) => { hasError: false as const, entries: normalizedData, failedFeedCount: 0, - isPending: query.isPending, isStale: Boolean(query.error), }; } @@ -38,12 +35,11 @@ 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, displayMode }: WidgetComponentProps<"rssFeed">) { +export default function RssFeed({ options, width, height }: WidgetComponentProps<"rssFeed">) { const feed = useLiveFeed({ urls: options.feedUrls, maximumAmountPosts: typeof options.maximumAmountPosts === "number" ? options.maximumAmountPosts : 100, @@ -61,7 +57,6 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC /> ); } - if (feed.isPending) return ; const warning = feed.isStale ? t("warning.stale") @@ -69,35 +64,13 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC ? 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 ( @@ -124,7 +97,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC title={feedEntry.title} > - {feedEntry.enclosure !== undefined && entryDisplay.showImage && ( + {feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && ( - + {feedEntry.title} - {entryDisplay.showDescription && feedEntry.description && ( - + {!options.hideDescription && feedEntry.description && !isTiny && ( + {feedDescriptionToText(feedEntry.description)} )} @@ -189,44 +151,15 @@ export const getHostname = (url: string): 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 }) => ( - +const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => ( + {date && } {date && ( {date} )} - {timestamp && •} - {timestamp && ( - - {timestamp} - - )} - {(date || timestamp) && source && •} + {date && source && •} {source && ( {source} diff --git a/packages/widgets/src/rssFeed/index.ts b/packages/widgets/src/rssFeed/index.ts index abf728aa0..683dd07ef 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: true, + supportsAdvancedFocus: false, 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 ea6398180..2b6e637e6 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: true, + supportsAdvancedFocus: false, 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 f9b8cac37..80e0dd1e8 100644 --- a/packages/widgets/src/system-disks/component.tsx +++ b/packages/widgets/src/system-disks/component.tsx @@ -9,21 +9,16 @@ import { formatBytesPair } from "@homarr/common"; import { useI18n } from "@homarr/translation/client"; import { WidgetEmptyState } from "../common/empty-state"; -import { IntegrationErrorIndicator } from "../common/integration-error-indicator"; -import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state"; -import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator"; +import { getUsableWidgetQueryData } from "../common/query-state"; import type { WidgetComponentProps } from "../definition"; import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes"; import { NoIntegrationDataError } from "../errors/no-data-integration"; -type DiskDisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; - -export const clampPercentage = (percentage: number): number => Math.min(100, Math.max(0, percentage)); +type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"]; export const getDisplayText = ( item: { used: string; available: string; percentage: number }, - displayMode: DiskDisplayMode, - translatedFreeText?: string, + displayMode: DisplayMode, ) => { switch (displayMode) { case "percentage": @@ -32,26 +27,27 @@ export const getDisplayText = ( const usedInBytes = Number(item.used); const availableInBytes = Number(item.available); if (Number.isFinite(usedInBytes) && Number.isFinite(availableInBytes)) { - const { used, available: total } = formatBytesPair(usedInBytes, usedInBytes + availableInBytes); - return `${used} / ${total}`; + const { used, available } = formatBytesPair(usedInBytes, availableInBytes); + return `${used} / ${available}`; } return `${item.used} / ${item.available}`; } case "free": - return translatedFreeText ?? `${Math.round(100 - clampPercentage(item.percentage))}% free`; + return `${Math.round(100 - item.percentage)}% free`; default: return `${Math.round(item.percentage)}%`; } }; -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), -}); +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}`; +}; interface SystemDiskCardProps { deviceName: string; @@ -62,9 +58,6 @@ interface SystemDiskCardProps { showBackgroundBar: boolean; integrationName?: string; secondaryText?: string; - freeText?: string; - smartStatus?: string; - isAdvanced: boolean; showSecondaryText: boolean; showTemperature: boolean; } @@ -78,9 +71,6 @@ const SystemDiskCard = ({ showBackgroundBar, integrationName, secondaryText, - freeText, - smartStatus, - isAdvanced, showSecondaryText, showTemperature, }: SystemDiskCardProps) => { @@ -108,9 +98,7 @@ const SystemDiskCard = ({ }, [displayText, healthy]); const unhealthyLabel = t("widget.systemDisks.status.unhealthy"); - const hasTemperature = temperature !== null && temperature !== undefined; - const temperatureText = hasTemperature ? `${temperature}°C` : "—°C"; - const hasHiddenTemperature = hasTemperature && !showTemperature; + const hasHiddenTemperature = temperature !== null && temperature !== undefined && !showTemperature; const hasHiddenSecondaryText = Boolean(secondaryText && secondaryText !== displayText && !showSecondaryText); const tooltipLabel = [ healthy ? displayText : `${displayText} (${unhealthyLabel})`, @@ -152,19 +140,11 @@ const SystemDiskCard = ({ {secondaryText} )} - {isAdvanced && freeText && freeText !== displayText && ( - - {freeText} - - )} - {isAdvanced && ( - - {t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"} - - )}
- {showTemperature && (hasTemperature || isAdvanced) ? {temperatureText} : null} + {showTemperature && temperature !== null && temperature !== undefined ? ( + {temperature}°C + ) : null}
) { - const t = useI18n(); const queryInput = { integrationIds }; - 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} - - - - ); + const data = + getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? []; - if (isInitialWidgetQueryPending(healthQuery)) return ; - if (data.length === 0) return emptyState; + if (data.length === 0) return ; const disks = data.flatMap((entry) => { const fileSystem = filterStorageVolumes( @@ -233,52 +193,32 @@ export default function SystemResources({ })); }); - 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; + if (data.every((entry) => entry.healthInfo.fileSystem.length === 0)) throw new NoIntegrationDataError(); + if (disks.length === 0) return ; - 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 ( - - - {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)} - /> - ); - })} - - - + + + {disks.map(({ integrationId, integrationName, item, smartItem }) => ( + 1 ? integrationName : undefined} + secondaryText={getAbsoluteText(item)} + showSecondaryText={height >= 160 && cellWidth >= 260} + showTemperature={height >= 100 && cellWidth >= 220} + /> + ))} + + ); } diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index d25f23b58..4331705cd 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -7,6 +7,13 @@ import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options export const { definition, componentLoader } = createWidgetDefinition("systemDisks", { supportsAdvancedFocus: true, icon: IconServer2, + mobile: { + width: 2, + height: 1, + supportsCompactSummary: true, + supportsDetailView: true, + unmountWhenOffscreen: true, + }, queryKey: [["widget", "healthMonitoring"]], refetchInterval: 5, supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"], diff --git a/packages/widgets/src/system-resources/component.tsx b/packages/widgets/src/system-resources/component.tsx index 87f0984dc..018a3388f 100644 --- a/packages/widgets/src/system-resources/component.tsx +++ b/packages/widgets/src/system-resources/component.tsx @@ -4,13 +4,10 @@ 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"; @@ -19,9 +16,6 @@ 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; @@ -57,26 +51,6 @@ 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, @@ -84,23 +58,13 @@ export default function SystemResources({ height, displayMode, }: WidgetComponentProps<"systemResources">) { - const t = useScopedI18n("widget.systemResources"); const healthQuery = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds }); - const results = getUsableWidgetQueryData(healthQuery) ?? []; - const data = results.filter( - (entry): entry is typeof entry & { healthInfo: NonNullable } => entry.healthInfo !== null, - ); + const data = getUsableWidgetQueryData(healthQuery) ?? []; 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; @@ -138,7 +102,6 @@ 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} @@ -157,23 +120,11 @@ export default function SystemResources({ ); } - if (data.length === 0) { - return ( - - - {queryIndicators} - - - - ); - } + if (data.length === 0) return ; if (!isAdvanced && data.length === 1 && data[0]) { return ( - - - {queryIndicators} - + {renderCharts(data[0], width, Math.max(0, height - 20))} ); @@ -182,23 +133,17 @@ export default function SystemResources({ const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2))); return ( - - - {queryIndicators} - - - - {data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))} - - - + + + {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; @@ -210,7 +155,6 @@ interface SystemChartsProps { const SystemCharts = ({ integrationName, items, - hasGpu, memoryCapacityInBytes, options, width, @@ -219,15 +163,9 @@ const SystemCharts = ({ showTitle, }: SystemChartsProps) => { const networkItems = getNetworkHistory(items); - const visibleCharts = getVisibleSystemCharts({ - configuredCharts: options.visibleCharts, - hasGpu, - hasNetwork: networkItems.length > 0, - height, - isAdvanced, - }); + const availableCharts = options.visibleCharts.filter((chart) => chart !== "network" || networkItems.length > 0); + const visibleCharts = isAdvanced ? availableCharts : availableCharts.slice(0, getCompactChartBudget(height)); 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 @@ -254,7 +192,7 @@ const SystemCharts = ({ item.cpu)} hasShadow={options.hasShadow} - labelDisplayMode={labelDisplayMode} + labelDisplayMode={options.labelDisplayMode} /> )} @@ -264,7 +202,7 @@ const SystemCharts = ({ memoryUsageOverTime={items.map((item) => item.memory)} totalCapacityInBytes={memoryCapacityInBytes} hasShadow={options.hasShadow} - labelDisplayMode={labelDisplayMode} + labelDisplayMode={options.labelDisplayMode} /> )} @@ -273,7 +211,7 @@ const SystemCharts = ({ item.gpu)} hasShadow={options.hasShadow} - labelDisplayMode={labelDisplayMode} + labelDisplayMode={options.labelDisplayMode} />
)} @@ -284,13 +222,13 @@ const SystemCharts = ({ usageOverTime={networkItems.map((network) => network.down)} isUp={false} hasShadow={options.hasShadow} - labelDisplayMode={labelDisplayMode} + labelDisplayMode={options.labelDisplayMode} /> network.up)} isUp hasShadow={options.hasShadow} - labelDisplayMode={labelDisplayMode} + labelDisplayMode={options.labelDisplayMode} /> ) : ( @@ -298,7 +236,7 @@ const SystemCharts = ({ ))} diff --git a/packages/widgets/src/uptime-kuma/component.tsx b/packages/widgets/src/uptime-kuma/component.tsx index c7c803898..6dccbbb11 100644 --- a/packages/widgets/src/uptime-kuma/component.tsx +++ b/packages/widgets/src/uptime-kuma/component.tsx @@ -9,10 +9,8 @@ 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"; @@ -130,16 +128,12 @@ interface UptimeKumaContentProps { function UptimeKumaContent({ integrationIds, options, width, height, displayMode }: UptimeKumaContentProps) { const t = useScopedI18n("widget.uptimeKuma"); - const dashboardQuery = clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds }); - const dashboardData = getUsableWidgetQueryData(dashboardQuery); + const dashboardData = getUsableWidgetQueryData(clientApi.widget.uptimeKuma.getDashboard.useQuery({ integrationIds })); if (!dashboardData) return ; - const successfulDashboardData = dashboardData.flatMap((item) => - item.dashboard === null ? [] : [{ ...item, dashboard: item.dashboard }], - ); - const combined = aggregateUptimeKumaDashboards(successfulDashboardData.map((item) => item.dashboard)); - const monitors = successfulDashboardData.flatMap((result) => + const combined = aggregateUptimeKumaDashboards(dashboardData.map((item) => item.dashboard)); + const monitors = dashboardData.flatMap((result) => result.dashboard.monitors.map((monitor) => ({ ...monitor, key: `${result.integrationId}:${monitor.id}`, @@ -151,22 +145,25 @@ 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]) => isAdvanced || options[optionKey as keyof typeof options]) + .filter(([optionKey]) => options[optionKey as keyof typeof options]) .map(([, statKey]) => statKey); - 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 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 visibleStatKeys = prioritizedStatKeys.slice( 0, - isAdvanced ? prioritizedStatKeys.length : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), + displayMode === "advanced" + ? prioritizedStatKeys.length + : getCompactStatLimit(height, showHero, prioritizedStatKeys.length), ); const hasContent = showHero || visibleStatKeys.length > 0; @@ -248,17 +245,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
); - if (displayMode === "compact") { - return ( - - - - - - {summaryContent} - - ); - } + if (displayMode === "compact") return summaryContent; const monitorList = ( @@ -270,7 +257,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode {monitor.name} - {(isAdvanced || successfulDashboardData.length > 1) && ( + {dashboardData.length > 1 && ( {monitor.integrationName} @@ -287,32 +274,20 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode if (width < 760) { return ( - - - - - - - - {summaryContent} - {monitorList} - - - + + + {summaryContent} + {monitorList} + + ); } return ( - - - - - - - {summaryContent} - {monitorList} - - + + {summaryContent} + {monitorList} + ); } diff --git a/packages/widgets/src/vpn/index.ts b/packages/widgets/src/vpn/index.ts index f8418d86f..c834da1e8 100644 --- a/packages/widgets/src/vpn/index.ts +++ b/packages/widgets/src/vpn/index.ts @@ -8,6 +8,8 @@ import { optionsBuilder } from "../options"; export const { componentLoader, definition } = createWidgetDefinition("vpn", { supportsAdvancedFocus: false, icon: IconShieldLock, + mobile: { supportsDetailView: true }, + queryKey: [["widget", "vpn"]], refetchInterval: null, createOptions() { return optionsBuilder.from(() => ({}));