feat: refine mobile dashboard experience

This commit is contained in:
ajnart committed 2026-08-14 11:32:09 +02:00
1 parent 46898cd0ea
commit c1da19c9b2
68 files changed
+1418 -1306

No files matched your search

@@ -38,7 +38,6 @@ export const createBoardContentPage = <TParams extends Record<string, unknown>>(
headerActions: <BoardContentHeaderActions demoReadOnly={env.DEMO_MODE && env.DEMO_READ_ONLY} />,
getInitialBoardAsync: getInitialBoard,
withTour: true,
mobileProfileInActions: true,
}),
// eslint-disable-next-line no-restricted-syntax
page: async ({ params }: { params: Promise<TParams> }) => {
@@ -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);
});
});
@@ -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;
};
@@ -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 (
<UnstyledButton component={Link} href="/">
<BoardLogoWithTitle size="md" hideTitleOnMobile />
</UnstyledButton>
);
}
return (
<Menu position="bottom-start" width={280}>
@@ -26,7 +36,7 @@ export const BoardSwitcherLogo = () => {
>
<Group gap="xs" wrap="nowrap">
<BoardLogo size={28} />
<Text fw={600} truncate>
<Text fw={600} truncate title={board.name}>
{board.name}
</Text>
<IconChevronDown size={16} aria-hidden />
@@ -46,7 +56,9 @@ export const BoardSwitcherLogo = () => {
leftSection={isCurrent ? <IconCheck size={20} /> : <IconLayoutBoard size={20} />}
aria-current={isCurrent ? "page" : undefined}
>
{availableBoard.name}
<Text truncate title={availableBoard.name}>
{availableBoard.name}
</Text>
</Menu.Item>
);
})}
@@ -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");
});
});
@@ -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<HTMLDivElement | null>(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 (
<div ref={wrapperRef} className={classes.deferredItem} aria-busy={!hasMounted}>
@@ -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;
}
@@ -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}
>
<DeferredMobileItem eager={presentation.eager}>
<DeferredMobileItem eager={presentation.eager} unmountWhenOffscreen={presentation.unmountWhenOffscreen}>
<BoardItemContent
item={item}
displayMode={presentation.displayMode}
disableContextMenu
widgetStateRef={widgetStateRef}
/>
<MobileWidgetActions
item={item}
supportsDetails={presentation.supportsDetails}
isReadOnly
widgetStateRef={widgetStateRef}
/>
</DeferredMobileItem>
<MobileWidgetActions item={item} supportsDetails={presentation.supportsDetails} widgetStateRef={widgetStateRef} />
</Box>
);
};
@@ -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 = () => {
}}
>
<Box className={classes.stream} data-mobile-board>
<Title order={1} className={classes.boardTitle}>
{board.name}
</Title>
{elements.length === 0 ? (
<Center className={classes.empty}>
<Stack align="center" gap="xs" ta="center" maw={360}>
@@ -120,7 +122,7 @@ export const MobileBoard = () => {
{t("board.mobile.empty.title")}
</Title>
<Text c="dimmed" size="sm">
{t("board.mobile.empty.description")}
{t(hasChangeAccess ? "board.mobile.empty.editorDescription" : "board.mobile.empty.viewerDescription")}
</Text>
</Stack>
</Center>
@@ -130,10 +132,12 @@ export const MobileBoard = () => {
<Title
key={block.id}
id={block.anchorId}
order={2}
size="h4"
order={block.headingLevel}
size={block.headingLevel === 2 ? "h4" : block.headingLevel === 3 ? "h5" : "h6"}
fw={block.headingLevel === 2 ? 700 : 600}
className={classes.sectionHeading}
tabIndex={-1}
style={{ marginInlineStart: Math.max(0, block.headingLevel - 2) * 12 }}
>
{block.title}
</Title>
@@ -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);
});
});
@@ -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<HTMLElement>('[role="dialog"]') ?? element.querySelector<HTMLElement>('[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<HTMLDivElement | null>) => {
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;
};
@@ -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,
});
});
});
@@ -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 = <TLayout extends PositionedLayout>(
(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,
@@ -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);
});
});
@@ -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;
@@ -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);
});
});
@@ -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;
};
@@ -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<HTMLButtonElement | null>(null);
const detailsDialogRef = useRef<HTMLDivElement | null>(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<string, unknown>,
[item.kind, item.options, settings],
@@ -51,36 +50,32 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
return definition.contextActions({
options,
setOptions: (partial: Record<string, unknown>) =>
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 (
<>
<ActionIcon
ref={actionTriggerRef}
className={classes.widgetAction}
variant="filled"
color="dark"
radius="xl"
size={44}
aria-label={t("board.mobile.actions")}
aria-label={t("board.mobile.actions", { title })}
onClick={actionsDisclosure.open}
>
<IconDotsVertical size={20} />
@@ -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,
}}
>
<Stack gap="xs" pb="calc(var(--mantine-spacing-sm) + env(safe-area-inset-bottom))">
{supportsDetails && (
@@ -103,8 +113,8 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
size="lg"
leftSection={<IconMaximize size={20} />}
onClick={() => {
openDetailsAfterActionsRef.current = true;
actionsDisclosure.close();
detailsDisclosure.open();
}}
>
{t("board.mobile.details")}
@@ -130,37 +140,74 @@ export const MobileWidgetActions = ({ item, supportsDetails, widgetStateRef }: M
</Button>
);
})}
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={refresh}
>
{t("item.menu.label.refresh")}
</Button>
{queryKey && <MobileWidgetRefreshAction queryKey={queryKey} onComplete={actionsDisclosure.close} />}
</Stack>
</Drawer>
<Modal
ref={detailsDialogRef}
opened={detailsOpened}
onClose={detailsDisclosure.close}
title={title}
fullScreen
padding="md"
returnFocus={false}
trapFocus={!hasNestedDialog}
closeOnEscape={!hasNestedDialog}
onExitTransitionEnd={() => 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))",
},
}}
>
<Box className={classes.detailContent}>
<BoardItemContent item={item} displayMode="mobileDetail" disableContextMenu widgetStateRef={widgetStateRef} />
<BoardItemContent
item={item}
displayMode="mobileDetail"
disableContextMenu
isReadOnly
widgetStateRef={widgetStateRef}
/>
</Box>
</Modal>
</>
);
};
const MobileWidgetRefreshAction = ({ queryKey, onComplete }: { queryKey: QueryKey; onComplete: () => void }) => {
const t = useI18n();
const queryClient = useQueryClient();
const isFetching = useIsFetching({ queryKey }) > 0;
return (
<Button
variant="subtle"
justify="flex-start"
size="lg"
disabled={isFetching}
leftSection={isFetching ? <Loader size={20} /> : <IconRefresh size={20} />}
onClick={() => {
void queryClient.refetchQueries({ queryKey, type: "all" });
onComplete();
}}
>
{t("item.menu.label.refresh")}
</Button>
);
};
+243
View File
@@ -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: "<p>Mobile notes</p>" } : {}),
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<HTMLElement>("[data-mobile-board] > div"));
const twoColumnGrids = grids.filter((grid) => getComputedStyle(grid).display === "grid");
const items = Array.from(document.querySelectorAll<HTMLElement>("[data-mobile-board-item]"));
const focusable = Array.from(
document.querySelectorAll<HTMLElement>(
'[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<HTMLElement>("[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);
});
+4 -2
View File
@@ -1913,6 +1913,9 @@ const getHomeIdBoardAsync = async (
user: InferSelectModel<typeof users> | 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))
+64 -3
View File
@@ -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({
+9 -54
View File
@@ -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({
@@ -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({
@@ -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,
})),
);
}),
});
+12 -27
View File
@@ -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,
};
});
}),
});
@@ -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([]);
});
});
@@ -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 (
<Stack gap="xs">
@@ -34,22 +40,24 @@ export function PaginatedList({ children, pageSize = 6 }: PaginatedListProps) {
<Group justify="center" gap="xs">
<ActionIcon
variant="subtle"
size="sm"
size={44}
disabled={clampedPage === 0}
aria-label={tCommon("action.previous")}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
<IconChevronLeft size={14} />
<PreviousIcon size={14} />
</ActionIcon>
<Text size="xs" c="dimmed">
{clampedPage + 1} / {totalPages}
</Text>
<ActionIcon
variant="subtle"
size="sm"
size={44}
disabled={clampedPage >= totalPages - 1}
aria-label={tCommon("action.next")}
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
>
<IconChevronRight size={14} />
<NextIcon size={14} />
</ActionIcon>
</Group>
)}
+44 -90
View File
@@ -2356,12 +2356,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",
@@ -2763,7 +2757,6 @@
},
"advanced": {
"entityId": "Entity ID: {id}",
"lastChanged": "Changed {date}",
"lastUpdated": "Updated {date}",
"attribute": {
"unit": "Unit",
@@ -2792,9 +2785,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",
@@ -2807,14 +2798,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"
@@ -3003,11 +2986,6 @@
},
"title": "Indexer manager",
"testAll": "Test all",
"status": {
"healthy": "Healthy",
"unhealthy": "Unhealthy",
"disabled": "Disabled"
},
"error": {
"internalServerError": "Failed to fetch indexers status"
}
@@ -3065,10 +3043,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": {
@@ -3187,13 +3162,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"
@@ -3212,11 +3181,7 @@
"restarting": "Restarting",
"unknown": "Unknown",
"online": "Online",
"offline": "Offline",
"reachable": "Reachable",
"unreachable": "Unreachable",
"usable": "Usable",
"unusable": "Unusable"
"offline": "Offline"
}
},
"vpn": {
@@ -3752,9 +3717,6 @@
"movies": "Missing Movies",
"providers": "Provider Issues",
"status": "Health Issues",
"sonarrSignalr": "Sonarr SignalR",
"radarrSignalr": "Radarr SignalR",
"announcements": "Announcements",
"option": {
"showMissingEpisodes": {
"label": "Show missing episodes",
@@ -4353,16 +4315,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}}",
@@ -4381,9 +4333,6 @@
"description": "Displays the summary of a Network Controller (like UniFi Controller)"
},
"networkControllerStatus": {
"advanced": {
"updatedAt": "Updated {date}"
},
"card": {
"variants": {
"wired": {
@@ -4515,8 +4464,7 @@
"range": {
"twelveHours": "12h",
"oneDay": "24h",
"all": "All",
"lastThirty": "Last 30"
"all": "All"
}
},
"uptimeKuma": {
@@ -4731,9 +4679,7 @@
}
},
"status": {
"unhealthy": "Unhealthy",
"smart": "SMART",
"free": "{percentage}% free"
"unhealthy": "Unhealthy"
}
},
"customApi": {
@@ -4854,12 +4800,6 @@
},
"column": {
"system": "System",
"source": "Source",
"hostname": "Hostname",
"os": "OS",
"cpuModel": "CPU model",
"cores": "Cores",
"memoryTotal": "Memory total",
"cpu": "CPU",
"memory": "Memory",
"disk": "Disk",
@@ -4920,14 +4860,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",
@@ -5079,18 +5011,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",
@@ -5330,12 +5250,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": {
+1 -4
View File
@@ -124,10 +124,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
/>
</Flex>
</Tooltip.Floating>
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
Boolean(app.pingUrl ?? app.href) ? (
{options.pingEnabled && !settings.forceDisableStatus && !board.disableStatus && app.href ? (
<Suspense fallback={<PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />}>
<PingIndicator appId={app.id} />
</Suspense>
+1
View File
@@ -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,
@@ -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<string, LucideIcon> = {
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<string, string> = {};
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 (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={alertsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
</Box>
<ScrollArea h="100%" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Stack gap="sm" p="sm">
@@ -153,12 +124,11 @@ export default function BeszelAlertsWidget({
</Group>
{triggeredAlerts.map((alert) => (
<AlertRow
key={alert.key}
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered
showDescription={showAlertDescriptions}
/>
@@ -178,12 +148,11 @@ export default function BeszelAlertsWidget({
</Group>
{okAlerts.map((alert) => (
<AlertRow
key={alert.key}
key={alert._key}
name={alert.name}
value={alert.value}
min={alert.min}
systemName={getBeszelSystemName(systemNameMap, alert.integrationId, alert.system)}
integrationName={showIntegrationName ? alert.integrationName : undefined}
systemName={systemNameMap[alert.system] ?? alert.system}
triggered={false}
showDescription={showAlertDescriptions}
/>
@@ -202,12 +171,11 @@ export default function BeszelAlertsWidget({
</Text>
</Group>
{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 (
<Group key={entry.key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group key={entry._key} justify="space-between" wrap="nowrap" gap="xs" pl={4}>
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
<Box
w={3}
@@ -221,7 +189,7 @@ export default function BeszelAlertsWidget({
{entry.name}
</Text>
<Text size="xs" c="dimmed" truncate>
{systemLabel}
{systemName}
</Text>
</Stack>
</Group>
@@ -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
<Stack gap={0} style={{ flex: 1, minWidth: 0 }}>
<Group gap={6} wrap="nowrap">
<Text size="xs" fw={600} truncate>
{integrationName ? `${systemName} · ${integrationName}` : systemName}
{systemName}
</Text>
<Text size="xs" c="dimmed">
·
+5 -8
View File
@@ -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() {
@@ -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<typeof useScopedI18n<"widget.beszelSystemGrid">>;
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 = [
<DiskUsage system={s} fontSize={sz.fontSize} progressSize={sz.progressSize} valueMiw={sz.valueMiw} />
</Group>
),
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 = ({
</Badge>
</Group>
{isAdvanced && (
<SimpleGrid cols={2} spacing={4} verticalSpacing={2} mb={4}>
<SystemMetadata label={t("metadata.hostname")} value={system.hostname || "—"} />
<SystemMetadata label={t("metadata.os")} value={system.osName || "—"} />
<SystemMetadata label={t("metadata.cpuModel")} value={system.cpuModel || "—"} />
<SystemMetadata label={t("metadata.cores")} value={String(system.cores)} />
<SystemMetadata
label={t("metadata.memoryTotal")}
value={system.memoryTotal > 0 ? formatBytes(system.memoryTotal) : "—"}
/>
<SystemMetadata label={t("metadata.source")} value={integrationName} />
</SimpleGrid>
)}
<Stack gap={0} style={{ flex: 1 }} justify="space-evenly">
{visibleMetrics.map((m) => m.render(system, t, size))}
</Stack>
@@ -422,24 +380,12 @@ const SystemCard = ({
);
};
const SystemMetadata = ({ label, value }: { label: string; value: string }) => (
<Box style={{ minWidth: 0 }}>
<Text size="10px" c="dimmed">
{label}
</Text>
<Text size="xs" fw={500} truncate title={value}>
{value}
</Text>
</Box>
);
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 (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -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 (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
<Box
h="100%"
style={{
@@ -508,22 +445,19 @@ export default function BeszelSystemGridWidget({
}}
>
{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 (
<SystemCard
key={system.rowKey}
key={system._key}
system={system}
options={options}
t={t}
size={size}
maxMetrics={maxMetrics}
itemRadius={board.itemRadius}
integrationName={integrationName}
isAdvanced={isAdvanced}
onClick={handleClick}
/>
);
@@ -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,
@@ -19,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";
@@ -47,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]);
@@ -142,10 +140,7 @@ export default function BeszelSystemStatsWidget({
if (systems.length === 0) {
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 1 }}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<IntegrationErrorIndicator results={systemsResult} />
<Center h="100%">
<Stack align="center" gap="xs">
<IconServerOff size={28} opacity={0.5} />
@@ -182,10 +177,7 @@ export default function BeszelSystemStatsWidget({
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={systemsResult} />
</Group>
<IntegrationErrorIndicator results={systemsResult} />
</Box>
<ScrollArea
h="100%"
@@ -16,6 +16,7 @@ const timePeriodOptions = [
];
export const { definition, componentLoader } = createWidgetDefinition("beszelSystemStats", {
supportsAdvancedFocus: false,
icon: IconChartAreaLine,
queryKeys: [[["widget", "beszel", "getSystems"]], [["widget", "beszel", "getSystemStats"]]],
queryMatcher(query, scope) {
@@ -20,7 +20,6 @@ import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { constructBoardPermissions } from "@homarr/auth/shared";
import { useOptionalBoard } from "@homarr/boards/context";
import { formatBytes } from "@homarr/common";
import { useModalAction } from "@homarr/modals";
import { showErrorNotification } from "@homarr/notifications";
import { useScopedI18n } from "@homarr/translation/client";
@@ -41,23 +40,15 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
import { getBeszelTableVisibleMetricKeys } from "./display";
const directionMultiplier: Record<string, number> = { 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<DataTableSortStatus<SystemRowWithKey>>({
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 }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
@@ -212,54 +201,6 @@ export default function BeszelSystemTableWidget({
</Group>
),
},
isAdvanced && {
accessor: "integrationName",
width: 140,
ellipsis: true,
title: t("column.source"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.integrationName}</Text>,
},
isAdvanced && {
accessor: "hostname",
width: 150,
ellipsis: true,
title: t("column.hostname"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.hostname || "—"}</Text>,
},
isAdvanced && {
accessor: "osName",
width: 160,
ellipsis: true,
title: t("column.os"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.osName || "—"}</Text>,
},
isAdvanced && {
accessor: "cpuModel",
width: 220,
ellipsis: true,
title: t("column.cpuModel"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cpuModel || "—"}</Text>,
},
isAdvanced && {
accessor: "cores",
width: 80,
title: t("column.cores"),
sortable: true,
render: (record) => <Text size={size.fontSize}>{record.cores}</Text>,
},
isAdvanced && {
accessor: "memoryTotal",
width: 120,
title: t("column.memoryTotal"),
sortable: true,
render: (record) => (
<Text size={size.fontSize}>{record.memoryTotal > 0 ? formatBytes(record.memoryTotal) : "—"}</Text>
),
},
visibleMetricKeys.has("showCpu") && {
accessor: "cpu",
width: 140,
@@ -416,7 +357,7 @@ export default function BeszelSystemTableWidget({
];
return cols.filter(Boolean) as DataTableColumn<SystemRowWithKey>[];
}, [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 = (
<div style={{ position: "relative", height: "100%" }}>
<div style={{ position: "absolute", top: 4, right: 8, zIndex: 1 }}>
<Group gap={0}>
<WidgetQueryErrorIndicator error={systemsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={results} />
</Group>
<IntegrationErrorIndicator results={results} />
</div>
<HomarrDataTable
isEditMode={isEditMode}
@@ -461,7 +399,7 @@ export default function BeszelSystemTableWidget({
sortStatus={sortStatus}
onSortStatusChange={setSortStatus}
noRecordsText={t("noRecords")}
idAccessor="rowKey"
idAccessor="_key"
storeColumnsKey={storeKey}
onRowClick={isEditMode ? undefined : handleRowClick}
/>
@@ -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"
@@ -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 <WidgetMobileLoading />;
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 (
<WidgetMobileSummary
value={systems.length}
label={label}
description={t("board.mobile.systemStatus", {
up,
attention: systems.length - up,
})}
isStale={failedResults.length > 0}
/>
);
};
+39 -11
View File
@@ -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 (
<Center h="100%" p="sm">
<Stack align="center" gap={2} w="100%" style={{ minWidth: 0 }}>
<Group gap="xs" justify="center" wrap="nowrap" w="100%">
<Text fz="xl" fw={700} lh={1}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1}>
{label}
</Text>
</Group>
<Text fz="xl" fw={700} lh={1.15} ta="center" w="100%" lineClamp={2} style={{ overflowWrap: "anywhere" }}>
{value}
</Text>
<Text size="sm" fw={600} lineClamp={1} ta="center" w="100%">
{label}
</Text>
{description !== undefined && (
<Text c="dimmed" size="xs" lineClamp={1} ta="center" w="100%">
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1} ta="center" w="100%">
{description}
</Text>
)}
{isStale && (
<Group component="output" gap={4} justify="center" wrap="nowrap" w="100%">
<IconAlertTriangle
size={14}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
aria-hidden
/>
<Text c="var(--mantine-color-text)" size="xs" lineClamp={1}>
{t("board.mobile.dataWarning")}
</Text>
</Group>
)}
</Stack>
</Center>
);
}
export function WidgetMobileLoading() {
const t = useI18n();
return (
<Center component="output" h="100%" aria-label={t("common.action.loading")}>
<Loader size="sm" />
</Center>
);
}
@@ -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 ? (
<Stack gap={4}>
{applications.map((app) => (
<ResourceRow key={app.uuid} item={app} baseUrl={baseUrl} isTiny={isTiny} resourceType="application" />
<ResourceRow
key={app.uuid}
item={app}
baseUrl={baseUrl}
isTiny={isTiny}
resourceType="application"
isMobileDetail={isMobileDetail}
/>
))}
</Stack>
) : (
+29 -98
View File
@@ -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 (
<CoolifyContent
integrationIds={integrationIds}
options={{ ...options, ...sectionVisibility }}
width={width}
height={height}
isAdvanced={isAdvanced}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
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 }:
</Stack>
);
}
if (successfulInstances.length === 0) {
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<Box style={{ minHeight: 0, flex: 1 }}>
<WidgetEmptyState />
</Box>
</Stack>
);
}
if (successfulInstances.length === 1 && firstInstance) {
if (instancesData.length === 1 && firstInstance) {
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<div style={{ minHeight: 0, flex: 1 }}>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
</div>
</Stack>
<SingleInstanceLayout
instance={firstInstance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
);
}
return (
<Stack h="100%" gap={0}>
<FailedSourceBadges sources={failedInstances} queryError={instancesQuery.error} />
<ScrollArea style={{ minHeight: 0, flex: 1 }}>
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{successfulInstances.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={!isAdvanced && isTiny}
isAdvanced={isAdvanced}
widgetKey={widgetKey}
hideFooter={!isAdvanced && hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
</Stack>
<ScrollArea h="100%">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
instance={instance}
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
hideFooter={hideFooter}
/>
))}
</SimpleGrid>
</ScrollArea>
);
}
const FailedSourceBadges = ({
sources,
queryError,
}: {
sources: { integrationId: string; integrationName: string }[];
queryError: unknown;
}) => {
const t = useScopedI18n("widget.coolify");
if (sources.length === 0 && !queryError) return null;
return (
<Group gap={4} p="xs" pb={0} wrap="wrap">
{sources.map((source) => (
<Badge key={source.integrationId} color="red" variant="light" size="xs">
{t("source.unavailable", { source: source.integrationName })}
</Badge>
))}
<Box ml="auto">
<WidgetQueryErrorIndicator error={queryError} label={t("name")} />
</Box>
</Group>
);
};
+2
View File
@@ -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({
+19 -42
View File
@@ -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)" }}
>
<Group gap={4} wrap="nowrap" miw={0}>
<Group gap={4} wrap="nowrap">
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Stack gap={0} miw={0}>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
{isAdvanced && (
<Text fz="10px" c="dimmed" truncate="end">
{t("source.url", { url: displayUrl })}
</Text>
)}
</Stack>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "10px" : "xs"}
fw={600}
c="inherit"
lineClamp={1}
>
{instance.integrationName}
</Anchor>
</Group>
<Group gap={4} wrap="nowrap">
{isTiny && totalResources > 0 ? (
@@ -136,20 +125,13 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
</Group>
</Group>
<Accordion
variant="filled"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -158,12 +140,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
@@ -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<number, { apps: number; services: number }>;
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 (
<Accordion.Item value="servers">
@@ -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 (
<Stack gap={0}>
<Group wrap="nowrap" gap={isTiny ? 4 : "xs"}>
<StatusIcon
aria-label={t(`status.${status}`)}
aria-label={t(isOnline ? "status.online" : "status.offline")}
size={isTiny ? 12 : 16}
color={`var(--mantine-color-${statusColor}-6)`}
color={`var(--mantine-color-${isOnline ? "green" : "red"}-6)`}
/>
<Anchor
className={actionTargetClasses.root}
@@ -144,30 +129,9 @@ function ServerRow({ server, counts, baseUrl, isTiny, isAdvanced, showIp }: Serv
</Group>
<Group wrap="nowrap" gap={4} ml={16}>
<Text fz="10px" c="dimmed">
{showIp ? (server.ip ?? "—") : "***.***.***.***"}
{showIp ? server.ip : "***.***.***.***"}
</Text>
</Group>
{isAdvanced && (
<Group gap={4} ml={16} wrap="wrap">
<ServerStateBadge field="reachability" value={reachability} />
<ServerStateBadge field="usability" value={usability} />
</Group>
)}
</Stack>
);
}
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 (
<Badge size="xs" variant="light" color={value === true ? "green" : value === false ? "red" : "gray"}>
{t(`server.${field}`)}: {status}
</Badge>
);
};
@@ -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 ? (
<Stack gap={4}>
{services.map((service) => (
<Stack key={service.uuid} gap={2}>
<ResourceRow item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
{isAdvanced && service.applications && service.applications.length > 0 && (
<Stack gap={2} ml="lg">
<Text fz="10px" c="dimmed">
{tCommon("applications")}
</Text>
{service.applications.map((application) => (
<ServiceApplicationRow key={application.uuid} application={application} />
))}
</Stack>
)}
</Stack>
<ResourceRow key={service.uuid} item={service} baseUrl={baseUrl} isTiny={isTiny} resourceType="service" />
))}
</Stack>
) : (
@@ -61,45 +46,3 @@ export function ServicesSection({ services, baseUrl, isTiny, isAdvanced }: Servi
</Accordion.Item>
);
}
type ServiceApplication = NonNullable<CoolifyServiceWithContext["applications"]>[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 (
<Group gap={4} wrap="nowrap">
<Badge size="xs" variant="dot" color={getStatusColor(status)}>
{statusLabel}
</Badge>
{publicUrl ? (
<Anchor
className={actionTargetClasses.root}
href={publicUrl}
target="_blank"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
style={{ textDecoration: "none" }}
>
{application.name}
</Anchor>
) : (
<Text fz="xs" truncate="end">
{application.name}
</Text>
)}
</Group>
);
};
@@ -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
</Text>
</Group>
<Stack gap={0} miw={0}>
{isAdvanced && (
<Text fz="xs" fw={600} truncate="end">
{instance.integrationName}
</Text>
)}
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{isAdvanced ? t("source.url", { url: displayUrl }) : displayUrl}
</Anchor>
</Stack>
<Anchor
component={baseUrl ? "a" : "span"}
href={baseUrl}
target={baseUrl ? "_blank" : undefined}
rel={baseUrl ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
</Group>
<Accordion
variant="contained"
chevronPosition="right"
multiple
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
<Accordion variant="contained" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -105,12 +81,7 @@ export function SingleInstanceLayout({
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
)}
</Accordion>
+2
View File
@@ -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: "" }),
@@ -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,
}}
>
<Flex direction="column" align="center" justify="center">
<Stack align="flex-end">
<Group>
<Text>{t("widget.dnsHoleControls.controls.hours")}</Text>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} −`}
onClick={() => hoursHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -58,15 +67,26 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={999}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.hours")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => hoursHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.hours")} +`}
onClick={() => hoursHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
<Group>
<Text>{t("widget.dnsHoleControls.controls.minutes")}</Text>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.decrement()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} −`}
onClick={() => minutesHandlers.current?.decrement()}
>
–
</ActionIcon>
<NumberInput
@@ -77,9 +97,15 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
max={59}
min={0}
step={1}
aria-label={t("widget.dnsHoleControls.controls.minutes")}
styles={{ input: { width: rem(54), textAlign: "center" } }}
/>
<ActionIcon size={35} variant="default" onClick={() => minutesHandlers.current?.increment()}>
<ActionIcon
size={44}
variant="default"
aria-label={`${t("widget.dnsHoleControls.controls.minutes")} +`}
onClick={() => minutesHandlers.current?.increment()}
>
+
</ActionIcon>
</Group>
@@ -91,7 +117,7 @@ const TimerModal = ({ opened, close, selectedIntegrationIds, disableDns }: Timer
variant="light"
color="red"
leftSection={<IconClockPause size={20} />}
h="2rem"
mih={44}
w="12rem"
onClick={handleSetTimer}
>
@@ -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<DnsSummaryResult["summary"]>;
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"
>
<Box pos="absolute" top={4} right={4} style={{ zIndex: 2 }}>
<Group gap={0}>
<IntegrationErrorIndicator results={summaryResults} />
<WidgetQueryErrorIndicator error={summaryQuery.error} label={t("widget.dnsHoleControls.name")} />
</Group>
</Box>
{controlAllButtonsVisible && (
<Flex className="dns-hole-controls-buttons" gap="sm">
<Tooltip label={t("widget.dnsHoleControls.controls.enableAll")}>
@@ -280,7 +260,7 @@ export default function DnsHoleControlsWidget({
interface ControlsCardProps {
integrationsWithInteractions: string[];
toggleDns: (integrationId: string) => Promise<void>;
data: AvailableDnsSummaryResult;
data: RouterOutputs["widget"]["dnsHole"]["summary"][number];
setSelectedIntegrationIds: (integrationId: string[]) => void;
open: () => void;
t: TranslationFunction;
@@ -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() {
+7
View File
@@ -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() {
@@ -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<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const healthData =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
[];
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(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 = (
<Group gap={0}>
<IntegrationErrorIndicator results={healthResults} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.healthMonitoring.name")} />
</Group>
);
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (healthData.length === 0) {
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
}
if (healthData.length === 0) return <WidgetEmptyState />;
const Container = withScrollArea ? ScrollArea : Box;
return (
<Container h={withScrollArea ? "100%" : undefined} pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Container h={withScrollArea ? "100%" : undefined}>
<Stack mih="100%" gap="sm" className="health-monitoring">
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
const filteredFileSystem = filterStorageVolumes(
@@ -174,18 +145,18 @@ export const SystemHealthMonitoring = ({
</Modal>
</Box>
<Flex className="health-monitoring-information-card-elements" justify="center" align="center" wrap="wrap">
{showCpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{showCpu && (
{options.cpu && <CpuRing cpuUtilization={healthInfo.cpuUtilization} isTiny={isTiny} />}
{options.cpu && (
<CpuTempRing fahrenheit={options.fahrenheit} cpuTemp={healthInfo.cpuTemp} isTiny={isTiny} />
)}
{showMemory && (
{options.memory && (
<MemoryRing
available={healthInfo.memAvailableInBytes}
used={healthInfo.memUsedInBytes}
isTiny={isTiny}
/>
)}
{showGpu &&
{options.gpu &&
healthInfo.gpu.map((gpu) => (
<GpuRing key={gpu.gpuId} gpu={gpu} isTiny={isTiny} fahrenheit={options.fahrenheit} />
))}
@@ -195,7 +166,7 @@ export const SystemHealthMonitoring = ({
<SystemInformationList healthInfo={healthInfo} memoryUsage={memoryUsage} t={t} compact />
</Card>
)}
{showFileSystem &&
{options.fileSystem &&
disksData.map((disk) => {
return (
<Card
@@ -271,9 +242,7 @@ export const SystemHealthMonitoring = ({
);
};
type HealthInfo = NonNullable<
RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"]
>;
type HealthInfo = RouterOutputs["widget"]["healthMonitoring"]["getSystemHealthStatus"][number]["healthInfo"];
const SystemInformationList = ({
healthInfo,
@@ -316,14 +285,6 @@ const SystemInformationList = ({
{healthInfo.loadAverage["5min"]}% / {healthInfo.loadAverage["15min"]}%
</List.Item>
)}
<List.Item className="health-monitoring-information-updates" icon={<IconPackages size={compact ? 18 : 30} />}>
{t("widget.healthMonitoring.popover.updatesAvailable", { count: healthInfo.availablePkgUpdates })}
</List.Item>
<List.Item className="health-monitoring-information-reboot" icon={<IconRefreshAlert size={compact ? 18 : 30} />}>
{healthInfo.rebootRequired
? t("widget.healthMonitoring.popover.rebootRequired")
: t("widget.healthMonitoring.popover.rebootNotRequired")}
</List.Item>
</List>
);
@@ -13,6 +13,7 @@ const createOptions = () =>
}));
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
supportsAdvancedFocus: false,
icon: IconReportSearch,
contextActions: ({ widgetRuntimeRef, context }) => [
{
+12 -16
View File
@@ -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<NonNullable<StreamSession["currentlyPlaying"]>["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<string | null>(null);
const columns = useMemo<MRT_ColumnDef<StreamSession>[]>(() => {
const result: MRT_ColumnDef<StreamSession>[] = [
@@ -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 (
<Stack gap={4} align="flex-start">
@@ -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 })}
</Badge>
<Group ml="auto">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
</Group>
)}
{!isAdvanced && (hasFailedIntegrations || currentStreamsQuery.error) && (
{!isAdvanced && hasFailedIntegrations && (
<Group px="xs" justify="flex-end">
<WidgetQueryErrorIndicator error={currentStreamsQuery.error} label={t("name")} />
<IntegrationErrorIndicator results={currentStreams} />
</Group>
)}
@@ -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(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
.replace(/<script\b[^>]*>[\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 <WidgetMobileSummary value={excerpt || "—"} label={t("name")} />;
}
return <Notebook {...props} />;
}
+6
View File
@@ -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) => ({
+11 -38
View File
@@ -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 (
<Box className="homarr-notebook" h="100%" onDoubleClick={handleDoubleClick}>
@@ -384,9 +375,9 @@ export function Notebook({
>
<RichTextEditor.Toolbar
style={{
display: display.showToolbar ? "flex" : "none",
maxHeight: display.toolbarMaxHeight,
overflowY: display.toolbarMaxHeight === undefined ? undefined : "auto",
display: isEditing && !isSaving && options.showToolbar === true ? "flex" : "none",
maxHeight: height < 180 ? "4.5rem" : "10rem",
overflowY: "auto",
}}
>
<RichTextEditor.ControlsGroup>
@@ -514,7 +505,7 @@ export function Notebook({
<RichTextEditor.Content />
</ScrollArea>
</RichTextEditor>
{(saveError || display.showDocumentStats) && (
{(saveError || height >= 180) && (
<Group pos="absolute" bottom={4} right={8} gap="xs" style={{ pointerEvents: saveError ? undefined : "none" }}>
{saveError && (
<Tooltip label={saveError} multiline>
@@ -530,7 +521,7 @@ export function Notebook({
</Text>
</Tooltip>
)}
{display.showDocumentStats && (
{height >= 180 && (
<Text size="xs" c="dimmed">
{t("widget.notebook.documentStats", documentStats)}
</Text>
@@ -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 (
<RichTextEditor.Control
@@ -915,18 +897,9 @@ function ListIndentDecrease() {
editor?.chain().focus().liftListItem(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 (
<RichTextEditor.Control
@@ -6,6 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("paperlessNgx", {
supportsAdvancedFocus: false,
icon: IconFileText,
queryKey: [["widget", "paperlessNgx"]],
refetchInterval: null,
supportedIntegrations: ["paperlessNgx"],
integrationsRequired: true,
+2
View File
@@ -9,6 +9,8 @@ import { patchmonOptionsSuperRefine } from "./threshold-validation";
export const { definition, componentLoader } = createWidgetDefinition("patchmon", {
supportsAdvancedFocus: true,
icon: IconShieldCheck,
mobile: { supportsDetailView: true },
queryKey: [["widget", "patchmon"]],
supportedIntegrations: ["patchmon"],
integrationsRequired: true,
maxIntegrations: 1,
+1 -1
View File
@@ -13,7 +13,7 @@ const relativeDateSchema = z
.or(z.literal(""));
export const { definition, componentLoader } = createWidgetDefinition("releases", {
supportsAdvancedFocus: true,
supportsAdvancedFocus: false,
icon: IconRocket,
queryKey: [["widget", "releases", "getLatest"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { itemId: scope.itemId }),
+10 -77
View File
@@ -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 <WidgetQueryLoadingState />;
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 (
<Flex direction="column" h="100%" p="xs">
{warning && (
<Alert role="presentation" color="orange" icon={<IconAlertTriangle aria-hidden size={16} />} p="xs">
<output>{warning}</output>
</Alert>
)}
<WidgetEmptyState />
</Flex>
);
}
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}
>
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
{feedEntry.enclosure !== undefined && options.showPosterImage && !isTiny && (
<Image
className={classes.poster}
src={feedEntry.enclosure}
@@ -137,29 +110,18 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
)}
<Flex gap={isRoomy ? "sm" : 6} direction="column" w="100%" miw={0}>
<Text
dir={languageDir}
fz={isRoomy ? "md" : "sm"}
fw={600}
lh={1.25}
lineClamp={isAdvanced ? undefined : 2}
>
<Text dir={languageDir} fz={isRoomy ? "md" : "sm"} fw={600} lh={1.25} lineClamp={2}>
{feedEntry.title}
</Text>
{entryDisplay.showDescription && feedEntry.description && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={entryDisplay.descriptionLineClamp}>
{!options.hideDescription && feedEntry.description && !isTiny && (
<Text dir={languageDir} c="dimmed" size="sm" lineClamp={descriptionLines}>
{feedDescriptionToText(feedEntry.description)}
</Text>
)}
<InfoDisplay
source={entryDisplay.showSource ? getHostname(feedEntry.feedUrl) : undefined}
source={!isDense ? getHostname(feedEntry.feedUrl) : undefined}
date={feedEntry.published ? dayjs(feedEntry.published).fromNow() : undefined}
timestamp={
isAdvanced && feedEntry.published
? dayjs(feedEntry.published).format("YYYY-MM-DD HH:mm:ss Z")
: undefined
}
/>
</Flex>
</Group>
@@ -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 }) => (
<Group gap={5} align="center" wrap={timestamp ? "wrap" : "nowrap"}>
const InfoDisplay = ({ date, source }: { date?: string; source?: string }) => (
<Group gap={5} align="center" wrap="nowrap">
{date && <IconClock size="1rem" color="var(--mantine-color-dimmed)" />}
{date && (
<Text size="xs" c="dimmed">
{date}
</Text>
)}
{timestamp && <Text c="dimmed">•</Text>}
{timestamp && (
<Text size="xs" c="dimmed" ff="monospace">
{timestamp}
</Text>
)}
{(date || timestamp) && source && <Text c="dimmed">•</Text>}
{date && source && <Text c="dimmed">•</Text>}
{source && (
<Text size="xs" c="dimmed" truncate="end">
{source}
+1 -1
View File
@@ -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) =>
+1 -1
View File
@@ -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) =>
+43 -103
View File
@@ -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}
</Text>
)}
{isAdvanced && freeText && freeText !== displayText && (
<Text size="xs" c="dimmed">
{freeText}
</Text>
)}
{isAdvanced && (
<Text size="xs" c={healthy ? "dimmed" : "red"}>
{t("widget.systemDisks.status.smart")}: {smartStatus?.trim() || "—"}
</Text>
)}
</div>
<div style={{ flexShrink: 0 }}>
{showTemperature && (hasTemperature || isAdvanced) ? <Text size="sm">{temperatureText}</Text> : null}
{showTemperature && temperature !== null && temperature !== undefined ? (
<Text size="sm">{temperature}°C</Text>
) : null}
</div>
</Group>
<Box
@@ -189,32 +169,12 @@ export default function SystemResources({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"systemDisks">) {
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<typeof entry.healthInfo> } => entry.healthInfo !== null,
);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("widget.systemDisks.name")} />
</Group>
);
const emptyState = (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
const data =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery(queryInput)) ?? [];
if (isInitialWidgetQueryPending(healthQuery)) return <WidgetQueryLoadingState />;
if (data.length === 0) return emptyState;
if (data.length === 0) return <WidgetEmptyState />;
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 <WidgetEmptyState />;
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 (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p="xs">
{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 (
<SystemDiskCard
key={`${integrationId}:${item.deviceName}`}
deviceName={item.deviceName}
percentage={item.percentage}
displayText={
isAdvanced ? advancedDisplayTexts.percentage : getDisplayText(item, options.displayMode, freeText)
}
temperature={isAdvanced || options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
showBackgroundBar={isAdvanced || options.showBackgroundBar}
integrationName={isAdvanced || data.length > 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)}
/>
);
})}
</SimpleGrid>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={columns} spacing="xs" p="xs">
{disks.map(({ integrationId, integrationName, item, smartItem }) => (
<SystemDiskCard
key={`${integrationId}:${item.deviceName}`}
deviceName={item.deviceName}
percentage={item.percentage}
displayText={getDisplayText(item, options.displayMode)}
temperature={options.showTemperatureIfAvailable ? smartItem?.temperature : undefined}
healthy={smartItem?.healthy ?? true} // fall back to healthy if no information is available
showBackgroundBar={options.showBackgroundBar}
integrationName={data.length > 1 ? integrationName : undefined}
secondaryText={getAbsoluteText(item)}
showSecondaryText={height >= 160 && cellWidth >= 260}
showTemperature={height >= 100 && cellWidth >= 220}
/>
))}
</SimpleGrid>
</ScrollArea>
);
}
@@ -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"],
@@ -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<typeof entry.healthInfo> } => 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<Record<string, ChartItem[]>>({});
const previousIntegrationKey = useRef(integrationKey);
const queryIndicators = (
<Group gap={0}>
<IntegrationErrorIndicator results={results} />
<WidgetQueryErrorIndicator error={healthQuery.error} label={t("name")} />
</Group>
);
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 (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<WidgetEmptyState />
</Box>
);
}
if (data.length === 0) return <WidgetEmptyState />;
if (!isAdvanced && data.length === 1 && data[0]) {
return (
<Box h="100%" p="xs" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<Box h="100%" p="xs">
{renderCharts(data[0], width, Math.max(0, height - 20))}
</Box>
);
@@ -182,23 +133,17 @@ export default function SystemResources({
const compactPanelHeight = Math.max(160, Math.floor(height / Math.min(data.length, 2)));
return (
<Box h="100%" pos="relative">
<Box pos="absolute" top={4} right={8} style={{ zIndex: 2 }}>
{queryIndicators}
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<SimpleGrid cols={panelColumns} spacing="xs" p="xs">
{data.map((entry) => renderCharts(entry, panelWidth, isAdvanced ? height : compactPanelHeight))}
</SimpleGrid>
</ScrollArea>
);
}
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 = ({
<SystemResourceCPUChart
cpuUsageOverTime={items.map((item) => item.cpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -264,7 +202,7 @@ const SystemCharts = ({
memoryUsageOverTime={items.map((item) => item.memory)}
totalCapacityInBytes={memoryCapacityInBytes}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -273,7 +211,7 @@ const SystemCharts = ({
<SystemResourceGPUChart
gpuUsageOverTime={items.map((item) => item.gpu)}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
)}
@@ -284,13 +222,13 @@ const SystemCharts = ({
usageOverTime={networkItems.map((network) => network.down)}
isUp={false}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
<NetworkTrafficChart
usageOverTime={networkItems.map((network) => network.up)}
isUp
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Group>
) : (
@@ -298,7 +236,7 @@ const SystemCharts = ({
<CombinedNetworkTrafficChart
usageOverTime={networkItems}
hasShadow={options.hasShadow}
labelDisplayMode={labelDisplayMode}
labelDisplayMode={options.labelDisplayMode}
/>
</Box>
))}
+28 -53
View File
@@ -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 <WidgetEmptyState />;
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
</div>
);
if (displayMode === "compact") {
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
{summaryContent}
</Box>
);
}
if (displayMode === "compact") return summaryContent;
const monitorList = (
<Stack gap="xs">
@@ -270,7 +257,7 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
<Text size="sm" fw={600} truncate>
{monitor.name}
</Text>
{(isAdvanced || successfulDashboardData.length > 1) && (
{dashboardData.length > 1 && (
<Text size="xs" c="dimmed">
{monitor.integrationName}
</Text>
@@ -287,32 +274,20 @@ function UptimeKumaContent({ integrationIds, options, width, height, displayMode
if (width < 760) {
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
</Box>
<ScrollArea h="100%">
<Stack gap="lg" p="md">
<Box h={Math.max(280, Math.min(420, height - 120))}>{summaryContent}</Box>
{monitorList}
</Stack>
</ScrollArea>
);
}
return (
<Box h="100%" pos="relative">
<Group pos="absolute" top={4} right={8} gap={0} style={{ zIndex: 2 }}>
<IntegrationErrorIndicator results={dashboardData} />
<WidgetQueryErrorIndicator error={dashboardQuery.error} label={t("name")} />
</Group>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
</Box>
<SimpleGrid cols={2} spacing="lg" h="100%" p="md" style={{ gridTemplateRows: "minmax(0, 1fr)" }}>
{summaryContent}
<ScrollArea h="100%">{monitorList}</ScrollArea>
</SimpleGrid>
);
}
+2
View File
@@ -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(() => ({}));