feat: refine mobile dashboard experience
This commit is contained in:
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>
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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))
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
);
|
||||
}),
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
·
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 }) => [
|
||||
{
|
||||
|
||||
@@ -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,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) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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(() => ({}));
|
||||
|
||||
Reference in new issue
Block a user