feat: modernize widget experience

This commit is contained in:
Thomas Camlong authored and ajnart committed 2026-08-14 11:07:08 +02:00
1 parent a607dff50a
commit 836a4cd46b
148 files changed
+3598 -1584

No files matched your search

@@ -34,7 +34,7 @@ This widget displays time-series charts for a selected Beszel system, including
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -48,7 +48,7 @@ import mediaRequestList from "./img/list.png";
- Hover over the widget to reveal a **search button** that opens Spotlight in media request search mode, scoped to the widget's integrations.
- A warning indicator identifies unavailable integrations without hiding requests returned by healthy integrations.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -1
View File
@@ -26,7 +26,7 @@ import editOn from "./img/edit.png";
<img src={editOn} alt="notebook edit-on" width={128 * 3} />
</div>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -1
View File
@@ -87,7 +87,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
+1 -1
View File
@@ -35,7 +35,7 @@ GitHub, Docker Hub, GitLab, NPM, Codeberg, GitHub Container Registry, Quay, and
For GitHub Container Registry, use `owner/package` identifiers such as `homarr-labs/homarr`.
Full image references such as `ghcr.io/homarr-labs/homarr:latest` are also accepted.
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -44,7 +44,7 @@ This widget displays filesystem and SMART disk usage for supported server and NA
]}
/>
<WidgetInteractionGuide />
<WidgetInteractionGuide advancedView={false} />
### Adding the widget
@@ -1,12 +1,13 @@
import { IconArrowsMaximize, IconLayoutGrid, IconMouse2, IconPlus } from "@tabler/icons-react";
const interactions = [
{
icon: IconArrowsMaximize,
title: "Advanced view",
description:
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
},
const advancedInteraction = {
icon: IconArrowsMaximize,
title: "Advanced view",
description:
"Hold Shift while hovering for an in-place preview, press Shift + Enter, or choose Open advanced view from the widget menu.",
} as const;
const standardInteractions = [
{
icon: IconMouse2,
title: "Widget actions",
@@ -27,27 +28,35 @@ const interactions = [
},
] as const;
export const WidgetInteractionGuide = () => (
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
<span>Power-user controls</span>
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
Hide shortcuts
</span>
</summary>
<div className="grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 xl:grid-cols-4 dark:border-[#333]">
{interactions.map(({ icon: Icon, title, description }) => (
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
<div className="font-semibold">{title}</div>
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
</div>
))}
</div>
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed. Touch
devices expose a maximize control; app tiles stay compact.
</p>
</details>
);
export const WidgetInteractionGuide = ({ advancedView = true }: { advancedView?: boolean }) => {
const interactions = advancedView ? [advancedInteraction, ...standardInteractions] : standardInteractions;
return (
<details className="group my-6 overflow-hidden rounded-xl border border-solid border-[#e5e7eb] bg-slate-50/70 dark:border-[#333] dark:bg-gray-900/40">
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 px-4 py-3 font-semibold marker:hidden">
<span>Power-user controls</span>
<span className="text-xs font-normal text-[#696969] group-open:hidden dark:text-[#999]">Show shortcuts</span>
<span className="hidden text-xs font-normal text-[#696969] group-open:inline dark:text-[#999]">
Hide shortcuts
</span>
</summary>
<div
className={`grid gap-3 border-0 border-t border-solid border-[#e5e7eb] p-4 md:grid-cols-2 ${advancedView ? "xl:grid-cols-4" : "xl:grid-cols-3"} dark:border-[#333]`}
>
{interactions.map(({ icon: Icon, title, description }) => (
<div key={title} className="rounded-lg bg-white p-3 shadow-sm dark:bg-gray-950">
<Icon aria-hidden size={20} stroke={1.5} className="mb-2 stroke-red-500" />
<div className="font-semibold">{title}</div>
<p className="mb-0 mt-1 text-sm text-[#696969] dark:text-[#999]">{description}</p>
</div>
))}
</div>
{advancedView && (
<p className="m-0 px-4 pb-4 text-xs text-[#696969] dark:text-[#999]">
Advanced view reuses the widget&apos;s live data and cache. The rest of the board stays visible but dimmed.
Touch devices expose a maximize control; compact-only widgets adapt in place instead.
</p>
)}
</details>
);
};
@@ -47,7 +47,6 @@ interface CustomWidgetFormProps {
initialValues?: Partial<CustomWidgetFormValues>;
definitionId?: string;
}
const sectionLinks = [
["general", "section.general", IconSettings],
["sources", "section.sources", IconApi],
@@ -3,6 +3,10 @@
animation: focus-backdrop-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
}
.manualBackdrop {
pointer-events: auto;
}
.surface {
position: fixed;
z-index: 1;
@@ -15,6 +19,25 @@
transform-origin: top left;
}
.manualSurface {
position: fixed;
max-width: none;
max-height: none;
overflow: visible;
background: transparent;
box-shadow: none;
overscroll-behavior: contain;
animation: focus-surface-in 180ms cubic-bezier(0.16, 1, 0.3, 1);
transform-origin: top left;
}
.manualSurface > div {
width: 100%;
height: 100%;
max-height: none !important;
overflow: visible;
}
.backdropClosing {
animation: focus-backdrop-out 180ms ease-in forwards;
}
@@ -32,6 +55,18 @@
height: 100%;
}
.surfacePortalTarget,
.manualSurfaceMount {
width: 100%;
height: 100%;
min-height: 0;
}
.manualSurfaceMount {
overflow: hidden;
border-radius: inherit;
}
.surfaceHostAdvanced {
position: fixed;
inset: 0;
@@ -49,7 +84,7 @@
}
.closeButton {
position: fixed;
position: absolute;
z-index: 2;
pointer-events: auto;
}
@@ -99,7 +134,8 @@
@media (prefers-reduced-motion: reduce) {
.backdrop,
.surface {
.surface,
.manualSurface {
animation: none;
}
}
@@ -16,6 +16,7 @@ interface ActiveFocus {
sourceRect: FocusRect;
activation: "preview" | "manual";
autofocusClose: boolean;
restorePreviewFocus: boolean;
phase: "visible" | "closing";
}
@@ -79,6 +80,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
sourceRect: { left: rect.left, top: rect.top, width: rect.width, height: rect.height },
activation: options.activation ?? "manual",
autofocusClose: options.autofocusClose ?? false,
restorePreviewFocus: options.activation === "preview" && document.activeElement === source,
phase: "visible",
});
},
@@ -118,12 +120,46 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (current?.activation === "preview" && current.itemId !== itemId) {
cancelCloseTimer();
updateActive(null);
if (current.restorePreviewFocus && current.source.isConnected) current.source.focus({ preventScroll: true });
}
startHoverTimer();
},
[cancelCloseTimer, startHoverTimer, updateActive],
);
useEffect(() => {
const handlePointerMove = (event: PointerEvent) => {
const current = activeRef.current;
if (!shiftHeldRef.current || current?.activation !== "preview") return;
const underlyingSource = document
.elementsFromPoint(event.clientX, event.clientY)
.map((element) =>
element instanceof HTMLElement
? element.matches(".grid-stack-item-content")
? element
: element.closest<HTMLElement>(".grid-stack-item-content")
: null,
)
.find((source) => source !== null && source !== current.source);
const itemId = underlyingSource?.closest<HTMLElement>(".grid-stack-item[data-id]")?.dataset.id;
if (!underlyingSource || !itemId) return;
if (underlyingSource.hasAttribute("aria-keyshortcuts")) {
hover(itemId, underlyingSource);
return;
}
hoveredRef.current = null;
cancelHoverTimer();
cancelCloseTimer();
updateActive(null);
};
window.addEventListener("pointermove", handlePointerMove, { capture: true });
return () => window.removeEventListener("pointermove", handlePointerMove, { capture: true });
}, [cancelCloseTimer, cancelHoverTimer, hover, updateActive]);
const leave = useCallback(
(itemId: string) => {
if (hoveredRef.current?.itemId === itemId) hoveredRef.current = null;
@@ -145,7 +181,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape" && activeRef.current) {
const advancedSurface = document.querySelector(`.${classes.surface}`);
const advancedSurface = document.querySelector("[data-advanced-focus-surface]");
if (event.defaultPrevented || event.isComposing || isEscapeOwnedByNestedOverlay(event.target, advancedSurface))
return;
event.preventDefault();
@@ -170,11 +206,11 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
if (activeRef.current?.activation === "preview") close(false);
};
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("keydown", handleKeyDown, { capture: true });
window.addEventListener("keyup", handleKeyUp);
window.addEventListener("blur", handleBlur);
return () => {
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("keydown", handleKeyDown, { capture: true });
window.removeEventListener("keyup", handleKeyUp);
window.removeEventListener("blur", handleBlur);
cancelHoverTimer();
@@ -194,7 +230,7 @@ export const BoardAdvancedFocusProvider = ({ children }: PropsWithChildren) => {
return (
<AdvancedFocusContext.Provider value={value}>
{children}
{active && (
{active?.activation === "preview" && (
<Portal>
<Overlay
fixed
@@ -5,7 +5,7 @@ import { isEscapeOwnedByNestedOverlay } from "./escape";
describe("isEscapeOwnedByNestedOverlay", () => {
test("allows Escape from the advanced surface itself", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
surface.setAttribute("role", "dialog");
const button = document.createElement("button");
surface.append(button);
@@ -44,6 +44,24 @@ describe("isEscapeOwnedByNestedOverlay", () => {
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
});
test("leaves Escape to a visible portalled menu when focus stays in the advanced surface", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "dialog");
const target = document.createElement("button");
surface.append(target);
document.body.append(surface);
const menu = document.createElement("div");
menu.setAttribute("role", "menu");
menu.getClientRects = () => [{ width: 100, height: 100 }] as unknown as DOMRectList;
document.body.append(menu);
expect(isEscapeOwnedByNestedOverlay(target, surface)).toBe(true);
menu.remove();
surface.remove();
});
test("does not mistake the compact widget source for a nested overlay", () => {
const surface = document.createElement("section");
surface.setAttribute("role", "region");
@@ -6,8 +6,14 @@ const escapeOwnerSelector = [
].join(", ");
export const isEscapeOwnedByNestedOverlay = (target: EventTarget | null, advancedSurface: Element | null) => {
if (!(target instanceof Element)) return false;
const escapeOwner = target instanceof Element ? target.closest(escapeOwnerSelector) : null;
if (escapeOwner !== null && escapeOwner !== advancedSurface) return true;
const escapeOwner = target.closest(escapeOwnerSelector);
return escapeOwner !== null && escapeOwner !== advancedSurface;
return Array.from(document.querySelectorAll(escapeOwnerSelector)).some(
(owner) =>
owner !== advancedSurface &&
owner.getAttribute("aria-hidden") !== "true" &&
owner.closest('[aria-hidden="true"]') === null &&
owner.getClientRects().length > 0,
);
};
@@ -0,0 +1,107 @@
// @vitest-environment jsdom
import { act, createElement, Fragment, useRef, useState } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { AdvancedFocusManualSurface } from "./manual-surface";
const sourceRect = { left: 100, top: 120, width: 240, height: 160 };
const rect = { left: 40, top: 80, width: 640, height: 480 };
const closePosition = { left: 688, top: 80 };
describe("AdvancedFocusManualSurface", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
document.querySelectorAll("[data-portal]").forEach((portal) => portal.remove());
vi.unstubAllGlobals();
});
test("portals dialog semantics, traps focus, closes from the backdrop, and restores focus", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal(
"matchMedia",
vi.fn().mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
addListener: vi.fn(),
removeListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const Harness = () => {
const [opened, setOpened] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
return createElement(
Fragment,
null,
createElement("button", { type: "button", onClick: () => setOpened(true) }, "Open advanced view"),
createElement(
AdvancedFocusManualSurface,
{
opened,
phase: "visible",
id: "advanced-focus-test",
label: "Test widget advanced view",
closeLabel: "Close advanced view",
rect,
closePosition,
sourceRect,
radius: "md",
contentRef,
onClose: () => setOpened(false),
},
createElement("button", { type: "button" }, "First widget action"),
createElement("button", { type: "button" }, "Last widget action"),
),
);
};
await act(async () => root?.render(createElement(MantineProvider, null, createElement(Harness))));
const trigger = document.querySelector<HTMLButtonElement>("button");
expect(trigger).not.toBeNull();
trigger?.focus();
await act(async () => trigger?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
const dialog = document.querySelector<HTMLElement>("[role='dialog']");
const closeButton = dialog?.querySelector<HTMLButtonElement>("[aria-label='Close advanced view']");
const actions = dialog?.querySelectorAll<HTMLButtonElement>("button");
expect(dialog?.id).toBe("advanced-focus-test");
expect(dialog?.getAttribute("aria-modal")).toBe("true");
expect(dialog?.closest("[data-portal]")).not.toBeNull();
expect(dialog?.closest(".grid-stack-item")).toBeNull();
expect(document.getElementById(dialog?.getAttribute("aria-labelledby") ?? "")?.textContent).toBe(
"Test widget advanced view",
);
expect(document.activeElement).toBe(closeButton);
const lastAction = actions?.item((actions?.length ?? 1) - 1);
lastAction?.focus();
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Tab", bubbles: true, cancelable: true }));
expect(document.activeElement).toBe(closeButton);
closeButton?.focus();
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true, cancelable: true }),
);
expect(document.activeElement).toBe(lastAction);
const backdrop = document.querySelector<HTMLElement>("[data-advanced-focus-overlay]");
await act(async () => backdrop?.click());
await act(async () => await new Promise((resolve) => setTimeout(resolve, 20)));
expect(document.activeElement).toBe(trigger);
});
});
@@ -0,0 +1,101 @@
"use client";
import type { CSSProperties, PropsWithChildren, Ref } from "react";
import { Modal, VisuallyHidden } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import classes from "./advanced-focus.module.css";
import type { FocusClosePosition, FocusRect } from "./geometry";
interface AdvancedFocusManualSurfaceProps extends PropsWithChildren {
opened: boolean;
phase: "visible" | "closing";
id: string;
label: string;
closeLabel: string;
rect: FocusRect;
closePosition: FocusClosePosition;
sourceRect: FocusRect;
radius: string | number;
contentRef: Ref<HTMLDivElement>;
onClose: () => void;
}
export const AdvancedFocusManualSurface = ({
opened,
phase,
id,
label,
closeLabel,
rect,
closePosition,
sourceRect,
radius,
contentRef,
onClose,
children,
}: AdvancedFocusManualSurfaceProps) => {
const reduceMotion = useReducedMotion();
const transitionDuration = reduceMotion ? 0 : 180;
const geometryStyle = {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
"--focus-translate-x": `${sourceRect.left - rect.left}px`,
"--focus-translate-y": `${sourceRect.top - rect.top}px`,
"--focus-scale-x": rect.width > 0 ? sourceRect.width / rect.width : 1,
"--focus-scale-y": rect.height > 0 ? sourceRect.height / rect.height : 1,
} as CSSProperties;
const relativeClosePosition = {
left: closePosition.left - rect.left,
top: closePosition.top - rect.top,
};
return (
<Modal.Root
opened={opened}
onClose={onClose}
radius={radius}
padding={0}
shadow="none"
zIndex="calc(var(--mantine-z-index-modal) - 1)"
closeOnEscape={false}
closeOnClickOutside
lockScroll
trapFocus
returnFocus
removeScrollProps={{ allowPinchZoom: true }}
transitionProps={{ duration: transitionDuration, transition: "fade" }}
>
<Modal.Overlay
data-advanced-focus-overlay
backgroundOpacity={0.38}
blur={0}
className={`${classes.backdrop} ${classes.manualBackdrop} ${
phase === "closing" ? classes.backdropClosing : ""
}`}
/>
<Modal.Content
ref={contentRef}
id={id}
data-advanced-focus-surface
className={`${classes.manualSurface} ${phase === "closing" ? classes.surfaceClosing : ""}`}
style={geometryStyle}
>
<Modal.Title>
<VisuallyHidden>{label}</VisuallyHidden>
</Modal.Title>
<Modal.CloseButton
className={classes.closeButton}
variant="default"
size={44}
aria-label={closeLabel}
aria-controls={id}
style={relativeClosePosition}
/>
{children}
</Modal.Content>
</Modal.Root>
);
};
@@ -13,10 +13,8 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (delta === 0) return false;
let current = target instanceof Element ? target : null;
let startedInScrollContainer = false;
while (current && root.contains(current)) {
const isCurrentScrollContainer = isVerticalScrollContainer(current);
startedInScrollContainer ||= isCurrentScrollContainer;
if (isCurrentScrollContainer && canScrollVertically(current, delta)) {
current.scrollTop += delta;
return true;
@@ -24,8 +22,9 @@ export const redirectShiftWheel = (root: HTMLElement, target: EventTarget | null
if (current === root) break;
current = current.parentElement;
}
if (startedInScrollContainer) return false;
// A nested control can reach its edge before the widget's main Mantine viewport.
// Hand the gesture to that viewport instead of making Shift+wheel feel stuck.
const viewport = [...root.querySelectorAll<HTMLElement>("[data-scrollbars='y'], [data-scrollbars='xy']")].find(
(element) => isVerticalScrollContainer(element) && canScrollVertically(element, delta),
);
@@ -28,7 +28,6 @@ export const BoardItemMenu = (props: BoardItemMenuProps) => {
return <BoardItemMenuInner {...props} />;
};
const BoardItemMenuInner = ({ item, definition, resetErrorBoundary }: BoardItemMenuProps) => {
const { data: session } = useSession();
const canDuplicate = item.kind !== "customApi" || (session?.user.permissions.includes("admin") ?? false);
+117 -53
View File
@@ -19,6 +19,7 @@ import {
import type { SqliteDatabase } from "./shared/e2e-db";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
import { createHomarrContainer } from "./shared/create-homarr-container";
import { loginAsync } from "./shared/login";
import { seedAdminUserAsync } from "./shared/seed-admin-user";
const ownerCredentials = { username: "owner", password: "Comp(exP4sswOrd" };
@@ -68,11 +69,12 @@ describe("Board advanced interactions", () => {
try {
const widget = page.locator(".grid-stack-item[data-kind='clock'] > .grid-stack-item-content").first();
const compactSurface = widget.locator(".clock-wrapper");
const advancedSurface = page.getByRole("region", { name: "Date and time advanced view" });
const iframeAdvancedSurface = page.getByRole("region", { name: "iFrame advanced view" });
const compactSurface = page.locator(".clock-wrapper").first();
const previewSurface = page.getByRole("region", { name: "Date and time advanced view" });
const manualSurface = page.getByRole("dialog", { name: "Date and time advanced view" });
const bookmarksPreviewSurface = page.getByRole("region", { name: "Bookmarks advanced view" });
const dimmingOverlay = page.locator("[data-advanced-focus-overlay]");
const otherWidget = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
const otherWidget = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
await compactSurface.evaluate((element) => {
element.setAttribute("data-lifecycle-probe", "same-instance");
@@ -80,42 +82,54 @@ describe("Board advanced interactions", () => {
await widget.focus();
await page.keyboard.down("Shift");
await widget.hover();
await expect(advancedSurface).toBeVisible({ timeout: 2_000 });
await expect(advancedSurface).toHaveCSS("animation-name", "none");
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
await expect(previewSurface).toHaveCSS("animation-name", "none");
const compactBounds = await widget.boundingBox();
const advancedBounds = await advancedSurface.boundingBox();
const advancedBounds = await previewSurface.boundingBox();
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
await expect(advancedSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(previewSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toBeVisible();
await expect(dimmingOverlay).toHaveCSS("pointer-events", "none");
await expect(otherWidget).toBeVisible();
await expect(widget).toBeFocused();
await advancedSurface.click({ button: "right", position: { x: 80, y: 80 } });
await previewSurface.click({ button: "right", position: { x: 80, y: 80 } });
const portalledMenuItem = page.getByRole("menuitem", { name: "Open advanced view" });
await expect(portalledMenuItem).toBeVisible();
await portalledMenuItem.hover();
await expect(advancedSurface).toBeVisible();
await expect(previewSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(portalledMenuItem).toBeHidden();
await expect(advancedSurface).toBeVisible();
await expect(previewSurface).toBeVisible();
await otherWidget.hover();
await expect(advancedSurface).toBeHidden();
await expect(iframeAdvancedSurface).toBeVisible({ timeout: 2_000 });
const otherBounds = await otherWidget.boundingBox();
expect(otherBounds).not.toBeNull();
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
await expect(previewSurface).toBeHidden();
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
await page.keyboard.up("Shift");
await expect(iframeAdvancedSurface).toBeHidden();
await expect(bookmarksPreviewSurface).toBeHidden();
await expect(dimmingOverlay).toBeHidden();
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(widget).toBeFocused();
await page.keyboard.press("Shift+Enter");
await expect(advancedSurface).toBeVisible();
await expect(manualSurface).toBeVisible();
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
expect(await manualSurface.evaluate((element) => element.closest(".grid-stack-item"))).toBeNull();
await expect(manualSurface.locator(".clock-wrapper")).toHaveAttribute("data-lifecycle-probe", "same-instance");
await expect(dimmingOverlay).toHaveCSS("pointer-events", "auto");
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).toBe("hidden");
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
await page.keyboard.press("Shift+Tab");
await expect(page.getByRole("button", { name: "Close advanced view" })).toBeFocused();
await expect(page.getByRole("button", { name: /advanced view is pinned|keep advanced view open/i })).toHaveCount(
0,
);
await advancedSurface.evaluate((element) => {
await manualSurface.evaluate((element) => {
const probe = document.createElement("div");
probe.dataset.shiftScrollProbe = "true";
probe.style.cssText = "height:80px;overflow-y:auto";
@@ -123,11 +137,19 @@ describe("Board advanced interactions", () => {
probe.addEventListener("wheel", (event) => event.stopPropagation());
element.append(probe);
const unrelated = probe.cloneNode(true) as HTMLDivElement;
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
mainViewport.innerHTML = '<div style="height:600px"></div>';
element.append(mainViewport);
const unrelated = document.createElement("div");
unrelated.dataset.shiftScrollProbe = "unrelated";
unrelated.style.cssText = "height:80px;overflow-y:hidden";
unrelated.innerHTML = '<div style="height:600px"></div>';
element.append(unrelated);
});
const scrollProbe = advancedSurface.locator("[data-shift-scroll-probe='true']");
const scrollProbe = manualSurface.locator("[data-shift-scroll-probe='true']");
await page.keyboard.down("Shift");
const pixelEventCancelled = await scrollProbe.evaluate(
(element) =>
@@ -156,56 +178,78 @@ describe("Board advanced interactions", () => {
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
);
});
expect(await manualSurface.locator("[data-scrollbars='y']").evaluate((element) => element.scrollTop)).toBe(120);
expect(
await advancedSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
await manualSurface.locator("[data-shift-scroll-probe='unrelated']").evaluate((element) => element.scrollTop),
).toBe(0);
await manualSurface.click({ button: "right", position: { x: 80, y: 80 } });
await expect(portalledMenuItem).toBeVisible();
await page.keyboard.press("Escape");
await expect(advancedSurface).toBeHidden();
await expect(portalledMenuItem).toBeHidden();
await expect(manualSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(manualSurface).toBeHidden();
await expect(widget).toBeFocused();
expect(await page.evaluate(() => getComputedStyle(document.body).overflow)).not.toBe("hidden");
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(advancedSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(advancedSurface).toBeHidden();
await expect(manualSurface).toBeVisible();
const viewport = page.viewportSize();
if (!viewport) throw new Error("Board page has no viewport");
await page.mouse.click(4, viewport.height - 4);
await expect(manualSurface).toBeHidden();
await expect(widget).toBeFocused();
const appWidget = page.locator(".grid-stack-item[data-kind='app'] > .grid-stack-item-content").first();
await expect(appWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await appWidget.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.down("Shift");
await appWidget.hover();
await expect(advancedSurface).toBeHidden();
await page.keyboard.up("Shift");
for (const compactOnlyKind of ["app", "iframe"]) {
const compactOnlyWidget = page
.locator(`.grid-stack-item[data-kind='${compactOnlyKind}'] > .grid-stack-item-content`)
.first();
await expect(compactOnlyWidget).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await compactOnlyWidget.dispatchEvent("contextmenu", { button: 2 });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.down("Shift");
await compactOnlyWidget.hover({ position: { x: 4, y: 4 } });
await expect(previewSurface).toBeHidden();
await expect(dimmingOverlay).toBeHidden();
await page.keyboard.up("Shift");
}
} finally {
await context.close();
}
}, 60_000);
test("preserves iframe browsing state and dismisses focus when a responsive layout remounts", async () => {
test("keeps compact-only iframe state and dismisses retained focus when a responsive layout remounts", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const iframeSlot = page.locator(".grid-stack-item[data-kind='iframe'] > .grid-stack-item-content").first();
const iframe = iframeSlot.locator("iframe");
await iframe.evaluate((element) => {
element.srcdoc = '<label>State <input id="state" value="initial"></label>';
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
(element as HTMLIFrameElement).dataset.advancedFocusProbe = "true";
});
const frame = page.frameLocator(".grid-stack-item[data-kind='iframe'] iframe");
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
await frame.locator("#state").fill("changed");
await iframeSlot.focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeVisible();
await expect(frame.locator("#state")).toHaveValue("changed");
await expect(iframeSlot).not.toHaveAttribute("aria-keyshortcuts", "Shift+Enter");
await iframeSlot.dispatchEvent("contextmenu", { button: 2 });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
await page.keyboard.press("Escape");
await page.keyboard.down("Shift");
await iframeSlot.hover({ position: { x: 4, y: 4 } });
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toHaveCount(0);
await page.keyboard.up("Shift");
await expect(frame.locator("#state")).toHaveValue("changed");
await iframeSlot.focus();
const bookmarksSlot = page.locator(".grid-stack-item[data-kind='bookmarks'] > .grid-stack-item-content").first();
await bookmarksSlot.focus();
await page.keyboard.press("Shift+Enter");
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeVisible();
await page.setViewportSize({ width: 800, height: 900 });
await expect(page.getByRole("region", { name: "iFrame advanced view" })).toBeHidden();
await expect(page.getByRole("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
await expect(page.locator(".grid-stack-item[data-kind='iframe'] iframe")).toBeVisible();
} finally {
await context.close();
@@ -229,7 +273,7 @@ describe("Board advanced interactions", () => {
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
const surfaceBounds = await page.getByRole("region", { name: "Date and time advanced view" }).boundingBox();
const surfaceBounds = await page.getByRole("dialog", { name: "Date and time advanced view" }).boundingBox();
const contentBounds = await page.locator(".clock-widget-container").boundingBox();
expect(surfaceBounds).not.toBeNull();
expect(contentBounds).not.toBeNull();
@@ -240,7 +284,7 @@ describe("Board advanced interactions", () => {
);
}
await closeButton.click();
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeHidden();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
} finally {
await context.close();
}
@@ -304,7 +348,7 @@ describe("Board advanced interactions", () => {
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(page.getByRole("region", { name: "Date and time advanced view" })).toBeVisible();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
await page.keyboard.press("Escape");
await expect(grid).not.toHaveAttribute("aria-label", "Add item here");
@@ -325,14 +369,8 @@ const openBoardAsync = async (
const context = await browser.newContext({ reducedMotion: "reduce", viewport });
const page = await context.newPage();
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(credentials.username);
await page.locator("#password").fill(credentials.password);
const loginRedirect = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
await page.locator("css=button[type='submit']").click();
await loginRedirect;
await page.goto(`${baseUrl}/boards/${boardName}`);
await expect(page.locator(".grid-stack-item[data-kind='clock'] .clock-wrapper").first()).toBeVisible({
await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` });
await expect(page.locator(".clock-wrapper").first()).toBeVisible({
timeout: 15_000,
});
@@ -368,6 +406,7 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
const desktopLayoutId = createId();
const sectionId = createId();
const clockItemId = createId();
const bookmarksItemId = createId();
const iframeItemId = createId();
const appItemId = createId();
const appId = createId();
@@ -392,6 +431,22 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
options: stringify({ is24HourFormat: true, showSeconds: false, showDate: true }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
{
id: bookmarksItemId,
kind: "bookmarks",
boardId,
options: stringify({
title: "",
layout: "column",
hideTitle: false,
hideIcon: false,
hideHostname: false,
openNewTab: true,
withBorder: false,
items: [appId],
}),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
{
id: iframeItemId,
kind: "iframe",
@@ -418,6 +473,15 @@ const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) =>
width: 4,
height: 3,
},
{
itemId: bookmarksItemId,
sectionId,
layoutId,
xOffset: 0,
yOffset: 3,
width: 4,
height: 3,
},
{
itemId: iframeItemId,
sectionId,
+51
View File
@@ -0,0 +1,51 @@
import type { Page } from "@playwright/test";
interface LoginInput {
page: Page;
baseUrl: string;
credentials: {
username: string;
password: string;
};
destination?: string;
}
export const loginAsync = async ({ page, baseUrl, credentials, destination = "/" }: LoginInput) => {
const destinationUrl = new URL(destination, baseUrl);
const loginUrl = new URL("/auth/login", baseUrl);
loginUrl.searchParams.set("callbackUrl", destinationUrl.href);
await page.goto(loginUrl.href, { waitUntil: "load" });
await page.waitForFunction(() => {
const form = document.querySelector("form");
return form !== null && Object.keys(form).some((key) => key.startsWith("__reactProps$"));
});
await page.getByLabel("Username").fill(credentials.username);
await page.locator("#password").fill(credentials.password);
await page.locator("button[type='submit']").click();
await waitForAuthenticatedSessionAsync(page, baseUrl);
await page.goto(destinationUrl.href, { waitUntil: "domcontentloaded" });
const currentUrl = new URL(page.url());
if (currentUrl.origin !== destinationUrl.origin || currentUrl.pathname !== destinationUrl.pathname) {
throw new Error("Authenticated destination was not reached");
}
};
const waitForAuthenticatedSessionAsync = async (page: Page, baseUrl: string) => {
const sessionUrl = new URL("/api/auth/session", baseUrl).href;
const deadline = Date.now() + 30_000;
while (Date.now() < deadline) {
const response = await page.request.get(sessionUrl);
if (response.ok()) {
const session = (await response.json()) as { user?: unknown } | null;
if (session?.user !== undefined) return;
}
await page.waitForTimeout(100);
}
throw new Error("Authentication session was not established");
};
@@ -0,0 +1,45 @@
import { TRPCError } from "@trpc/server";
import { parse } from "superjson";
import { z } from "zod/v4";
import type { Session } from "@homarr/auth";
import type { Database } from "@homarr/db";
import { and, eq } from "@homarr/db";
import { boards, items } from "@homarr/db/schema";
import type { BoardPermission } from "@homarr/definitions";
import { throwIfActionForbiddenAsync } from "../board/board-access";
export const customApiItemInputSchema = z.object({
boardId: z.string().min(1).max(100),
itemId: z.string().min(1).max(100),
definitionId: z.string().min(1).max(100),
});
const customApiOptionsSchema = z.object({ definitionId: z.string() }).passthrough();
export async function assertCustomApiItemBindingAsync(
ctx: { db: Database; session: Session | null },
input: z.infer<typeof customApiItemInputSchema>,
permission?: BoardPermission,
) {
const item = await ctx.db.query.items.findFirst({
columns: { options: true },
where: and(eq(items.id, input.itemId), eq(items.boardId, input.boardId), eq(items.kind, "customApi")),
});
if (!item) throwCustomApiWidgetNotFound();
if (permission) await throwIfActionForbiddenAsync(ctx, eq(boards.id, input.boardId), permission);
try {
const options = customApiOptionsSchema.parse(parse<unknown>(item.options));
if (options.definitionId !== input.definitionId) throwCustomApiWidgetNotFound();
} catch (error) {
if (error instanceof TRPCError) throw error;
throwCustomApiWidgetNotFound();
}
}
function throwCustomApiWidgetNotFound(): never {
throw new TRPCError({ code: "NOT_FOUND", message: "Custom API widget not found" });
}
+3 -4
View File
@@ -37,11 +37,10 @@ export const locationRouter = createTRPCRouter({
.input(locationSearchCityInput)
.output(locationSearchCityOutput)
.query(async ({ input }) => {
const url = new URL("https://geocoding-api.open-meteo.com/v1/search");
url.searchParams.set("name", input.query);
const res = await withTimeoutAsync(async (signal) => {
return await fetchWithTrustedCertificatesAsync(
`https://geocoding-api.open-meteo.com/v1/search?name=${input.query}`,
{ signal },
);
return await fetchWithTrustedCertificatesAsync(url.toString(), { signal });
});
return (await res.json()) as z.infer<typeof locationSearchCityOutput>;
}),
@@ -0,0 +1,376 @@
// @vitest-environment node
import { stringify } from "superjson";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { Session } from "@homarr/auth";
import { createId } from "@homarr/common";
import type { Database } from "@homarr/db";
import { eq } from "@homarr/db";
import { boards, boardUserPermissions, customWidgetDefinitions, items, users } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import type { GroupPermissionKey } from "@homarr/definitions";
import { customWidgetRouter } from "../custom-widget/custom-widget-router";
import { customApiRouter } from "../widgets/custom-api";
const createSession = (userId: string, permissions: GroupPermissionKey[] = []): Session => ({
user: { id: userId, permissions, colorScheme: "light" },
expires: new Date().toISOString(),
});
const createCustomWidgetCaller = (db: Database, session: Session | null) =>
customWidgetRouter.createCaller({ db, deviceType: undefined, session });
const createCustomApiCaller = (db: Database, session: Session | null) =>
customApiRouter.createCaller({ db, deviceType: undefined, session });
async function createFixtureAsync(db: Database) {
const ownerId = createId();
const viewerId = createId();
const modifierId = createId();
const unrelatedId = createId();
const adminId = createId();
const boardId = createId();
const itemId = createId();
const definitionId = createId();
await db.insert(users).values(
[ownerId, viewerId, modifierId, unrelatedId, adminId].map((id) => ({
id,
name: id,
})),
);
await db.insert(boards).values({ id: boardId, name: `board-${boardId}`, creatorId: ownerId, isPublic: false });
await db.insert(boardUserPermissions).values([
{ boardId, userId: viewerId, permission: "view" },
{ boardId, userId: modifierId, permission: "modify" },
]);
await db.insert(customWidgetDefinitions).values({
id: definitionId,
name: "Restart service",
description: "Restarts a service",
url: "https://api.example.com/restart",
method: "POST",
displayType: "actionButton",
displayConfig: stringify({
type: "actionButton",
buttonLabel: "Restart",
confirmText: "Continue?",
}),
creatorId: adminId,
});
await db.insert(items).values({
id: itemId,
boardId,
kind: "customApi",
options: stringify({ definitionId, refreshInterval: 30 }),
});
return { ownerId, viewerId, modifierId, unrelatedId, adminId, boardId, itemId, definitionId };
}
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("custom widget definition authorization", () => {
it("restricts the full definition list to administrators", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await expect(createCustomWidgetCaller(db, null).all()).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).all()).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
await expect(createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).all()).resolves.toEqual([
expect.objectContaining({ id: fixture.definitionId, url: "https://api.example.com/restart" }),
]);
});
it("returns only safe definition summaries to board modifiers", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const result = await createCustomWidgetCaller(db, createSession(fixture.modifierId)).available({
boardId: fixture.boardId,
});
expect(result).toEqual([
{
id: fixture.definitionId,
name: "Restart service",
description: "Restarts a service",
iconUrl: null,
displayType: "actionButton",
enabled: true,
},
]);
expect(JSON.stringify(result)).not.toContain("api.example.com");
for (const userId of [fixture.viewerId, fixture.unrelatedId]) {
await expect(
createCustomWidgetCaller(db, createSession(userId)).available({ boardId: fixture.boardId }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
}
});
});
describe("custom widget action authorization", () => {
it("requires an administrator even for users who can view or modify the board", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
await expect(createCustomWidgetCaller(db, null).execute(input)).rejects.toMatchObject({ code: "UNAUTHORIZED" });
for (const userId of [fixture.ownerId, fixture.viewerId, fixture.modifierId, fixture.unrelatedId]) {
await expect(createCustomWidgetCaller(db, createSession(userId)).execute(input)).rejects.toMatchObject({
code: "FORBIDDEN",
});
}
});
it("executes for an administrator only when board, item, and definition are bound", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
await expect(
caller.execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: true, responseInfo: { status: 204 } });
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ method: "POST", redirect: "error", signal: expect.any(AbortSignal) }),
);
});
it.each(["board", "item", "definition"] as const)("rejects a mismatched %s binding", async (mismatch) => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const input = {
boardId: mismatch === "board" ? createId() : fixture.boardId,
itemId: mismatch === "item" ? createId() : fixture.itemId,
definitionId: mismatch === "definition" ? createId() : fixture.definitionId,
};
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute(input),
).rejects.toMatchObject({ code: "NOT_FOUND" });
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects disabled definitions before sending a request", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ enabled: false })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).rejects.toMatchObject({ code: "FORBIDDEN" });
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("custom API outbound request hardening", () => {
it("rejects credential-bearing action URLs without exposing the credential", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({ url: "https://operator:super-secret@private.example.com/restart" })
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/super-secret|private\.example\.com/);
expect(fetchMock).not.toHaveBeenCalled();
});
it("rejects redirects and redacts fetch errors", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi
.fn()
.mockRejectedValue(
new TypeError("Redirect to http://token:secret@internal.example.local/admin?apiKey=top-secret was rejected"),
);
vi.stubGlobal("fetch", fetchMock);
const result = await createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/restart",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/top-secret|internal\.example\.local|apiKey/);
});
it("aborts timed-out action requests and returns a stable failure", async () => {
vi.useFakeTimers();
const db = createDb();
const fixture = await createFixtureAsync(db);
const started = Promise.withResolvers<void>();
const fetchMock = vi.fn((_url: string, init: RequestInit) => {
started.resolve();
return new Promise<Response>((_resolve, reject) => {
init.signal?.addEventListener("abort", () => {
reject(new Error("Timed out calling http://internal.example.local?secret=timeout-secret"));
});
});
});
vi.stubGlobal("fetch", fetchMock);
const request = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
});
await started.promise;
await vi.advanceTimersByTimeAsync(10_000);
const result = await request;
expect(result).toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
expect(JSON.stringify(result)).not.toMatch(/timeout-secret|internal\.example\.local/);
});
it("bounds action responses before reporting success", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1))));
await expect(
createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"])).execute({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
}),
).resolves.toEqual({ success: false, errorCode: "REQUEST_FAILED", responseInfo: null });
});
it("binds data requests to a viewable item and never exposes backend error text", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal(
"fetch",
vi.fn().mockRejectedValue(new Error("Backend http://private.example.local/path?token=super-secret\nstack trace")),
);
const input = {
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
};
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData(input)
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toMatch(/super-secret|private\.example\.local|stack trace/);
await expect(createCustomApiCaller(db, createSession(fixture.unrelatedId)).getData(input)).rejects.toMatchObject({
code: "NOT_FOUND",
});
await expect(
createCustomApiCaller(db, createSession(fixture.viewerId)).getData({ ...input, definitionId: createId() }),
).rejects.toMatchObject({ code: "NOT_FOUND" });
});
it("bounds and disposes non-success data responses", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
await db
.update(customWidgetDefinitions)
.set({
displayType: "singleValue",
method: "GET",
displayConfig: stringify({ type: "singleValue", jsonPath: "$.value" }),
})
.where(eq(customWidgetDefinitions.id, fixture.definitionId));
vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("x".repeat(2 * 1024 * 1024 + 1), { status: 502 })));
const error = await createCustomApiCaller(db, createSession(fixture.viewerId))
.getData({
boardId: fixture.boardId,
itemId: fixture.itemId,
definitionId: fixture.definitionId,
})
.catch((caught: unknown) => caught);
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", message: "Custom API request failed" });
expect(JSON.stringify(error)).not.toContain("x".repeat(100));
});
it("redacts unsafe preview failures and rejects redirects", async () => {
const db = createDb();
const fixture = await createFixtureAsync(db);
const fetchMock = vi.fn().mockRejectedValue(new Error("redirected to http://private.local?secret=value"));
vi.stubGlobal("fetch", fetchMock);
const caller = createCustomWidgetCaller(db, createSession(fixture.adminId, ["admin"]));
const result = await caller.preview({
url: "https://api.example.com/data",
method: "GET",
authType: "none",
displayType: "singleValue",
displayConfig: { type: "singleValue", jsonPath: "$.value", label: "Value", unit: "" },
secrets: [],
});
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/data",
expect.objectContaining({ redirect: "error" }),
);
expect(result).toEqual({
success: false,
error: "Custom API preview failed",
responseInfo: null,
rawResponse: null,
});
expect(JSON.stringify(result)).not.toMatch(/private\.local|secret=value/);
});
});
@@ -0,0 +1,36 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { locationRouter } from "../location";
const mocks = vi.hoisted(() => ({ fetch: vi.fn() }));
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: mocks.fetch,
}));
vi.mock("@homarr/core/infrastructure/http/timeout", () => ({
withTimeoutAsync: async (callback: (signal: AbortSignal) => Promise<Response>) =>
await callback(new AbortController().signal),
}));
beforeEach(() => {
vi.clearAllMocks();
mocks.fetch.mockResolvedValue(new Response(JSON.stringify({ results: [] })));
});
describe("locationRouter", () => {
test("encodes the complete search text as one name parameter", async () => {
const caller = locationRouter.createCaller({ db: createDb(), deviceType: undefined, session: null });
await caller.searchCity({ query: "Paris&language=xx&token=secret" });
const requestedUrl = new URL(mocks.fetch.mock.calls[0]?.[0] as string);
expect(requestedUrl.searchParams.get("name")).toBe("Paris&language=xx&token=secret");
expect(requestedUrl.searchParams.has("language")).toBe(false);
expect(requestedUrl.searchParams.has("token")).toBe(false);
});
});
@@ -12,8 +12,14 @@ import type { MediaRequest, MediaRequestStats } from "@homarr/integrations/types
import type * as MediaRequestListHandlerModule from "@homarr/request-handler/media-request-list";
import { calendarRouter } from "../../widgets/calendar";
import { beszelRouter } from "../../widgets/beszel";
import { firewallRouter } from "../../widgets/firewall";
import { indexerManagerRouter } from "../../widgets/indexer-manager";
import { mediaOrganizerRouter } from "../../widgets/media-organizer";
import { mediaRequestsRouter } from "../../widgets/media-requests";
import { mediaServerRouter } from "../../widgets/media-server";
import { notificationsRouter } from "../../widgets/notifications";
import { vpnRouter } from "../../widgets/vpn";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
@@ -21,7 +27,11 @@ vi.hoisted(() => {
const createHandler = <T>(integration: { name: string }, data: T) => ({
getDataAsync: async () => {
if (integration.name === "Offline") throw new Error("offline https://internal.example?token=secret");
if (integration.name === "Offline") {
throw new Error(
"GET http://admin:password@internal.example/private/path?token=secret returned\nprivate response body",
);
}
return { data, timestamp: new Date() };
},
});
@@ -34,6 +44,40 @@ vi.mock("@homarr/request-handler/media-server", () => ({
mediaServerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/media-organizer", () => ({
mediaOrganizerRequestHandler: {
handler: (integration: { name: string }) =>
createHandler(integration, { missing: [], missingCount: 0, queued: [], queuedCount: 0 }),
},
}));
vi.mock("@homarr/request-handler/beszel", () => ({
beszelSystemsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/firewall", () => ({
firewallCpuRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, { total: 1 }) },
firewallInterfacesRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
firewallMemoryRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { used: 1, total: 2, percent: 50 }),
},
firewallVersionRequestHandler: {
handler: (integration: { name: string }) => createHandler(integration, { version: "1.0" }),
},
}));
vi.mock("@homarr/request-handler/indexer-manager", () => ({
indexerManagerRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/notifications", () => ({
notificationsRequestHandler: { handler: (integration: { name: string }) => createHandler(integration, []) },
}));
vi.mock("@homarr/request-handler/vpn", () => ({
vpnSummaryHandler: { handler: (integration: { name: string }) => createHandler(integration, { connected: true }) },
}));
vi.mock("@homarr/request-handler/media-request-list", async (importOriginal) => {
const actual = await importOriginal<typeof MediaRequestListHandlerModule>();
return {
@@ -121,7 +165,7 @@ describe("partial integration failures", () => {
expect.arrayContaining([
expect.objectContaining({
integration: expect.objectContaining({ id: calendar.offlineId }),
error: "Integration request failed",
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
@@ -136,7 +180,7 @@ describe("partial integration failures", () => {
expect.objectContaining({
integrationId: mediaServer.offlineId,
sessions: [],
error: "Integration request failed",
error: "INTEGRATION_REQUEST_FAILED",
}),
]),
);
@@ -165,13 +209,13 @@ describe("partial integration failures", () => {
}),
]);
expect(latest.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(latest)).not.toContain("secret");
const stats = await caller.getStats({ integrationIds: mediaRequests.integrationIds });
expect(stats.failedIntegrations).toEqual([
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "Integration request failed" }),
expect.objectContaining({ integrationId: mediaRequests.offlineId, error: "INTEGRATION_REQUEST_FAILED" }),
]);
expect(JSON.stringify(stats)).not.toContain("secret");
expect(stats.stats).toHaveLength(1);
@@ -183,4 +227,58 @@ describe("partial integration failures", () => {
}),
]);
});
test("aggregate widget payloads never expose sensitive partial-failure details", async () => {
const cases = [
{
kind: "sonarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await mediaOrganizerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getData({ integrationIds: setup.integrationIds, pageSize: 10 }),
},
{
kind: "beszel" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await beszelRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSystems({ integrationIds: setup.integrationIds }),
},
{
kind: "opnsense" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await firewallRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getFirewallCpuStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "prowlarr" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await indexerManagerRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getIndexersStatus({ integrationIds: setup.integrationIds }),
},
{
kind: "ntfy" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await notificationsRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getNotifications({ integrationIds: setup.integrationIds }),
},
{
kind: "gluetun" as const,
run: async (setup: Awaited<ReturnType<typeof setupAsync>>) =>
await vpnRouter
.createCaller({ db: setup.db, deviceType: undefined, session: setup.session })
.getSummaries({ integrationIds: setup.integrationIds }),
},
];
for (const testCase of cases) {
const setup = await setupAsync(testCase.kind);
const payload = JSON.stringify(await testCase.run(setup));
expect(payload).toContain("INTEGRATION_REQUEST_FAILED");
expect(payload).not.toMatch(/internal\.example|token=secret|\/private\/|response body|password/i);
}
});
});
@@ -1,3 +1,5 @@
// @vitest-environment node
import { describe, expect, it } from "vitest";
import { readBoundedJsonResponseAsync, validateCustomApiUrl } from "../custom-api-security";
+10 -15
View File
@@ -10,7 +10,7 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -18,8 +18,6 @@ import { BoundedAsyncQueue } from "./bounded-async-queue";
const logger = createLogger({ module: "beszelRouter" });
const MAX_PENDING_LIVE_EVENTS = 4;
const errorMessage = (error: unknown) => (error instanceof Error ? error.message : String(error));
export const beszelRouter = createTRPCRouter({
getSystems: publicProcedure
.meta({
@@ -41,7 +39,6 @@ export const beszelRouter = createTRPCRouter({
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: data,
updatedAt: timestamp,
};
@@ -50,10 +47,9 @@ export const beszelRouter = createTRPCRouter({
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
systems: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -127,7 +123,7 @@ export const beszelRouter = createTRPCRouter({
history: [],
systemNameMap: {},
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -194,7 +190,7 @@ export const beszelRouter = createTRPCRouter({
systemStats: [],
containerStats: [],
updatedAt: new Date(0),
error: errorMessage(error),
error: toPublicIntegrationError(error),
};
}
}),
@@ -271,15 +267,14 @@ export const beszelRouter = createTRPCRouter({
integrationId: integration.id,
systemId: input.systemId,
emittedEventCount,
error: errorMessage(error),
error,
});
queue.fail(
error instanceof TRPCError
? error
: new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: error instanceof Error ? error.message : String(error),
}),
new TRPCError({
code: "BAD_GATEWAY",
message: "Live integration request failed",
cause: error,
}),
);
}
})();
+2 -2
View File
@@ -5,7 +5,7 @@ import { radarrReleaseTypes } from "@homarr/integrations/types";
import { calendarMonthRequestHandler } from "@homarr/request-handler/calendar";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const calendarRouter = createTRPCRouter({
@@ -52,7 +52,7 @@ export const calendarRouter = createTRPCRouter({
name: integration.name,
kind: integration.kind,
},
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -1,6 +1,7 @@
import { readBoundedJsonResponseAsync as readBoundedJsonResponse } from "@homarr/common/server";
export const MAX_CUSTOM_API_RESPONSE_BYTES = 2 * 1024 * 1024;
export const CUSTOM_API_FETCH_TIMEOUT_MS = 10_000;
export function validateCustomApiUrl(urlString: string): URL {
if (urlString.length > 2_048) throw new Error("Custom API URL is too long");
@@ -19,3 +20,48 @@ export async function readBoundedJsonResponseAsync(
): Promise<unknown> {
return await readBoundedJsonResponse(response, maxBytes, "Custom API response");
}
export async function consumeBoundedResponseAsync(
response: Response,
maxBytes = MAX_CUSTOM_API_RESPONSE_BYTES,
): Promise<void> {
const declaredLength = Number(response.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
await response.body?.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
if (!response.body) return;
const reader = response.body.getReader();
let byteLength = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) return;
byteLength += value?.byteLength ?? 0;
if (byteLength > maxBytes) {
await reader.cancel();
throw new Error(`Custom API response exceeds ${maxBytes} bytes`);
}
}
}
export async function withCustomApiResponseAsync<TResult>(
url: URL,
init: Omit<RequestInit, "redirect" | "signal">,
consumeResponseAsync: (response: Response) => Promise<TResult>,
timeoutMs = CUSTOM_API_FETCH_TIMEOUT_MS,
): Promise<TResult> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url.toString(), {
...init,
redirect: "error",
signal: controller.signal,
});
return await consumeResponseAsync(response);
} finally {
clearTimeout(timeout);
}
}
+2 -2
View File
@@ -7,7 +7,7 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
@@ -35,7 +35,7 @@ const queryFirewall = <
fallback: (integration, error) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: new Date(0) },
summary: createFallback(),
error: error instanceof Error ? error.message : "Integration request failed",
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -6,7 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -34,7 +34,7 @@ export const indexerManagerRouter = createTRPCRouter({
integrationId: integration.id,
integrationName: integration.name,
indexers: [],
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -4,7 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaOrganizerRequestHandler } from "@homarr/request-handler/media-organizer";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaOrganizerRouter = createTRPCRouter({
@@ -39,7 +39,7 @@ export const mediaOrganizerRouter = createTRPCRouter({
missingCount: 0,
queued: [],
queuedCount: 0,
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
@@ -12,7 +12,7 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
interface IntegrationQueryResult<TData> {
@@ -47,7 +47,7 @@ export const mediaRequestsRouter = createTRPCRouter({
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: [],
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -104,7 +104,7 @@ export const mediaRequestsRouter = createTRPCRouter({
fallback: (integration) => ({
integration: { id: integration.id, name: integration.name, kind: integration.kind },
data: { stats: null, users: [] },
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -43,7 +43,7 @@ export const mediaServerRouter = createTRPCRouter({
integrationName: integration.name,
integrationKind: integration.kind,
sessions: [],
error: "Integration request failed",
error: PUBLIC_INTEGRATION_ERROR,
}),
throwOnAllFailures: true,
},
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -39,7 +39,7 @@ export const notificationsRouter = createTRPCRouter({
updatedAt: new Date(0),
},
data: [],
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
+22 -2
View File
@@ -13,6 +13,7 @@ import { createTRPCRouter, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
const logger = createLogger({ module: "releasesRouter" });
const PUBLIC_RELEASE_ERROR_MESSAGE: string = "Release provider request failed";
const formatVersionFilterRegex = (versionFilter: z.infer<typeof releaseVersionFilterSchema> | undefined) => {
if (!versionFilter) return undefined;
@@ -100,19 +101,38 @@ export const releasesRouter = createTRPCRouter({
})
.getDataAsync();
const data = response.data.success
? response.data
: {
success: false as const,
error: { code: response.data.error.code, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
if (!response.data.success) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error: response.data.error,
});
}
return {
id: repositoryId,
provider: repository.provider,
timestamp: response.timestamp,
...response.data,
...data,
};
} catch (error) {
logger.warn("Release provider request failed", {
repositoryId,
provider: repository.provider,
error,
});
return {
id: repositoryId,
provider: repository.provider,
timestamp: new Date(),
success: false as const,
error: { code: "unexpected" as const, message: String(error) },
error: { code: "unexpected" as const, message: PUBLIC_RELEASE_ERROR_MESSAGE },
};
}
}),
+2 -2
View File
@@ -2,7 +2,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries, toPublicIntegrationError } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const vpnRouter = createTRPCRouter({
@@ -33,7 +33,7 @@ export const vpnRouter = createTRPCRouter({
updatedAt: new Date(0),
},
summary: null,
error: error instanceof Error ? error.message : String(error),
error: toPublicIntegrationError(error),
}),
throwOnAllFailures: true,
},
+4
View File
@@ -5,6 +5,10 @@ import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
const logger = createLogger({ module: "settleIntegrations" });
export const PUBLIC_INTEGRATION_ERROR: string = "INTEGRATION_REQUEST_FAILED";
export const toPublicIntegrationError = (_error: unknown): string => PUBLIC_INTEGRATION_ERROR;
interface IntegrationLike {
id: string;
name: string;
@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { settleIntegrationQueries } from "../settle-integrations";
import { PUBLIC_INTEGRATION_ERROR, settleIntegrationQueries, toPublicIntegrationError } from "../settle-integrations";
const mocks = vi.hoisted(() => ({
logger: { warn: vi.fn() },
@@ -22,6 +22,17 @@ const integrations = [
beforeEach(() => vi.clearAllMocks());
describe("settleIntegrationQueries", () => {
test("maps sensitive integration failures to a stable public code", () => {
const error = new Error(
"GET http://admin:password@nas.internal.local/private/path?token=secret returned\nprivate response body",
);
const publicError = toPublicIntegrationError(error);
expect(publicError).toBe(PUBLIC_INTEGRATION_ERROR);
expect(publicError).not.toMatch(/admin|password|nas\.internal|private|token|secret|response body/i);
});
test("returns an empty result when no integrations are configured", async () => {
await expect(settleIntegrationQueries([], vi.fn())).resolves.toEqual([]);
});
+35
View File
@@ -48,3 +48,38 @@ const absoluteUrlRegex = /^[a-z]+:(\/\/)?/;
export const isAbsoluteUrl = (urlOrPath: string): boolean => {
return absoluteUrlRegex.test(urlOrPath.toLowerCase());
};
export const SAFE_NEW_TAB_REL = "noopener noreferrer";
interface SafeApplicationUrlOptions {
baseUrl?: unknown;
}
/** Returns an absolute, credential-free HTTP(S) URL suitable for application navigation. */
export const getSafeApplicationUrl = (value: unknown, options: SafeApplicationUrlOptions = {}): string | undefined => {
if (typeof value !== "string" || value.trim() === "") return undefined;
try {
let baseUrl: string | undefined;
if (options.baseUrl !== undefined) {
const parsedBase = parseHttpUrl(options.baseUrl);
if (!parsedBase) return undefined;
parsedBase.search = "";
parsedBase.hash = "";
baseUrl = parsedBase.toString();
}
return parseHttpUrl(value, baseUrl)?.toString();
} catch {
return undefined;
}
};
const parseHttpUrl = (value: unknown, baseUrl?: string): URL | undefined => {
if (typeof value !== "string") return undefined;
const url = new URL(value, baseUrl);
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username !== "" || url.password !== "") {
return undefined;
}
return url;
};
@@ -1,5 +1,6 @@
import { Spotlight } from "@mantine/spotlight";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
import { Link } from "@homarr/ui";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
@@ -15,10 +16,18 @@ interface ChildrenActionItemProps {
export const ChildrenActionItem = ({ childrenOptions, action, query, setChildrenOptions }: ChildrenActionItemProps) => {
const interaction = action.useInteraction(childrenOptions.option, query);
const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined;
const renderRoot =
interaction.type === "link"
interaction.type === "link" && safeHref
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
return (
<Link
href={safeHref}
target={interaction.newTab ? "_blank" : undefined}
rel={interaction.newTab ? SAFE_NEW_TAB_REL : undefined}
{...props}
/>
);
}
: undefined;
@@ -2,6 +2,7 @@ import { Spotlight } from "@mantine/spotlight";
import type { TranslationObject } from "@homarr/translation";
import { Link } from "@homarr/ui";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
import type { SearchGroup } from "../../../lib/group";
import type { inferSearchInteractionOptions } from "../../../lib/interaction";
@@ -30,10 +31,18 @@ export const SpotlightGroupActionItem = <TOption extends Record<string, unknown>
const { key: _reactKey, ...optionProps } = option as unknown as { key?: unknown } & Record<string, unknown>;
const safeHref = interaction.type === "link" ? getSafeApplicationUrl(interaction.href) : undefined;
const renderRoot =
interaction.type === "link"
interaction.type === "link" && safeHref
? (props: Record<string, unknown>) => {
return <Link href={interaction.href} target={interaction.newTab ? "_blank" : undefined} {...props} />;
return (
<Link
href={safeHref}
target={interaction.newTab ? "_blank" : undefined}
rel={interaction.newTab ? SAFE_NEW_TAB_REL : undefined}
{...props}
/>
);
}
: undefined;
@@ -21,8 +21,9 @@ import { IconClick, IconListSearch } from "@tabler/icons-react";
import type { RouterOutputs } from "@homarr/api";
import { clientApi } from "@homarr/api/client";
import { createModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { formatLocalizedCompactNumber } from "../common/locale";
import type { OptionLocation } from "../options";
import type { CommonWidgetInputProps } from "./common";
import { useWidgetInputTranslation } from "./common";
@@ -128,7 +129,7 @@ const LocationSearchModal = createModal<LocationSearchInnerProps>(({ actions, in
if (error) {
return (
<Alert title={tCommon("error")} color="red">
{error.message}
{tCommon("action.tryAgain")}
</Alert>
);
}
@@ -187,6 +188,7 @@ interface LocationSearchTableRowProps {
const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: LocationSearchTableRowProps) => {
const t = useScopedI18n("widget.common.location.table");
const locale = useCurrentIntlLocale();
const onSelect = useCallback(() => {
onLocationSelect({
name: city.name,
@@ -196,8 +198,6 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
closeModal();
}, [city, onLocationSelect, closeModal]);
const formatter = Intl.NumberFormat("en", { notation: "compact" });
return (
<Table.Tr>
<Table.Td>
@@ -215,7 +215,7 @@ const LocationSelectTableRow = ({ city, onLocationSelect, closeModal }: Location
</Table.Td>
<Table.Td>
{city.population ? (
<Text style={{ whiteSpace: "nowrap" }}>{formatter.format(city.population)}</Text>
<Text style={{ whiteSpace: "nowrap" }}>{formatLocalizedCompactNumber(city.population, locale)}</Text>
) : (
<Text c="gray"> {t("population.fallback")}</Text>
)}
@@ -271,19 +271,19 @@ const ProviderTokensSection = ({ itemId, repositories }: { itemId: string; repos
const { data: configuredKinds = [], refetch } = clientApi.widget.secrets.getConfiguredKinds.useQuery({ itemId });
const setSecret = clientApi.widget.secrets.setSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to save token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
const deleteSecret = clientApi.widget.secrets.deleteSecret.useMutation({
onSuccess: () => refetch(),
onError: (error) => {
onError: () => {
showErrorNotification({
title: "Failed to delete token",
message: error.message,
title: tRepository("tokens.label"),
message: tRepository("tokens.notConfigured"),
});
},
});
@@ -0,0 +1,42 @@
import { describe, expect, test } from "vitest";
import { supportsAdvancedFocus } from "./definition";
import { widgetImports } from ".";
const compactOnlyWidgetKinds = [
"app",
"bazarr",
"beszelSystemGrid",
"beszelSystemStats",
"beszelSystemTable",
"coolify",
"dnsHoleControls",
"dnsHoleSummary",
"iframe",
"indexerManager",
"mediaRequests-requestList",
"mediaRequests-requestStats",
"minecraftServerStatus",
"networkControllerSummary",
"notebook",
"notifications",
"paperlessNgx",
"patchmon",
"releases",
"rssFeed",
"stockPrice",
"systemDisks",
"video",
"vpn",
] as const;
describe("advanced focus support", () => {
test("is reserved for widgets with a distinct advanced experience", () => {
const actualCompactOnlyKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => !supportsAdvancedFocus(widgetImport.definition))
.map(([kind]) => kind)
.toSorted();
expect(actualCompactOnlyKinds).toEqual([...compactOnlyWidgetKinds].toSorted());
});
});
@@ -18,6 +18,7 @@ import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client"
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import "react-quill-new/dist/quill.snow.css";
@@ -181,10 +182,12 @@ const AnchorNoteWidgetContent = ({
}: AnchorNoteWidgetContentProps) => {
const t = useScopedI18n("widget.anchorNote");
const locale = useCurrentIntlLocale();
const { data: note, refetch } = clientApi.widget.anchorNotes.getNote.useQuery({
const noteQuery = clientApi.widget.anchorNotes.getNote.useQuery({
integrationId,
noteId,
});
const note = getUsableWidgetQueryData(noteQuery);
const { refetch } = noteQuery;
const { mutateAsync: updateNoteAsync, isPending: isUpdating } = clientApi.widget.anchorNotes.updateNote.useMutation();
const [isEditing, setIsEditing] = useState(false);
+90 -84
View File
@@ -2,7 +2,7 @@
import type { PropsWithChildren } from "react";
import { Fragment, Suspense } from "react";
import { Flex, rem, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { Box, Flex, Stack, Text, Tooltip, UnstyledButton } from "@mantine/core";
import { IconMinus } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -14,6 +14,9 @@ import { useI18n } from "@homarr/translation/client";
import { MaskedOrNormalImage } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./app.module.css";
import { PingDot } from "./ping/ping-dot";
@@ -23,12 +26,12 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
const t = useI18n();
const settings = useSettings();
const board = useRequiredBoard();
const { data: app } = clientApi.app.byId.useQuery({
id: options.appId,
});
const appQuery = clientApi.app.byId.useQuery({ id: options.appId });
const app = getUsableWidgetQueryData(appQuery);
const href = getSafeApplicationUrl(app?.href);
useRegisterSpotlightContextResults(
`app-${app?.id ?? options.appId}`,
app?.href
app && href
? [
{
id: app.id,
@@ -37,99 +40,102 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
interaction() {
return {
type: "link",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
href,
newTab: options.openInNewTab,
};
},
},
]
: [],
[app, options.openInNewTab],
[app, href, options.openInNewTab],
);
if (isInitialWidgetQueryPending(appQuery)) return <WidgetQueryLoadingState />;
if (!app) return <WidgetEmptyState />;
const isTiny = height < 100 || width < 100;
const isColumnLayout = options.layout.startsWith("column");
return (
<AppLink
href={app.href ?? undefined}
openInNewTab={options.openInNewTab}
enabled={Boolean(app.href) && !isEditMode}
>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, app.href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
<Box h="100%" w="100%" pos="relative">
<AppLink href={href} openInNewTab={options.openInNewTab} enabled={Boolean(href) && !isEditMode}>
<Tooltip.Floating
label={app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
disabled={options.descriptionDisplayMode !== "tooltip" || !app.description || isEditMode}
styles={{ tooltip: { maxWidth: 300 } }}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && (
<Text
className="app-description"
size={isTiny ? rem(8) : "sm"}
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{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>
) : null}
</AppLink>
<Flex
p={isTiny ? 4 : "sm"}
className={combineClasses("app-flex-wrapper", app.name, app.id, href && classes.appWithUrl)}
h="100%"
w="100%"
direction={options.layout}
justify="center"
align="center"
gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
>
<Stack gap={0}>
{options.showTitle && (
<Text
className="app-title"
fw={700}
size={isTiny ? "xs" : "sm"}
ta={isColumnLayout ? "center" : undefined}
>
{app.name}
</Text>
)}
{options.descriptionDisplayMode === "normal" && !isTiny && (
<Text
className="app-description"
size="sm"
ta={isColumnLayout ? "center" : undefined}
c="dimmed"
lineClamp={Math.max(1, Math.floor((height - 48) / 18))}
>
{app.description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
</Text>
)}
</Stack>
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={board.iconColor !== null}
alt={app.name}
className={combineClasses(classes.appIcon, "app-icon")}
style={{
height: "100%",
width: "100%",
minWidth: "20%",
maxWidth: isColumnLayout ? undefined : isTiny ? "38%" : "50%",
}}
/>
</Flex>
</Tooltip.Floating>
{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>
) : null}
</AppLink>
{appQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={appQuery.error} label={t("widget.app.name")} />
</Box>
)}
</Box>
);
}
@@ -145,7 +151,7 @@ const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<Ap
component="a"
href={href}
target={openInNewTab ? "_blank" : undefined}
rel="noreferrer"
rel={openInNewTab ? SAFE_NEW_TAB_REL : undefined}
h="100%"
w="100%"
>
@@ -11,7 +11,9 @@ interface PingIndicatorProps {
export const PingIndicator = ({ appId }: PingIndicatorProps) => {
const t = useI18n();
const { data: pingResult } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
const { data: pingResult, error } = clientApi.widget.app.ping.useQuery({ id: appId }, { refetchOnMount: false });
if (error) return <PingDot icon={IconX} color="red" tooltip={t("common.error")} />;
if (!pingResult) return <PingDot icon={IconMinus} color="gray" tooltip={`${t("common.action.loading")}…`} />;
+11 -16
View File
@@ -36,19 +36,14 @@ const iconSizeByWidth = [
{ minWidth: 0, size: 16 },
] as const;
export default function BazarrWidget({
integrationIds,
options,
width,
height,
displayMode,
}: WidgetComponentProps<"bazarr">) {
export default function BazarrWidget({ integrationIds, options, width, height }: WidgetComponentProps<"bazarr">) {
const t = useScopedI18n("widget.bazarr");
const { data: badges } = clientApi.widget.bazarr.getBadges.useQuery(
const { data: badges, error } = clientApi.widget.bazarr.getBadges.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: Boolean(integrationIds[0]) },
);
if (error && !badges) throw error;
if (!badges) return <WidgetEmptyState />;
const statValues = {
@@ -59,10 +54,10 @@ export default function BazarrWidget({
} as const;
const visibleStatKeys = Object.entries(statVisibilityByOption)
.filter(([optionKey]) => displayMode === "advanced" || options[optionKey as keyof typeof options])
.filter(([optionKey]) => options[optionKey as keyof typeof options])
.map(([, statKey]) => statKey);
const gridCols = getGridCols(width, height, visibleStatKeys.length, displayMode === "advanced");
const gridCols = getGridCols(width, height, visibleStatKeys.length);
const iconSize = getIconSize(Math.min(width, height));
if (visibleStatKeys.length === 0) {
@@ -100,14 +95,14 @@ export default function BazarrWidget({
);
}
export function getGridCols(width: number, height: number, itemCount: number, isAdvanced = false): number {
export function getGridCols(width: number, height: number, itemCount: number): number {
if (itemCount <= 1) return 1;
if (isAdvanced && width >= 640) return Math.min(itemCount, 4);
const widthMatch = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const rowsAtWidth = Math.ceil(itemCount / widthMatch);
const rowHeight = height / rowsAtWidth;
return rowHeight >= 72 ? widthMatch : Math.min(itemCount, Math.max(widthMatch, 2));
const preferredColumns = gridColsByWidth.find(({ minWidth }) => width >= minWidth)?.cols ?? 1;
const maxColumnsByWidth = Math.max(1, Math.floor(width / 160));
const maxRowsByHeight = Math.max(1, Math.floor(height / 72));
const columnsNeededToFit = Math.ceil(itemCount / maxRowsByHeight);
return Math.min(itemCount, maxColumnsByWidth, Math.max(preferredColumns, columnsNeededToFit));
}
export function getIconSize(width: number): number {
+1
View File
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("bazarr", {
supportsAdvancedFocus: false,
icon: IconBadgeCc,
supportedIntegrations: ["bazarr"],
integrationsRequired: true,
+2 -2
View File
@@ -4,11 +4,11 @@ import { getGridCols, getIconSize } from "./component";
describe("Bazarr responsive layout", () => {
test("uses two columns for a short widget so all stats remain visible", () => {
expect(getGridCols(220, 100, 4)).toBe(2);
expect(getGridCols(220, 100, 4)).toBe(1);
});
test("uses four columns in a wide advanced surface", () => {
expect(getGridCols(800, 400, 4, true)).toBe(4);
expect(getGridCols(800, 400, 4)).toBe(2);
});
test("sizes icons from the constrained dimension", () => {
@@ -26,6 +26,7 @@ import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
const alertIconMap: Record<string, LucideIcon> = {
CPU: Cpu,
@@ -56,7 +57,9 @@ export default function BeszelAlertsWidget({
() => ({ integrationIds, includeHistory: options.showHistory, maxHistoryItems: options.maxHistoryItems }),
[integrationIds, options.showHistory, options.maxHistoryItems],
);
const { data: results = [], error: alertsError, isPending } = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const alertsQuery = clientApi.widget.beszel.getAlerts.useQuery(alertsInput);
const results = getUsableWidgetQueryData(alertsQuery) ?? [];
const { isPending } = alertsQuery;
const systemNameMap = useMemo(() => {
const map: Record<string, string> = {};
@@ -86,8 +89,6 @@ export default function BeszelAlertsWidget({
const showHistory = options.showHistory && (displayMode === "advanced" || height >= 360);
const showAlertDescriptions = displayMode === "advanced" || height >= 190;
if (alertsError) throw alertsError;
if (isPending) {
return (
<Center h="100%">
@@ -37,6 +37,7 @@ import {
} from "../beszel/_shared/format";
import { useBeszelFilteredSystems } from "../beszel/_shared/hooks";
import { IntegrationErrorIndicator } from "../common/integration-error-indicator";
import { getUsableWidgetQueryData } from "../common/query-state";
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
@@ -385,22 +386,17 @@ export default function BeszelSystemGridWidget({
isEditMode,
width,
height,
displayMode = "compact",
}: WidgetComponentProps<"beszelSystemGrid">) {
const t = useScopedI18n("widget.beszelSystemGrid");
const board = useRequiredBoard();
const { openModal } = useModalAction(BeszelSystemStatsModal);
const {
data: results = [],
error: systemsError,
isPending,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const results = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending } = systemsQuery;
const filteredSystems = useBeszelFilteredSystems(results, options.statusFilter);
if (systemsError) throw systemsError;
if (isPending) {
return (
<Center h="100%">
@@ -425,20 +421,15 @@ export default function BeszelSystemGridWidget({
);
}
const advanced = displayMode === "advanced";
const cols = advanced
? Math.min(filteredSystems.length, width >= 900 ? 2 : 1)
: getColCount(width, height, filteredSystems.length);
const cols = getColCount(width, height, filteredSystems.length);
const rows = Math.ceil(filteredSystems.length / cols) || 1;
const rawCellHeight = height / rows;
const minimumCellHeight = advanced ? 320 : 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 = advanced
? Math.min(metricRenderers.length, getMaxVisibleMetrics(effectiveCellHeight, size))
: getMaxVisibleMetrics(effectiveCellHeight, size);
const maxMetrics = getMaxVisibleMetrics(effectiveCellHeight, size);
return (
<Box h="100%" pos="relative" style={{ pointerEvents: isEditMode ? "none" : undefined }}>
@@ -14,9 +14,10 @@ import { useScopedI18n } from "@homarr/translation/client";
import classes from "./component.module.css";
import type { WidgetComponentProps } from "../definition";
import { setWidgetRuntimeQueries } from "../definition";
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 { BeszelStatsView } from "../beszel/_shared/stats-view";
import { createBeszelSystemChoices, resolveBeszelSystemChoice } from "./selection";
@@ -25,12 +26,10 @@ export default function BeszelSystemStatsWidget({
integrationIds,
isEditMode,
width,
height,
displayMode = "compact",
boardId,
itemId,
setOptions,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"beszelSystemStats">) {
const t = useScopedI18n("widget.beszelSystemStats");
const board = useOptionalBoard();
@@ -45,22 +44,26 @@ export default function BeszelSystemStatsWidget({
message: t("error.selectionSaveMessage"),
}),
});
const {
data: systemsResult = [],
isPending: systemsPending,
error: systemsError,
} = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsQuery = clientApi.widget.beszel.getSystems.useQuery({ integrationIds });
const systemsResult = getUsableWidgetQueryData(systemsQuery) ?? [];
const { isPending: systemsPending } = systemsQuery;
const systems = useMemo(() => createBeszelSystemChoices(systemsResult), [systemsResult]);
const selectedSystem = resolveBeszelSystemChoice(systems, options.systemId);
const selectedValue = selectedSystem?.value ?? "";
const selectedLabel = selectedSystem?.label;
const systemExists = selectedSystem !== undefined;
const includeDocker =
(displayMode === "advanced" || height >= 380) &&
(options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork);
setWidgetRuntimeQueries(
widgetStateRef,
const includeDocker = options.showDockerCpu || options.showDockerMemory || options.showDockerNetwork;
const periodOptions = [
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
];
useWidgetRuntimeQueries(
widgetRuntimeRef,
selectedSystem && options.timePeriod !== "1m"
? [
{
@@ -125,7 +128,6 @@ export default function BeszelSystemStatsWidget({
[boardId, hasChangeAccess, itemId, options.timePeriod, saveItemOptions, setOptions],
);
if (systemsError) throw systemsError;
if (systemsPending)
return (
<Center h="100%">
@@ -210,36 +212,39 @@ export default function BeszelSystemStatsWidget({
</Menu.Dropdown>
</Menu>
)}
{!isEditMode && displayMode === "advanced" && (
<SegmentedControl
size="xs"
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={[
{ value: "1m", label: t("period.live") },
{ value: "1h", label: t("period.oneHour") },
{ value: "12h", label: t("period.twelveHours") },
{ value: "24h", label: t("period.oneDay") },
{ value: "1w", label: t("period.oneWeek") },
{ value: "30d", label: t("period.thirtyDays") },
]}
/>
)}
{!isEditMode &&
(width >= 560 ? (
<SegmentedControl
size="xs"
fullWidth
value={options.timePeriod}
onChange={handleTimePeriod}
disabled={isSelectionSavePending}
data={periodOptions}
/>
) : (
<Select
size="xs"
value={options.timePeriod}
onChange={(value) => value && handleTimePeriod(value)}
disabled={isSelectionSavePending}
data={periodOptions}
/>
))}
<BeszelStatsView
integrationIds={selectedSystem ? [selectedSystem.integrationId] : []}
systemId={selectedSystem?.systemId ?? ""}
timePeriod={options.timePeriod as BeszelTimePeriod}
columns={displayMode === "advanced" || width > 600 ? 2 : 1}
columns={width > 600 ? 2 : 1}
visibility={{
cpu: options.showCpu,
memory: options.showMemory,
disk: options.showDisk,
diskIO: options.showDiskIO && (displayMode === "advanced" || height >= 260),
network: options.showNetwork && (displayMode === "advanced" || height >= 260),
dockerCpu: options.showDockerCpu && (displayMode === "advanced" || height >= 380),
dockerMemory: options.showDockerMemory && (displayMode === "advanced" || height >= 380),
dockerNetwork: options.showDockerNetwork && (displayMode === "advanced" || height >= 380),
diskIO: options.showDiskIO,
network: options.showNetwork,
dockerCpu: options.showDockerCpu,
dockerMemory: options.showDockerMemory,
dockerNetwork: options.showDockerNetwork,
}}
onSwitchToHistorical={() => handleTimePeriod("1h")}
/>
@@ -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) {
+33 -24
View File
@@ -8,33 +8,38 @@ import dayjs from "dayjs";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { formatLocalizedDate } from "../../common/locale";
export type BeszelTimePeriod = "1m" | "1h" | "12h" | "24h" | "1w" | "30d";
const timeFormats: Record<BeszelTimePeriod, string> = {
"1m": "HH:mm:ss",
"1h": "HH:mm",
"12h": "HH:mm",
"24h": "HH:mm",
"1w": "MMM D",
"30d": "MMM D",
const timeFormatOptions: Record<BeszelTimePeriod, Intl.DateTimeFormatOptions> = {
"1m": { hour: "numeric", minute: "2-digit", second: "2-digit" },
"1h": { hour: "numeric", minute: "2-digit" },
"12h": { hour: "numeric", minute: "2-digit" },
"24h": { hour: "numeric", minute: "2-digit" },
"1w": { month: "short", day: "numeric" },
"30d": { month: "short", day: "numeric" },
};
const periodDays: Partial<Record<BeszelTimePeriod, number>> = { "1w": 7, "30d": 30 };
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod) {
const fmt = (timestamp: string) => dayjs(timestamp).format(timeFormats[timePeriod]);
export const formatBeszelChartTimestamp = (timestamp: string, timePeriod: BeszelTimePeriod, locale: string): string =>
formatLocalizedDate(timestamp, locale, timeFormatOptions[timePeriod]);
function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: string) {
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
if (timePeriod === "1m") {
return { fmt, ordered: records };
}
return { fmt, ordered: [...records].toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod) {
if (timePeriod === "1m") return padLiveTimeGrid(data);
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
if (timePeriod === "1m") return padLiveTimeGrid(data, 60, locale);
const days = periodDays[timePeriod];
if (!days) return data;
const fmt = (ts: string) => dayjs(ts).format(timeFormats[timePeriod]);
const fmt = (timestamp: string) => formatBeszelChartTimestamp(timestamp, timePeriod, locale);
const now = dayjs();
const existingDays = new Set(data.map((d) => dayjs(d.rawTime as string).format("YYYY-MM-DD")));
const result = [...data];
@@ -51,7 +56,7 @@ function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeri
return result.toSorted((a, b) => new Date(a.rawTime as string).getTime() - new Date(b.rawTime as string).getTime());
}
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60) {
export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60, locale = "en-US") {
if (data.length === 0) return data;
const end = dayjs(
@@ -76,7 +81,7 @@ export function padLiveTimeGrid(data: Record<string, unknown>[], pointCount = 60
const timestamp = end.subtract(pointCount - index - 1, "second");
return (
pointsBySecond.get(timestamp.valueOf()) ?? {
time: timestamp.format(timeFormats["1m"]),
time: formatBeszelChartTimestamp(timestamp.toISOString(), "1m", locale),
rawTime: timestamp.toISOString(),
}
);
@@ -162,17 +167,18 @@ export const useSystemChartData = (
systemStats: BeszelSystemStatsRecord[] | undefined,
mapFn: (stats: BeszelSystemStatsRecord["stats"]) => Record<string, unknown>,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!systemStats) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((r) => ({
time: fmt(r.created),
rawTime: r.created,
...mapFn(r.stats),
}));
return padTimeGrid(mapped, timePeriod);
}, [systemStats, mapFn, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [systemStats, mapFn, timePeriod, locale]);
export const useContainerNames = (containerStats: BeszelContainerStatsRecord[] | undefined, max = 15) => {
const prevRef = useRef<string[]>([]);
@@ -212,10 +218,11 @@ export const useDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod),
[systemStats, efsPaths, rootSeriesName, timePeriod],
() => buildDiskChartData(systemStats, efsPaths, rootSeriesName, timePeriod, locale),
[systemStats, efsPaths, rootSeriesName, timePeriod, locale],
);
export const buildDiskChartData = (
@@ -223,9 +230,10 @@ export const buildDiskChartData = (
efsPaths: string[],
rootSeriesName: string,
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) => {
if (!systemStats?.length) return [];
const { fmt, ordered } = prepareRecords(systemStats, timePeriod);
const { fmt, ordered } = prepareRecords(systemStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = {
time: fmt(record.created),
@@ -238,7 +246,7 @@ export const buildDiskChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
return padTimeGrid(mapped, timePeriod, locale);
};
export const useDockerChartData = (
@@ -246,12 +254,13 @@ export const useDockerChartData = (
containerNames: string[],
metric: "cpu" | "memory" | "network",
timePeriod: BeszelTimePeriod = "1h",
locale = "en-US",
) =>
useMemo(() => {
if (!containerStats?.length) return [];
const extract = defaultContainerExtractors[metric];
if (!extract) return [];
const { fmt, ordered } = prepareRecords(containerStats, timePeriod);
const { fmt, ordered } = prepareRecords(containerStats, timePeriod, locale);
const mapped = ordered.map((record) => {
const point: Record<string, unknown> = { time: fmt(record.created), rawTime: record.created };
const byName = new Map(record.stats.map((c) => [c.n, c]));
@@ -260,5 +269,5 @@ export const useDockerChartData = (
}
return point;
});
return padTimeGrid(mapped, timePeriod);
}, [containerStats, containerNames, metric, timePeriod]);
return padTimeGrid(mapped, timePeriod, locale);
}, [containerStats, containerNames, metric, timePeriod, locale]);
@@ -6,8 +6,9 @@ import { IconPlugConnectedX, IconServerOff } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import type { BeszelContainerStatsRecord, BeszelSystemStatsRecord } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { getUsableWidgetQueryData } from "../../common/query-state";
import { containerColors } from "./colors";
import type { BeszelTimePeriod } from "./chart";
import {
@@ -71,13 +72,15 @@ export function BeszelStatsView({
onSwitchToHistorical,
}: BeszelStatsViewProps) {
const t = useScopedI18n("widget.beszelSystemStats");
const locale = useCurrentIntlLocale();
const showDocker = visibility.dockerCpu || visibility.dockerMemory || visibility.dockerNetwork;
const isLive = timePeriod === "1m";
const { data: historicalData, error: historicalError } = clientApi.widget.beszel.getSystemStats.useQuery(
const historicalQuery = clientApi.widget.beszel.getSystemStats.useQuery(
{ integrationIds, systemId, timePeriod, includeDocker: showDocker },
{ refetchInterval: isLive ? false : 5_000, enabled: !isLive && systemId !== "" },
);
const historicalData = isLive ? historicalQuery.data : getUsableWidgetQueryData(historicalQuery);
const { data: liveData, error: liveError } = useLiveStats(integrationIds, systemId, isLive && systemId !== "");
const activeStats:
@@ -125,10 +128,25 @@ export function BeszelStatsView({
const systemStats = activeStats?.systemStats;
const containerStats = activeStats?.containerStats;
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod);
const memoryData = useSystemChartData(whenVisible(visibility.memory, systemStats), mappers.memory, timePeriod);
const diskIOData = useSystemChartData(whenVisible(visibility.diskIO, systemStats), mappers.diskIO, timePeriod);
const networkData = useSystemChartData(whenVisible(visibility.network, systemStats), mappers.network, timePeriod);
const cpuData = useSystemChartData(whenVisible(visibility.cpu, systemStats), mappers.cpu, timePeriod, locale);
const memoryData = useSystemChartData(
whenVisible(visibility.memory, systemStats),
mappers.memory,
timePeriod,
locale,
);
const diskIOData = useSystemChartData(
whenVisible(visibility.diskIO, systemStats),
mappers.diskIO,
timePeriod,
locale,
);
const networkData = useSystemChartData(
whenVisible(visibility.network, systemStats),
mappers.network,
timePeriod,
locale,
);
const efsPaths = useMemo(() => {
const paths = new Set<string>();
@@ -138,7 +156,13 @@ export function BeszelStatsView({
return [...paths];
}, [systemStats]);
const rootSeriesName = t("chart.disk.series");
const diskData = useDiskChartData(whenVisible(visibility.disk, systemStats), efsPaths, rootSeriesName, timePeriod);
const diskData = useDiskChartData(
whenVisible(visibility.disk, systemStats),
efsPaths,
rootSeriesName,
timePeriod,
locale,
);
const diskSeries = useMemo(
() => [
{ name: rootSeriesName, color: "grape.6" },
@@ -161,22 +185,23 @@ export function BeszelStatsView({
containerNames,
"cpu",
timePeriod,
locale,
);
const dockerMemoryData = useDockerChartData(
whenVisible(visibility.dockerMemory, containerStats),
containerNames,
"memory",
timePeriod,
locale,
);
const dockerNetworkData = useDockerChartData(
whenVisible(visibility.dockerNetwork, containerStats),
containerNames,
"network",
timePeriod,
locale,
);
if (historicalError) throw historicalError;
if (isLive && liveError) {
return (
<Center h={CHART_HEIGHT}>
@@ -198,7 +223,7 @@ export function BeszelStatsView({
if (!activeStats) {
const visibleChartCount = Object.values(visibility).filter(Boolean).length;
return (
<SimpleGrid cols={columns} spacing="md" aria-label="Loading system statistics">
<SimpleGrid cols={columns} spacing="md" aria-label={t("name")}>
{Array.from({ length: visibleChartCount }, (_, index) => (
<ChartSkeleton key={index} />
))}
@@ -212,7 +237,7 @@ export function BeszelStatsView({
<Stack align="center" gap="xs">
<IconServerOff size={24} opacity={0.5} />
<Text size="sm" c="dimmed">
{activeStats.error}
{t("error.internalServerError")}
</Text>
</Stack>
</Center>
+148 -135
View File
@@ -22,6 +22,8 @@ import { useRegisterSpotlightContextResults } from "@homarr/spotlight";
import { MaskedOrNormalImage } from "@homarr/ui";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import classes from "./bookmark.module.css";
type BookmarkLayout = WidgetComponentProps<"bookmarks">["options"]["layout"];
@@ -64,30 +66,36 @@ export default function BookmarksWidget({
displayMode,
}: WidgetComponentProps<"bookmarks">) {
const board = useRequiredBoard();
const { data = [] } = clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
});
const data =
getUsableWidgetQueryData(
clientApi.app.byIds.useQuery(options.items, {
select(apps) {
return apps.toSorted((appA, appB) => options.items.indexOf(appA.id) - options.items.indexOf(appB.id));
},
}),
) ?? [];
useRegisterSpotlightContextResults(
`bookmark-${itemId}`,
data
.filter((app) => app.href !== null)
.map((app) => ({
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
// We checked above that app.href is defined
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
href: app.href!,
newTab: false,
};
},
})),
data.flatMap((app) => {
const href = getSafeApplicationUrl(app.href);
return href
? [
{
id: app.id,
name: app.name,
icon: app.iconUrl,
interaction() {
return {
type: "link",
href,
newTab: false,
};
},
},
]
: [];
}),
[data],
);
@@ -174,47 +182,50 @@ const FlexLayout = ({
const board = useRequiredBoard();
return (
<Flex direction={direction} gap={4} w="100%" mih="100%" wrap="nowrap">
{data.map((app) => (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
w="100%"
>
<Card
radius={board.itemRadius}
className={classes.card}
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<div key={app.id} style={{ display: "flex", flex: `1 0 ${minimumItemSize}px`, flexDirection: direction }}>
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
))}
<Card
radius={board.itemRadius}
className={classes.card}
w="100%"
display="flex"
p={4}
h="100%"
withBorder={withBorder}
>
{direction === "row" ? (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
</div>
);
})}
</Flex>
);
};
@@ -231,12 +242,8 @@ export const getAdvancedBookmarkColumns = (width: number, itemCount: number): nu
Math.max(1, Math.min(itemCount || 1, Math.floor(width / 280)));
export const getBookmarkHostname = (href: string | null): string | undefined => {
if (!href) return undefined;
try {
return new URL(href).hostname;
} catch {
return href;
}
const safeHref = getSafeApplicationUrl(href);
return safeHref ? new URL(safeHref).hostname : undefined;
};
const AdvancedBookmarksLayout = ({
@@ -251,41 +258,44 @@ const AdvancedBookmarksLayout = ({
return (
<ScrollArea h="100%" style={{ flex: 1 }}>
<SimpleGrid cols={getAdvancedBookmarkColumns(width, data.length)} spacing="sm">
{data.map((app) => (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
key={app.id}
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
>
<Card radius={board.itemRadius} className={classes.card} withBorder={withBorder} p="md" h="100%">
<Group align="flex-start" wrap="nowrap">
<MaskedOrNormalImage
imageUrl={app.iconUrl}
hasColor={hasIconColor}
alt={app.name}
className={classes.bookmarkIcon}
style={{ width: 40, height: 40, flex: "0 0 auto" }}
/>
<Stack gap={3} miw={0}>
<Text fw={700} size="sm" truncate="end">
{app.name}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
<Anchor component="span" size="xs" truncate="end">
{getBookmarkHostname(app.href)}
</Anchor>
{app.description && (
<Text size="xs" c="dimmed" lineClamp={3}>
{app.description}
</Text>
)}
</Stack>
</Group>
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
</ScrollArea>
);
@@ -327,43 +337,46 @@ const GridLayout = ({
mih="100%"
style={{ gridAutoRows: `minmax(${minimumItemHeight}px, 1fr)` }}
>
{data.map((app) => (
<UnstyledButton
className={classes.bookmarkButton}
component="a"
href={app.href ?? undefined}
target={openNewTab ? "_blank" : "_self"}
rel="noopener noreferrer"
key={app.id}
h="100%"
>
<Card
{data.map((app) => {
const href = getSafeApplicationUrl(app.href);
return (
<UnstyledButton
className={classes.bookmarkButton}
component={href ? "a" : "div"}
href={href}
target={href ? (openNewTab ? "_blank" : "_self") : undefined}
rel={href && openNewTab ? SAFE_NEW_TAB_REL : undefined}
key={app.id}
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
))}
<Card
h="100%"
className={combineClasses(classes.card, classes["card-grid"])}
radius={board.itemRadius}
withBorder={withBorder}
p="xs"
>
{itemDirection === "horizontal" ? (
<HorizontalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
) : (
<VerticalItem
app={app}
hideTitle={hideTitle}
hideIcon={hideIcon}
hideHostname={hideHostname}
hasIconColor={hasIconColor}
/>
)}
</Card>
</UnstyledButton>
);
})}
</SimpleGrid>
);
};
@@ -27,7 +27,7 @@ describe("bookmark display helpers", () => {
test("does not crash on malformed bookmark URLs", () => {
expect(getBookmarkHostname("https://homarr.dev/docs")).toBe("homarr.dev");
expect(getBookmarkHostname("not a URL")).toBe("not a URL");
expect(getBookmarkHostname("not a URL")).toBeUndefined();
expect(getBookmarkHostname(null)).toBeUndefined();
});
});
@@ -17,9 +17,11 @@ import dayjs from "dayjs";
import { isNullOrWhitespace } from "@homarr/common";
import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { groupEventsByDate } from "./calendar-events";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { formatLocalizedTime } from "../common/locale";
import classes from "./calendar-event-list.module.css";
interface CalendarEventListProps {
@@ -36,7 +38,9 @@ export const CalendarEventList = ({
locale,
}: CalendarEventListProps) => {
const headingIdPrefix = useId();
const dateFormatter = new Intl.DateTimeFormat(locale, {
const currentLocale = useCurrentIntlLocale();
const effectiveLocale = locale ?? currentLocale;
const dateFormatter = new Intl.DateTimeFormat(effectiveLocale, {
weekday: "long",
year: "numeric",
month: "long",
@@ -66,20 +70,20 @@ export const CalendarEventList = ({
{dateFormatter.format(groupedEvents[0]?.startDate)}
</Text>
<Stack mt="xs">
<CalendarEventRows events={groupedEvents} />
<CalendarEventRows events={groupedEvents} locale={effectiveLocale} />
</Stack>
</Box>
);
})
) : (
<CalendarEventRows events={events} />
<CalendarEventRows events={events} locale={effectiveLocale} />
)}
</Stack>
</ScrollArea>
);
};
const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) => {
const CalendarEventRows = ({ events, locale }: Pick<CalendarEventListProps, "events"> & { locale: string }) => {
const { colorScheme } = useMantineColorScheme();
const t = useI18n();
return (
@@ -136,14 +140,14 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
) : (
<>
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.startDate).format("HH:mm")}
{formatLocalizedTime(event.startDate, locale)}
</Text>
{event.endDate !== null && (
<>
-{" "}
<Text c={"dimmed"} size={"sm"}>
{dayjs(event.endDate).format("HH:mm")}
{formatLocalizedTime(event.endDate, locale)}
</Text>
</>
)}
@@ -171,31 +175,35 @@ const CalendarEventRows = ({ events }: Pick<CalendarEventListProps, "events">) =
<Group pt={5} gap={5} mt={"auto"} wrap="nowrap">
{event.links
.filter((link) => link.href)
.map((link) => (
<Button
key={link.href}
component={"a"}
href={link.href.toString()}
target={"_blank"}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
))}
.map((link) => {
const href = getSafeApplicationUrl(link.href.toString());
return (
<Button
key={link.href}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
size={"xs"}
radius={"xl"}
variant={link.color ? undefined : "default"}
styles={{
root: {
backgroundColor: link.color,
color: link.isDark && colorScheme === "dark" ? "white" : "black",
"&:hover": link.color
? {
backgroundColor: link.isDark ? lighten(link.color, 0.1) : darken(link.color, 0.1),
}
: undefined,
},
}}
leftSection={link.logo ? <Image src={link.logo} fit="contain" w={20} h={20} /> : undefined}
>
<Text>{link.name}</Text>
</Button>
);
})}
</Group>
)}
</Stack>
@@ -0,0 +1,30 @@
import { describe, expect, test } from "vitest";
import { getSafeApplicationUrl } from "./application-url";
describe("getSafeApplicationUrl", () => {
test.each(["javascript:alert(1)", "data:text/html,test", "not a URL", "/relative", "./relative"])(
"rejects unsafe or non-absolute URL %s",
(value) => expect(getSafeApplicationUrl(value)).toBeUndefined(),
);
test("rejects credential-bearing URLs", () => {
expect(getSafeApplicationUrl("https://user:password@example.com/path")).toBeUndefined();
expect(getSafeApplicationUrl("/path", { baseUrl: "https://user:password@example.com" })).toBeUndefined();
});
test("allows absolute HTTP and HTTPS URLs", () => {
expect(getSafeApplicationUrl("http://example.com/path")).toBe("http://example.com/path");
expect(getSafeApplicationUrl("https://example.com/path?q=1#result")).toBe("https://example.com/path?q=1#result");
});
test("resolves feed-relative URLs only against a safe HTTP(S) base", () => {
expect(getSafeApplicationUrl("/story", { baseUrl: "https://example.com/feed.xml?token=secret" })).toBe(
"https://example.com/story",
);
});
test.each([undefined, null, ""])("rejects missing URL %s", (value) => {
expect(getSafeApplicationUrl(value)).toBeUndefined();
});
});
@@ -0,0 +1 @@
export { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "@homarr/common";
@@ -0,0 +1,35 @@
import { describe, expect, test } from "vitest";
import { formatLocalizedCompactNumber, formatLocalizedDate, formatLocalizedTime, getBinaryStatusKey } from "./locale";
const sample = new Date("2026-01-02T13:05:09.000Z");
describe("localized widget formatting", () => {
test.each([
["en-US", /Jan/],
["fr-FR", /janv/i],
["de-DE", /Jan/],
["he-IL", /[\u0590-\u05ff]/],
])("formats dates for %s", (locale, expected) => {
expect(
formatLocalizedDate(sample, locale, { weekday: "short", month: "short", day: "numeric", timeZone: "UTC" }),
).toMatch(expected);
});
test("supports explicit 12-hour and 24-hour clocks", () => {
expect(formatLocalizedTime(sample, "en-US", { hour12: true, timeZone: "UTC" })).toBe("1:05 PM");
expect(formatLocalizedTime(sample, "en-US", { hour12: false, timeZone: "UTC" })).toBe("13:05");
});
test("localizes compact numbers", () => {
expect(formatLocalizedCompactNumber(1_234_567, "en-US")).toBe("1.2M");
expect(formatLocalizedCompactNumber(1_234_567, "fr-FR")).toContain("1,2");
expect(formatLocalizedCompactNumber(1_234_567, "de-DE")).toContain("Mio.");
});
test("normalizes binary status for translated accessibility labels", () => {
expect(getBinaryStatusKey("enabled")).toBe("enabled");
expect(getBinaryStatusKey("disabled")).toBe("disabled");
expect(getBinaryStatusKey()).toBe("unknown");
});
});
+61
View File
@@ -0,0 +1,61 @@
export type LocalizedDateValue = Date | number | string;
interface LocalizedTimeOptions {
hour12?: boolean;
includeSeconds?: boolean;
timeZone?: string;
}
const toValidDate = (value: LocalizedDateValue): Date | undefined => {
const date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? undefined : date;
};
export const formatLocalizedDate = (
value: LocalizedDateValue,
locale: string,
options: Intl.DateTimeFormatOptions,
fallback = "?",
): string => {
const date = toValidDate(value);
return date ? new Intl.DateTimeFormat(locale, options).format(date) : fallback;
};
export const formatLocalizedTime = (
value: LocalizedDateValue,
locale: string,
{ hour12, includeSeconds = false, timeZone }: LocalizedTimeOptions = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{
hour: "numeric",
minute: "2-digit",
second: includeSeconds ? "2-digit" : undefined,
hour12,
timeZone,
},
fallback,
);
export const formatLocalizedDateTime = (
value: LocalizedDateValue,
locale: string,
options: Pick<LocalizedTimeOptions, "hour12" | "timeZone"> = {},
fallback = "?",
): string =>
formatLocalizedDate(
value,
locale,
{ dateStyle: "medium", timeStyle: "short", hour12: options.hour12, timeZone: options.timeZone },
fallback,
);
export const formatLocalizedCompactNumber = (value: number, locale: string): string =>
new Intl.NumberFormat(locale, { notation: "compact" }).format(value);
export type BinaryStatusKey = "enabled" | "disabled" | "unknown";
export const getBinaryStatusKey = (status?: "enabled" | "disabled"): BinaryStatusKey => status ?? "unknown";
@@ -0,0 +1,56 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetQueryErrorIndicator } from "./query-state-indicator";
vi.mock("@homarr/translation/client", () => ({
useScopedI18n: () => (key: string) => key,
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetQueryErrorIndicator", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("uses a localized label without exposing the raw failure", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetQueryErrorIndicator, { error: new Error(secret), label: "Weather" }),
),
),
);
expect(host.querySelector("[aria-label='Weather']")).not.toBeNull();
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
});
@@ -0,0 +1,38 @@
"use client";
import { Box, Center, Loader, Tooltip } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
interface WidgetQueryErrorIndicatorProps {
error: unknown;
label: string;
}
/** Localized warning for stale or optional-query failures. Never renders the raw error. */
export function WidgetQueryErrorIndicator({ error, label }: WidgetQueryErrorIndicatorProps) {
if (!error) return null;
const accessibleLabel = label;
return (
<Tooltip label={accessibleLabel} position="left" withArrow>
<Box
component="span"
c="orange"
role="img"
aria-label={accessibleLabel}
tabIndex={0}
style={{ display: "inline-flex", cursor: "help" }}
>
<IconAlertTriangle aria-hidden size={14} />
</Box>
</Tooltip>
);
}
export function WidgetQueryLoadingState() {
return (
<Center h="100%" w="100%" p="sm" role="status" aria-live="polite">
<Loader size="sm" />
</Center>
);
}
@@ -0,0 +1,39 @@
import { describe, expect, test } from "vitest";
import { getUsableWidgetQueryData, hasStaleWidgetQueryError, isInitialWidgetQueryPending } from "./query-state";
describe("getUsableWidgetQueryData", () => {
test.each([[], null, 0, false, ""])("preserves successful empty value %#", (data) => {
expect(getUsableWidgetQueryData({ data, error: null })).toBe(data);
});
test("preserves stale cached data when a refetch fails", () => {
const data = [{ id: "healthy" }];
expect(getUsableWidgetQueryData({ data, error: new Error("offline") })).toBe(data);
});
test("throws a terminal failure when no usable data exists", () => {
const error = new Error("offline");
expect(() => getUsableWidgetQueryData({ data: undefined, error })).toThrow(error);
});
test("keeps the initial pending state when neither data nor an error exists", () => {
expect(getUsableWidgetQueryData({ data: undefined, error: null })).toBeUndefined();
});
});
describe("isInitialWidgetQueryPending", () => {
test("only marks the unresolved first request as initial pending", () => {
expect(isInitialWidgetQueryPending({ data: undefined, error: null, isPending: true })).toBe(true);
expect(isInitialWidgetQueryPending({ data: [], error: null, isPending: true })).toBe(false);
expect(isInitialWidgetQueryPending({ data: undefined, error: new Error("offline"), isPending: true })).toBe(false);
});
});
describe("hasStaleWidgetQueryError", () => {
test("only marks failures that retain usable cached data", () => {
expect(hasStaleWidgetQueryError({ data: [{ id: "cached" }], error: new Error("offline") })).toBe(true);
expect(hasStaleWidgetQueryError({ data: undefined, error: new Error("offline") })).toBe(false);
expect(hasStaleWidgetQueryError({ data: [], error: null })).toBe(false);
});
});
@@ -0,0 +1,22 @@
interface WidgetQueryState {
data: unknown;
error: unknown;
}
interface PendingWidgetQueryState extends WidgetQueryState {
isPending: boolean;
}
/** Throws terminal query failures while preserving successful empty values and stale cached data. */
export const getUsableWidgetQueryData = <TQuery extends WidgetQueryState>(query: TQuery): TQuery["data"] => {
if (query.error && query.data === undefined) throw query.error;
return query.data;
};
/** True only before the first result; successful empty values and cached data are both ready to render. */
export const isInitialWidgetQueryPending = ({ data, error, isPending }: PendingWidgetQueryState): boolean =>
isPending && data === undefined && !error;
/** A failed background refresh still has usable cached data, but should remain visible to the user. */
export const hasStaleWidgetQueryError = ({ data, error }: WidgetQueryState): boolean =>
error !== null && error !== undefined && data !== undefined;
+10 -24
View File
@@ -6,17 +6,12 @@ import { clientApi } from "@homarr/api/client";
import { useScopedI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { createWidgetKey } from "./coolify-utils";
import { InstanceCard } from "./instance-card";
import { SingleInstanceLayout } from "./single-instance-layout";
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) {
@@ -27,15 +22,7 @@ export default function CoolifyWidget({
);
}
return (
<CoolifyContent
integrationIds={integrationIds}
options={options}
width={width}
height={height}
isAdvanced={displayMode === "advanced"}
/>
);
return <CoolifyContent integrationIds={integrationIds} options={options} width={width} height={height} />;
}
interface CoolifyContentProps {
@@ -43,17 +30,18 @@ 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 { data: instancesData = [], isPending } = clientApi.widget.coolify.getInstancesInfo.useQuery({
const instancesQuery = clientApi.widget.coolify.getInstancesInfo.useQuery({
integrationIds,
});
const instancesData = getUsableWidgetQueryData(instancesQuery) ?? [];
const { isPending } = instancesQuery;
const isTiny = !isAdvanced && (width < 256 || height < 144);
const hideFooter = !isAdvanced && height < 112;
const isTiny = width < 256 || height < 144;
const hideFooter = height < 112;
const [firstInstance] = instancesData;
const widgetKey = createWidgetKey(integrationIds);
@@ -74,7 +62,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
);
@@ -82,7 +69,7 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
return (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced && width >= 760 ? 2 : 1} spacing="sm" p="xs">
<SimpleGrid cols={width >= 760 ? 2 : 1} spacing="sm" p="xs">
{instancesData.map((instance) => (
<InstanceCard
key={instance.integrationId}
@@ -90,7 +77,6 @@ function CoolifyContent({ integrationIds, options, width, height, isAdvanced }:
options={options}
isTiny={isTiny}
widgetKey={widgetKey}
isAdvanced={isAdvanced}
hideFooter={hideFooter}
/>
))}
+1
View File
@@ -4,6 +4,7 @@ import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("coolify", {
supportsAdvancedFocus: false,
icon: IconCloud,
createOptions() {
return optionsBuilder.from((factory) => ({
@@ -13,6 +13,7 @@ import {
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, getResourceTimestamp, getStatusColor, parseStatus } from "./coolify-utils";
interface ResourceRowProps {
@@ -59,13 +60,14 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
? t("status.restarting")
: t("status.unknown");
const resourceUrl =
const resourceUrl = getSafeApplicationUrl(
item.projectUuid && item.environmentUuid
? `${baseUrl}/project/${item.projectUuid}/environment/${item.environmentUuid}/${resourceType}/${item.uuid}`
: undefined;
: undefined,
);
const logsUrl = resourceUrl ? `${resourceUrl}/logs` : undefined;
const publicUrl = cleanFqdn(item.fqdn);
const logsUrl = getSafeApplicationUrl(resourceUrl ? `${resourceUrl}/logs` : undefined);
const publicUrl = getSafeApplicationUrl(cleanFqdn(item.fqdn));
const resourceTimestamp = getResourceTimestamp(item, resourceType);
return (
@@ -77,7 +79,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
className={actionTargetClasses.root}
href={resourceUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
fz="xs"
c="inherit"
truncate="end"
@@ -98,7 +100,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
component="a"
href={publicUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openResource", { name: item.name })}
size="xs"
variant="subtle"
@@ -113,7 +115,7 @@ export function ResourceRow({ item, baseUrl, isTiny, resourceType }: ResourceRow
component="a"
href={logsUrl}
target="_blank"
rel="noopener noreferrer"
rel={SAFE_NEW_TAB_REL}
aria-label={t("action.openLogs", { name: item.name })}
size="xs"
variant="subtle"
@@ -7,6 +7,7 @@ import type { CoolifyServer } from "@homarr/integrations/types";
import { useScopedI18n } from "@homarr/translation/client";
import actionTargetClasses from "../common/action-target.module.css";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getBadgeColor } from "./coolify-utils";
interface ServersSectionProps {
@@ -92,7 +93,7 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
const t = useScopedI18n("widget.coolify");
const isBuildServer = server.settings?.is_build_server === true;
const isOnline = server.is_reachable !== false;
const serverUrl = `${baseUrl}/server/${server.uuid}`;
const serverUrl = getSafeApplicationUrl(`${baseUrl}/server/${server.uuid}`);
const StatusIcon = isOnline ? IconCircleCheckFilled : IconCircleXFilled;
return (
@@ -105,9 +106,10 @@ function ServerRow({ server, counts, baseUrl, isTiny, showIp }: ServerRowProps)
/>
<Anchor
className={actionTargetClasses.root}
component={serverUrl ? "a" : "span"}
href={serverUrl}
target="_blank"
rel="noopener noreferrer"
target={serverUrl ? "_blank" : undefined}
rel={serverUrl ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
c="inherit"
truncate="end"
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useState } from "react";
import { Accordion, Anchor, Group, Image, ScrollArea, Stack, Text } from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
@@ -8,6 +8,7 @@ import { useTimeAgo } from "@homarr/common";
import { useScopedI18n } from "@homarr/translation/client";
import { ApplicationsSection } from "./applications-section";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { buildServerResourceCounts } from "./coolify-utils";
import { ServersSection } from "./servers-section";
import { ServicesSection } from "./services-section";
@@ -18,8 +19,8 @@ interface SingleInstanceLayoutProps {
instance: InstanceData;
options: CoolifyOptions;
isTiny: boolean;
widgetKey: string;
isAdvanced: boolean;
widgetKey: string;
hideFooter: boolean;
}
@@ -27,8 +28,8 @@ export function SingleInstanceLayout({
instance,
options,
isTiny,
widgetKey,
isAdvanced,
widgetKey,
hideFooter,
}: SingleInstanceLayoutProps) {
const t = useScopedI18n("widget.coolify");
@@ -40,36 +41,15 @@ export function SingleInstanceLayout({
key: `coolify-sections-${widgetKey}`,
defaultValue: ["applications"],
});
const visibleSections = useMemo(
() =>
[
options.showServers ? "servers" : null,
options.showApplications ? "applications" : null,
options.showServices ? "services" : null,
].filter((section): section is string => section !== null),
[options.showApplications, options.showServers, options.showServices],
);
const [advancedOpenSections, setAdvancedOpenSections] = useState(visibleSections);
const previousVisibleSectionsRef = useRef(visibleSections);
useEffect(() => {
const newlyVisibleSections = visibleSections.filter(
(section) => !previousVisibleSectionsRef.current.includes(section),
);
previousVisibleSectionsRef.current = visibleSections;
if (newlyVisibleSections.length === 0) return;
setAdvancedOpenSections((current) => [...new Set([...current, ...newlyVisibleSections])]);
}, [visibleSections]);
const [advancedOpenSections, setAdvancedOpenSections] = useState(["servers", "applications", "services"]);
const serverResourceCounts = buildServerResourceCounts(
instance.instanceInfo.servers,
instance.instanceInfo.applications,
instance.instanceInfo.services,
);
const baseUrl = instance.integrationUrl.replace(/\/+$/, "");
const displayUrl = baseUrl.replace(/^https?:\/\//, "");
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
return (
@@ -82,17 +62,25 @@ export function SingleInstanceLayout({
oolify
</Text>
</Group>
<Anchor
href={baseUrl}
target="_blank"
rel="noopener noreferrer"
fz={isTiny ? "xs" : "sm"}
fw={500}
c="dimmed"
lineClamp={1}
>
{displayUrl}
</Anchor>
<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>
</Group>
<Accordion
@@ -108,6 +96,7 @@ export function SingleInstanceLayout({
serverResourceCounts={serverResourceCounts}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
showIp={showIp}
onToggleIp={() => setShowIp((prev) => !prev)}
/>
@@ -116,7 +105,12 @@ export function SingleInstanceLayout({
<ApplicationsSection applications={instance.instanceInfo.applications} baseUrl={baseUrl} isTiny={isTiny} />
)}
{options.showServices && (
<ServicesSection services={instance.instanceInfo.services} baseUrl={baseUrl} isTiny={isTiny} />
<ServicesSection
services={instance.instanceInfo.services}
baseUrl={baseUrl}
isTiny={isTiny}
isAdvanced={isAdvanced}
/>
)}
</Accordion>
@@ -415,7 +415,6 @@ describe("CustomJsxDisplay", () => {
});
});
});
describe("Custom JSX component registry", () => {
it.each([
["@mantine/core", MantineCore],
@@ -77,7 +77,6 @@ export default function CustomJsxDisplay({ data }: { data: Record<string, unknow
</WidgetDefinitionProvider>
);
}
function sameStatus(left: CustomWidgetPublishedQueryState["status"], right: CustomWidgetPublishedQueryState["status"]) {
return (
left.loading === right.loading &&
+2 -19
View File
@@ -1,28 +1,11 @@
import { IconApi, IconPlayerPause } from "@tabler/icons-react";
import { IconApi } from "@tabler/icons-react";
import { z } from "zod/v4";
import { createWidgetDefinition, widgetQueryInputMatches } from "../definition";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("customApi", {
icon: IconApi,
queryKey: [["widget", "customApi", "getData"]],
queryMatcher: ({ input }, scope) => widgetQueryInputMatches(input, { definitionId: scope.options.definitionId }),
contextActions({ options, widgetStateRef }) {
return [
{
key: "togglePolling",
label: (t) => t("widget.customApi.actions.togglePolling"),
icon: IconPlayerPause,
hidden: typeof options.definitionId !== "string" || options.definitionId.trim() === "",
disabled: typeof widgetStateRef?.current?.togglePolling !== "function",
onClick: () => {
const action = widgetStateRef?.current?.togglePolling;
if (typeof action === "function") action();
},
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
definitionId: factory.customWidgetSelect({ defaultValue: "" }),
+28 -25
View File
@@ -1,16 +1,16 @@
import { describe, expect, test } from "vitest";
import { definition as appDefinition } from "./app";
import { definition as beszelAlertsDefinition } from "./beszel-alerts";
import { definition as beszelSystemGridDefinition } from "./beszel-system-grid";
import { definition as beszelSystemStatsDefinition } from "./beszel-system-stats";
import { definition as beszelSystemTableDefinition } from "./beszel-system-table";
import {
getWidgetQueryKeys,
getWidgetRuntimeQueries,
setWidgetRuntimeQueries,
supportsAdvancedFocus,
} from "./definition";
const togglePolling = () => undefined;
import { definition as clockDefinition } from "./clock";
import { definition as customApiDefinition } from "./custom-api";
import { getWidgetQueryKeys, supportsAdvancedFocus } from "./definition";
import { definition as dockerDefinition } from "./docker";
import { definition as downloadsDefinition } from "./downloads";
import { definition as indexerManagerDefinition } from "./indexer-manager";
import { definition as notebookDefinition } from "./notebook";
describe("getWidgetQueryKeys", () => {
test("falls back to the widget router namespace", () => {
@@ -29,23 +29,26 @@ describe("getWidgetQueryKeys", () => {
expect(getWidgetQueryKeys({ kind: "audioStats", queryKeys })).toBe(queryKeys);
});
test("registers runtime queries without replacing imperative widget actions", () => {
const widgetStateRef = { current: { togglePolling } };
const runtimeQueries = [{ path: ["widget", "calendar", "findAllEvents"], input: { month: 7 } }];
setWidgetRuntimeQueries(widgetStateRef, runtimeQueries);
expect(widgetStateRef.current.togglePolling).toBe(togglePolling);
expect(getWidgetRuntimeQueries(widgetStateRef)).toEqual(runtimeQueries);
});
});
describe("supportsAdvancedFocus", () => {
test.each([appDefinition, beszelSystemGridDefinition, beszelSystemTableDefinition])(
"keeps non-enhanced widgets compact",
(widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
},
);
test.each([
appDefinition,
beszelAlertsDefinition,
beszelSystemGridDefinition,
beszelSystemStatsDefinition,
beszelSystemTableDefinition,
clockDefinition,
customApiDefinition,
dockerDefinition,
indexerManagerDefinition,
notebookDefinition,
])("keeps non-enhanced widgets compact", (widgetDefinition) => {
expect(supportsAdvancedFocus(widgetDefinition)).toBe(false);
});
test("requires an explicit opt-in", () => {
expect(supportsAdvancedFocus({})).toBe(false);
expect(supportsAdvancedFocus(downloadsDefinition)).toBe(true);
});
});
@@ -33,6 +33,7 @@ import { MaskedOrNormalImage } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import classes from "./component.module.css";
import TimerModal from "./TimerModal";
@@ -46,7 +47,6 @@ export default function DnsHoleControlsWidget({
isEditMode,
width,
height,
displayMode,
}: WidgetComponentProps<typeof widgetKind>) {
const board = useRequiredBoard();
// DnsHole integrations with interaction permissions
@@ -54,9 +54,9 @@ export default function DnsHoleControlsWidget({
.map(({ id }) => id)
.filter((id) => integrationIds.includes(id));
const { data: summaries = [], isPending: isSummaryPending } = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({ integrationIds });
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending: isSummaryPending } = summaryQuery;
const utils = clientApi.useUtils();
const {
@@ -118,8 +118,7 @@ export default function DnsHoleControlsWidget({
const [bulkFailureCount, setBulkFailureCount] = useState(0);
const [opened, { close, open }] = useDisclosure(false);
const controlAllButtonsVisible =
(options.showToggleAllButtons || displayMode === "advanced") && integrationsWithInteractions.length > 0;
const controlAllButtonsVisible = options.showToggleAllButtons && integrationsWithInteractions.length > 0;
const actionsPending = bulkPending || isEnabling || isDisabling;
const actionError = enableError ?? disableError;
const runBulkToggle = async (integrationIdsToToggle: string[]) => {
@@ -229,7 +228,6 @@ export default function DnsHoleControlsWidget({
hasIconColor={board.iconColor !== null}
rootWidth={width}
rootHeight={height}
isAdvanced={displayMode === "advanced"}
actionsPending={actionsPending}
/>
))}
@@ -242,7 +240,7 @@ export default function DnsHoleControlsWidget({
</Text>
)}
{actionError && (
<Tooltip label={actionError.message} multiline>
<Tooltip label={t("widget.dnsHoleControls.error.internalServerError")}>
<Text size="xs" c="red" ta="center" lineClamp={2} tabIndex={0}>
{t("widget.dnsHoleControls.error.internalServerError")}
</Text>
@@ -269,7 +267,6 @@ interface ControlsCardProps {
hasIconColor: boolean;
rootWidth: number;
rootHeight: number;
isAdvanced: boolean;
actionsPending: boolean;
}
@@ -283,7 +280,6 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
hasIconColor,
rootWidth,
rootHeight,
isAdvanced,
actionsPending,
}) => {
const isConnected = useIntegrationConnected(data.integration.updatedAt, { timeout: 30000 });
@@ -294,7 +290,7 @@ const ControlsCard: React.FC<ControlsCardProps> = ({
const board = useRequiredBoard();
const iconUrl = integrationDefs[data.integration.kind].iconUrl;
const layout = !isAdvanced && (rootWidth < 256 || rootHeight < 112) ? "sm" : "md";
const layout = rootWidth < 256 || rootHeight < 112 ? "sm" : "md";
return (
<Indicator
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleControls";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
@@ -2,7 +2,19 @@
import { useMemo } from "react";
import type { BoxProps } from "@mantine/core";
import { Avatar, AvatarGroup, Badge, Card, Flex, Group, SimpleGrid, Stack, Text, Tooltip } from "@mantine/core";
import {
Avatar,
AvatarGroup,
Badge,
Card,
Flex,
Group,
ScrollArea,
SimpleGrid,
Stack,
Text,
Tooltip,
} from "@mantine/core";
import { useElementSize } from "@mantine/hooks";
import { IconBarrierBlock, IconPercentage, IconSearch, IconWorldWww } from "@tabler/icons-react";
@@ -18,20 +30,25 @@ import type { TablerIcon } from "@homarr/ui";
import type { widgetKind } from ".";
import type { WidgetComponentProps, WidgetProps } from "../../definition";
import { getUsableWidgetQueryData } from "../../common/query-state";
export default function DnsHoleSummaryWidget({
options,
integrationIds,
displayMode,
width,
height,
}: WidgetComponentProps<typeof widgetKind>) {
const { data: summaries = [], isPending } = clientApi.widget.dnsHole.summary.useQuery({
const summaryQuery = clientApi.widget.dnsHole.summary.useQuery({
integrationIds,
});
const summaries = getUsableWidgetQueryData(summaryQuery) ?? [];
const { isPending } = summaryQuery;
const t = useI18n();
const data = useMemo(() => summaries.flatMap(({ summary }) => summary), [summaries]);
const layoutProps = boxPropsByLayout(options.layout);
const showSourceStatuses = summaries.length > 1 && width >= 240 && height >= 220;
if (isPending) {
return (
@@ -74,18 +91,21 @@ export default function DnsHoleSummaryWidget({
</Stack>
)}
</SimpleGrid>
{displayMode === "advanced" && summaries.length > 0 && (
<Group px="xs" pb="xs" gap="xs">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}`)}
</Badge>
))}
</Group>
{showSourceStatuses && (
<ScrollArea type="auto" scrollbarSize={4} px="xs" pb="xs">
<Group gap="xs" wrap="nowrap">
{summaries.map(({ integration, summary }) => (
<Badge
key={integration.id}
variant="light"
color={summary.status === "enabled" ? "green" : summary.status === "disabled" ? "red" : "gray"}
style={{ flexShrink: 0 }}
>
{integration.name}: {t(`widget.dnsHoleSummary.status.${summary.status ?? "unknown"}` as never)}
</Badge>
))}
</Group>
</ScrollArea>
)}
</Stack>
);
@@ -174,7 +194,7 @@ const StatCard = ({ item, data, usePiHoleColors, t }: StatCardProps) => {
className="summary-card"
p="sm"
radius={board.itemRadius}
bg={usePiHoleColors ? item.color : "rgba(96, 96, 96, 0.1)"}
bg={usePiHoleColors ? item.color : "var(--mantine-color-default-hover)"}
style={{
flex: 1,
}}
@@ -8,6 +8,7 @@ import { optionsBuilder } from "../../options";
export const widgetKind = "dnsHoleSummary";
export const { definition, componentLoader } = createWidgetDefinition(widgetKind, {
supportsAdvancedFocus: false,
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
@@ -0,0 +1,61 @@
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, describe, expect, test, vi } from "vitest";
import { WidgetError } from "./component";
vi.mock("@homarr/translation/client", () => ({
useI18n: () => (key: string) => key,
}));
const matchMedia = (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
});
describe("WidgetError", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("never renders an unmapped backend error", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", matchMedia);
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const secret = "http://admin:password@private.local/path?token=secret";
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(WidgetError, {
kind: "app",
error: new Error(secret),
resetErrorBoundary: vi.fn(),
}),
),
),
);
expect(host.textContent).toContain("common.error");
expect(host.textContent).not.toContain(secret);
expect(host.textContent).not.toMatch(/password|private\.local|token=secret/);
});
});
+9 -2
View File
@@ -28,7 +28,14 @@ export const WidgetError = ({ error, resetErrorBoundary, definition }: WidgetErr
return <BaseWidgetError {...trpcErrorData} onRetry={resetErrorBoundary} />;
}
return <BaseWidgetError icon={IconExclamationCircle} message={String(error)} onRetry={resetErrorBoundary} />;
return (
<BaseWidgetError
icon={IconExclamationCircle}
message={(t) => t("common.error")}
showLogsLink
onRetry={resetErrorBoundary}
/>
);
};
const handleWidgetTrpcError = (
@@ -60,7 +67,7 @@ const handleCommonTrpcError = (error: unknown): Omit<BaseWidgetErrorProps, "onRe
if (errorData.code === "UNAUTHORIZED" || errorData.code === "FORBIDDEN") {
return {
icon: IconShield,
message: "You don't have permission to access this widget",
message: (t) => t("integration.testConnection.error.certificate.alert.permission.title"),
showLogsLink: false,
};
}
+27 -19
View File
@@ -44,7 +44,8 @@ export const hasTotalFirewallFailure = (queries: readonly FirewallQueryState[])
queries.every((query) => !query.isPending && !query.isFetching) &&
queries.every(
(query) =>
query.isError || (query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
(query.isError && query.data === undefined) ||
(query.data !== undefined && query.data.length > 0 && query.data.every(({ error }) => error)),
);
export default function FirewallWidget({
@@ -58,7 +59,7 @@ export default function FirewallWidget({
const isAdvanced = displayMode === "advanced";
const isTiny = !isAdvanced && (width < 256 || height < 180);
const ringSize = isAdvanced ? 100 : height < 120 ? 44 : isTiny ? 64 : 100;
const showInterfaces = isAdvanced || height >= 120;
const showInterfaces = isAdvanced || (!isTiny && height >= 120);
const t = useI18n();
const handleSelect = useCallback((value: string | null) => {
@@ -159,7 +160,6 @@ export default function FirewallWidget({
interfacesError={interfacesQuery.isError || Boolean(interfaces?.error)}
hasError={hasError}
isAdvanced={isAdvanced}
isTiny={isTiny}
ringSize={ringSize}
showInterfaces={showInterfaces}
accordionValue={accordionValue}
@@ -190,7 +190,6 @@ interface FirewallPanelProps {
interfacesError: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
ringSize: number;
showInterfaces: boolean;
accordionValue: string | null;
@@ -214,7 +213,6 @@ const FirewallPanel = ({
interfacesError,
hasError,
isAdvanced,
isTiny,
ringSize,
showInterfaces,
accordionValue,
@@ -276,7 +274,6 @@ const FirewallPanel = ({
hasResult={interfacesLoaded}
hasError={interfacesError}
isAdvanced={isAdvanced}
isTiny={isTiny}
accordionValue={accordionValue}
setAccordionValue={setAccordionValue}
errorLabel={errorLabel}
@@ -302,6 +299,8 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
const safeValue = Math.max(0, Math.min(100, Number.isFinite(value) ? value : 0));
const status = getMetricStatus(safeValue);
const statusLabel = t(`widget.firewall.status.${status}`);
const showIcon = size >= 64;
const showStatus = size >= 96;
return (
<RingProgress
@@ -315,9 +314,9 @@ const MetricRing = ({ value, icon: Icon, size, label, t }: MetricRingProps) => {
thickness={size < 72 ? 4 : 8}
label={
<Center style={{ flexDirection: "column" }}>
<Text size={size < 72 ? "8px" : "xs"}>{safeValue.toFixed(1)}%</Text>
<Icon size={size < 72 ? 8 : 16} />
{size >= 72 && <Text size="8px">{statusLabel}</Text>}
<Text size="xs">{safeValue.toFixed(1)}%</Text>
{showIcon && <Icon size={size < 96 ? 12 : 16} />}
{showStatus && <Text size="xs">{statusLabel}</Text>}
</Center>
}
sections={[
@@ -341,7 +340,6 @@ interface InterfacesPanelProps {
hasResult: boolean;
hasError: boolean;
isAdvanced: boolean;
isTiny: boolean;
accordionValue: string | null;
setAccordionValue: (value: string | null) => void;
errorLabel: string;
@@ -356,7 +354,6 @@ const InterfacesPanel = ({
hasResult,
hasError,
isAdvanced,
isTiny,
accordionValue,
setAccordionValue,
errorLabel,
@@ -374,9 +371,9 @@ const InterfacesPanel = ({
variant={isAdvanced ? "contained" : "default"}
>
<Accordion.Item value="interfaces">
<Accordion.Control icon={isTiny ? null : <IconTopologyBus size={16} />}>
<Accordion.Control icon={<IconTopologyBus size={16} />}>
<Group justify="space-between" wrap="nowrap" gap="xs">
<Text size={isTiny ? "8px" : "xs"}>{label}</Text>
<Text size="xs">{label}</Text>
{hasError && (
<Badge color="red" variant="light" size="xs">
{errorBadgeLabel}
@@ -387,19 +384,30 @@ const InterfacesPanel = ({
<Accordion.Panel>
<Stack gap={4}>
{bandwidth.map(({ name, receive, transmit }) => (
<Group key={name} gap="xs" wrap={isTiny ? "wrap" : "nowrap"} justify="space-between">
<Text size={isTiny ? "8px" : "xs"} c="blue.3" truncate="end" style={{ flex: 1 }}>
<Group key={name} gap="xs" wrap="nowrap" justify="space-between">
<Text
size="xs"
c="light-dark(var(--mantine-color-blue-8), var(--mantine-color-blue-3))"
truncate="end"
style={{ flex: 1 }}
>
{name}
</Text>
<Group gap={4} wrap="nowrap">
<IconArrowBarUp size={isTiny ? 8 : 12} color="lightgreen" />
<Text size={isTiny ? "8px" : "xs"} c="green.3">
<IconArrowBarUp
size={12}
color="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))"
/>
<Text size="xs" c="light-dark(var(--mantine-color-green-8), var(--mantine-color-green-3))">
{formatBitsPerSec(transmit, 2)}
</Text>
</Group>
<Group gap={4} wrap="nowrap">
<IconArrowBarDown size={isTiny ? 8 : 12} color="yellow" />
<Text size={isTiny ? "8px" : "xs"} c="yellow.3">
<IconArrowBarDown
size={12}
color="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))"
/>
<Text size="xs" c="light-dark(var(--mantine-color-yellow-9), var(--mantine-color-yellow-3))">
{formatBitsPerSec(receive, 2)}
</Text>
</Group>
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { hasTotalFirewallFailure } from "./component";
import { hasFirewallPartialFailure, hasTotalFirewallFailure } from "./component";
const failedQuery = {
isPending: false,
@@ -27,4 +27,31 @@ describe("firewall failure state", () => {
false,
);
});
it("preserves stale cached data when a query-wide refetch fails", () => {
const staleQuery = {
...failedQuery,
isError: true,
data: [{ integration: { id: "firewall-1" }, error: undefined }],
};
expect(hasTotalFirewallFailure([staleQuery, failedQuery, failedQuery, failedQuery])).toBe(false);
});
it("surfaces query-wide and integration-specific partial failures", () => {
const successfulQuery = {
isPending: false,
isFetching: false,
isError: false,
data: [{ integration: { id: "firewall-1" }, error: undefined }],
};
expect(hasFirewallPartialFailure("firewall-1", [successfulQuery])).toBe(false);
expect(hasFirewallPartialFailure("firewall-1", [{ ...successfulQuery, isError: true }])).toBe(true);
expect(
hasFirewallPartialFailure("firewall-1", [
{ ...successfulQuery, data: [{ integration: { id: "firewall-1" }, error: "offline" }] },
]),
).toBe(true);
});
});
@@ -7,6 +7,7 @@ import type { Resource } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getUsableWidgetQueryData } from "../../common/query-state";
import type { WidgetComponentProps } from "../../definition";
import { formatUptime } from "../system-health";
import { getClusterAccordionDefault } from "./accordion-state";
@@ -38,9 +39,9 @@ export const ClusterHealthMonitoring = ({
displayMode,
}: WidgetComponentProps<"healthMonitoring"> & { integrationId: string }) => {
const t = useI18n();
const { data: healthData } = clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({
integrationId,
});
const healthData = getUsableWidgetQueryData(
clientApi.widget.healthMonitoring.getClusterHealthStatus.useQuery({ integrationId }),
);
const accordionScope = `${displayMode}:${options.visibleClusterSections.join(",")}`;
const accordionDefault = getClusterAccordionDefault(displayMode, options.visibleClusterSections);
const [accordionValues, setAccordionValues] = useState<Record<string, string[]>>({});
@@ -67,9 +68,9 @@ export const ClusterHealthMonitoring = ({
const isTiny = displayMode !== "advanced" && width < 256;
return (
<Stack h={displayMode === "advanced" ? "auto" : "100%"} p="xs" gap={isTiny ? "xs" : "md"}>
{options.showUptime && (
{options.showUptime && !isTiny && (
<Group justify="center" wrap="nowrap">
<Text fz={isTiny ? 8 : "xs"} fw={700} c="dimmed" ta="center">
<Text fz="xs" fw={700} c="dimmed" ta="center">
{formatUptime(uptime, t)}
</Text>
</Group>
@@ -190,10 +191,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
sections={[{ value: cpu.value, color: cpu.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text fw={500} size={isTiny ? "xs" : "sm"}>
{t("widget.healthMonitoring.cluster.summary.cpu")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{cpu.value.toFixed(1)}%</Text>
{!isTiny && (
<Text fw={500} size="sm">
{t("widget.healthMonitoring.cluster.summary.cpu")}
</Text>
)}
<Text size="xs">{cpu.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
@@ -211,10 +214,12 @@ const SummaryHeader = ({ cpu, memory, isTiny }: SummaryHeaderProps) => {
sections={[{ value: memory.value, color: memory.value > 75 ? "orange" : "green" }]}
/>
<Stack align="center" justify="center" gap={0}>
<Text size={isTiny ? "xs" : "sm"} fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
</Text>
<Text size={isTiny ? "8px" : "xs"}>{memory.value.toFixed(1)}%</Text>
{!isTiny && (
<Text size="sm" fw={500}>
{t("widget.healthMonitoring.cluster.summary.memory")}
</Text>
)}
<Text size="xs">{memory.value.toFixed(1)}%</Text>
</Stack>
</Flex>
)}
@@ -27,21 +27,21 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
return (
<Table highlightOnHover>
<TableThead>
<TableTr fz={isTiny ? "8px" : "xs"}>
<TableTr fz="xs">
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.name")}
</TableTh>
{type !== "storage" ? (
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.cpu")}
</TableTh>
) : null}
{type !== "storage" ? (
{!isTiny && type !== "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.memory")}
</TableTh>
) : null}
{type === "storage" ? (
{!isTiny && type === "storage" ? (
<TableTh ta="start" p={0}>
{t("widget.healthMonitoring.cluster.table.header.node")}
</TableTh>
@@ -59,25 +59,25 @@ export const ResourceTable = ({ type, data, isTiny }: ResourceTableProps) => {
return (
<ResourcePopover key={item.id} item={item}>
<Popover.Target>
<TableTr fz={isTiny ? "8px" : "xs"}>
<TableTr fz="xs">
<TableTd>
<Group wrap="nowrap" gap={isTiny ? 8 : "xs"}>
<Indicator size={isTiny ? 4 : 8} color={item.isRunning ? "green" : "yellow"}>
<Indicator size={isTiny ? 6 : 8} color={item.isRunning ? "green" : "yellow"}>
{null}
</Indicator>
<Text lineClamp={1} fz={isTiny ? "8px" : "xs"}>
<Text lineClamp={1} fz="xs">
{item.name}
</Text>
</Group>
</TableTd>
{item.type === "storage" ? (
<td style={{ WebkitLineClamp: "1" }}>{item.node}</td>
{isTiny ? null : item.type === "storage" ? (
<TableTd style={{ WebkitLineClamp: "1" }}>{item.node}</TableTd>
) : (
<>
<td style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</td>
<td style={{ whiteSpace: "nowrap" }}>
<TableTd style={{ whiteSpace: "nowrap" }}>{(item.cpu.utilization * 100).toFixed(1)}%</TableTd>
<TableTd style={{ whiteSpace: "nowrap" }}>
{(item.memory.total ? (item.memory.used / item.memory.total) * 100 : 0).toFixed(1)}%
</td>
</TableTd>
</>
)}
</TableTr>
@@ -8,6 +8,7 @@ import { clientApi } from "@homarr/api/client";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { ClusterHealthMonitoring } from "./cluster/cluster-health";
import { partitionHealthMonitoringIntegrations } from "./integration-selection";
import { SystemHealthMonitoring } from "./system-health";
@@ -15,7 +16,7 @@ import { SystemHealthMonitoring } from "./system-health";
dayjs.extend(duration);
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const integrations = getUsableWidgetQueryData(clientApi.integration.byIds.useQuery(props.integrationIds)) ?? [];
const t = useI18n();
const { clusterIntegrationIds, systemIntegrationIds } = partitionHealthMonitoringIntegrations(integrations);
@@ -41,6 +41,7 @@ import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import { getUsableWidgetQueryData } from "../common/query-state";
import type { WidgetComponentProps } from "../definition";
import actionTargetClasses from "../common/action-target.module.css";
import { CpuRing } from "./rings/cpu-ring";
@@ -59,9 +60,9 @@ export const SystemHealthMonitoring = ({
withScrollArea = true,
}: WidgetComponentProps<"healthMonitoring"> & { withScrollArea?: boolean }) => {
const t = useI18n();
const { data: healthData = [] } = clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({
integrationIds,
});
const healthData =
getUsableWidgetQueryData(clientApi.widget.healthMonitoring.getSystemHealthStatus.useQuery({ integrationIds })) ??
[];
const [openedIntegrationId, setOpenedIntegrationId] = useState<string | null>(null);
const board = useRequiredBoard();
+9 -22
View File
@@ -1,43 +1,35 @@
"use client";
import { Anchor, Box, Group, Stack, Text, Title } from "@mantine/core";
import { Box, Stack, Text, Title } from "@mantine/core";
import { IconBrowserOff, IconProtocol } from "@tabler/icons-react";
import { objectEntries } from "@homarr/common";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import classes from "./component.module.css";
export default function IFrameWidget({ options, isEditMode, displayMode }: WidgetComponentProps<"iframe">) {
export default function IFrameWidget({ options, isEditMode }: WidgetComponentProps<"iframe">) {
const t = useI18n();
const { embedUrl, allowScrolling, ...permissions } = options;
const safeEmbedUrl = getSafeApplicationUrl(embedUrl);
const allowedPermissions = getAllowedPermissions(permissions);
const sandboxFlags = getSandboxFlags(permissions);
if (embedUrl.trim() === "") return <NoUrl />;
if (!isSupportedProtocol(embedUrl)) {
if (!safeEmbedUrl) {
return <UnsupportedProtocol />;
}
return (
<Stack h="100%" w="100%" gap={0}>
{displayMode === "advanced" && (
<Group key="advanced-header" px="xs" py={4} wrap="nowrap" justify="space-between">
<Text size="xs" c="dimmed" truncate>
{getFrameTitle(embedUrl)}
</Text>
<Anchor href={embedUrl} target="_blank" rel="noreferrer" size="xs">
{t("widget.app.option.openInNewTab.label")}
</Anchor>
</Group>
)}
<Box key="frame" style={{ flex: 1, minHeight: 0 }}>
<Box style={{ flex: 1, minHeight: 0 }}>
<iframe
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={embedUrl}
title={getFrameTitle(embedUrl)}
src={safeEmbedUrl}
title={getFrameTitle(safeEmbedUrl)}
allow={allowedPermissions}
scrolling={allowScrolling ? "yes" : "no"}
sandbox={sandboxFlags.join(" ")}
@@ -52,12 +44,7 @@ export default function IFrameWidget({ options, isEditMode, displayMode }: Widge
const supportedProtocols = ["http", "https"];
export const isSupportedProtocol = (url: string) => {
try {
const parsedUrl = new URL(url);
return supportedProtocols.map((protocol) => `${protocol}:`).includes(`${parsedUrl.protocol}`);
} catch {
return false;
}
return getSafeApplicationUrl(url) !== undefined;
};
const NoUrl = () => {
+31 -15
View File
@@ -1,23 +1,39 @@
import { IconBrowser } from "@tabler/icons-react";
import { IconBrowser, IconExternalLink } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { getSafeApplicationUrl } from "../common/application-url";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
export const { definition, componentLoader } = createWidgetDefinition("iframe", {
supportsAdvancedFocus: false,
icon: IconBrowser,
createOptions() {
return optionsBuilder.from((factory) => ({
embedUrl: factory.text(),
allowFullScreen: factory.switch(),
allowScrolling: factory.switch({
defaultValue: true,
}),
allowPayment: factory.switch(),
allowAutoPlay: factory.switch(),
allowMicrophone: factory.switch(),
allowCamera: factory.switch(),
allowGeolocation: factory.switch(),
allowModals: factory.switch(),
}));
contextActions: ({ options }) => {
const embedUrl = getSafeApplicationUrl(options.embedUrl);
return embedUrl
? [
{
key: "open-iframe",
label: "widget.app.option.openInNewTab.label",
icon: IconExternalLink,
onClick: () => window.open(embedUrl, "_blank", "noopener,noreferrer"),
},
]
: [];
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -1,7 +1,7 @@
"use client";
import type { Dispatch, SetStateAction } from "react";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ActionIcon, Box, Center, Group, Image, ScrollArea, Stack, Text, UnstyledButton } from "@mantine/core";
import { useReducedMotion } from "@mantine/hooks";
import {
@@ -18,6 +18,8 @@ import { useCurrentIntlLocale, useI18n, useScopedI18n } from "@homarr/translatio
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { useWidgetRuntimeActions } from "../../runtime-hooks";
import { getUsableWidgetQueryData } from "../../common/query-state";
import classes from "./component.module.css";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
@@ -25,17 +27,19 @@ export default function ImmichAlbumCarouselWidget({
integrationIds,
options,
displayMode = "compact",
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"immich-albumCarousel">) {
const [currentPhotoIndex, setCurrentPhotoIndex] = useState(0);
const [paused, setPaused] = useState(false);
const { data: album } = clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
},
{ enabled: integrationIds.length > 0 },
const album = getUsableWidgetQueryData(
clientApi.widget.immich.getAlbum.useQuery(
{
integrationId: integrationIds[0] ?? "",
albumId: options.albumId && options.albumId !== ALL_PHOTOS_ALBUM_ID ? options.albumId : undefined,
},
{ enabled: integrationIds.length > 0 },
),
);
const photoAssets = useMemo(() => {
@@ -48,30 +52,19 @@ export default function ImmichAlbumCarouselWidget({
setCurrentPhotoIndex((current) => Math.min(current, photoAssets.length - 1));
}, [photoAssets.length]);
useEffect(() => {
if (!widgetStateRef) return;
const clearActions = () => {
if (!widgetStateRef.current) return;
delete widgetStateRef.current.previousPhoto;
delete widgetStateRef.current.nextPhoto;
delete widgetStateRef.current.toggleSlideshow;
};
if (photoAssets.length <= 1) {
clearActions();
return;
}
const previousPhoto = () =>
setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length);
const nextPhoto = () => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length);
const toggleSlideshow = () => setPaused((value) => !value);
widgetStateRef.current = {
...widgetStateRef.current,
previousPhoto,
nextPhoto,
toggleSlideshow,
};
return clearActions;
}, [photoAssets.length, widgetStateRef]);
const previousPhoto = useCallback(
() => setCurrentPhotoIndex((current) => (current - 1 + photoAssets.length) % photoAssets.length),
[photoAssets.length],
);
const nextPhoto = useCallback(
() => setCurrentPhotoIndex((current) => (current + 1) % photoAssets.length),
[photoAssets.length],
);
const toggleSlideshow = useCallback(() => setPaused((value) => !value), []);
useWidgetRuntimeActions(
widgetRuntimeRef,
photoAssets.length > 1 ? { previousPhoto, nextPhoto, toggleSlideshow } : {},
);
if (!album) return <WidgetEmptyState />;
@@ -8,6 +8,47 @@ import { createWidgetDefinition, widgetQueryInputMatches } from "../../definitio
import { optionsBuilder } from "../../options";
import { ALL_PHOTOS_ALBUM_ID } from "./constants";
const createOptions = () =>
optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(3600),
withDescription: true,
}),
showPhotoInfo: factory.switch({
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
export const { definition, componentLoader } = createWidgetDefinition("immich-albumCarousel", {
icon: IconPhoto,
queryKey: [["widget", "immich", "getAlbum"]],
@@ -23,75 +64,31 @@ export const { definition, componentLoader } = createWidgetDefinition("immich-al
supportedIntegrations: ["immich"],
integrationsRequired: true,
maxIntegrations: 1,
contextActions({ widgetStateRef }) {
const call = (key: string) => () => {
const action = widgetStateRef?.current?.[key];
if (typeof action === "function") action();
};
const disabled = (key: string) => typeof widgetStateRef?.current?.[key] !== "function";
contextActions: ({ widgetRuntimeRef }) => {
const actions = widgetRuntimeRef.current.actions;
return [
{
key: "previousPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.previousPhoto"),
label: "widget.immich-albumCarousel.actions.previousPhoto",
icon: IconChevronLeft,
disabled: disabled("previousPhoto"),
onClick: call("previousPhoto"),
disabled: typeof actions.previousPhoto !== "function",
onClick: () => actions.previousPhoto?.(),
},
{
key: "nextPhoto",
label: (t) => t("widget.immich-albumCarousel.actions.nextPhoto"),
label: "widget.immich-albumCarousel.actions.nextPhoto",
icon: IconChevronRight,
disabled: disabled("nextPhoto"),
onClick: call("nextPhoto"),
disabled: typeof actions.nextPhoto !== "function",
onClick: () => actions.nextPhoto?.(),
},
{
key: "toggleSlideshow",
label: (t) => t("widget.immich-albumCarousel.actions.toggleSlideshow"),
label: "widget.immich-albumCarousel.actions.toggleSlideshow",
icon: IconPlayerPause,
disabled: disabled("toggleSlideshow"),
onClick: call("toggleSlideshow"),
disabled: typeof actions.toggleSlideshow !== "function",
onClick: () => actions.toggleSlideshow?.(),
},
];
},
createOptions() {
return optionsBuilder.from((factory) => ({
albumId: factory.integrationSelect({
defaultValue: ALL_PHOTOS_ALBUM_ID,
withDescription: true,
clearable: true,
useOptions: (integrationIds: string[]) => {
const t = useScopedI18n("widget.immich-albumCarousel");
const {
data = [],
isPending,
isError,
} = clientApi.widget.immich.getAlbums.useQuery(
{ integrationId: integrationIds[0] ?? "" },
{ enabled: integrationIds.length > 0, staleTime: 15 * 60 * 1000 },
);
return {
data: [
{ value: ALL_PHOTOS_ALBUM_ID, label: t("allPhotos") },
...data.map((album) => ({ value: album.id, label: album.albumName })),
],
isPending,
isError,
};
},
}),
rotationIntervalSeconds: factory.number({
defaultValue: 5,
validate: z.number().min(1).max(3600),
withDescription: true,
}),
showPhotoInfo: factory.switch({
defaultValue: false,
withDescription: true,
}),
randomizePhotos: factory.switch({
defaultValue: false,
withDescription: true,
}),
}));
},
createOptions,
}).withDynamicImport(() => import("./component"));
@@ -1,4 +1,9 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
// @vitest-environment jsdom
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { MantineProvider } from "@mantine/core";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import type { WidgetComponentProps } from "../definition";
import ImmichServerStatsWidget from "./server-stats/component";
@@ -26,20 +31,52 @@ vi.mock("@homarr/translation/client", () => ({
}));
describe("Immich album queries", () => {
let host: HTMLDivElement | undefined;
let root: ReturnType<typeof createRoot> | undefined;
beforeEach(() => vi.clearAllMocks());
test("server stats requests a bounded album response", () => {
ImmichServerStatsWidget({
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">);
afterEach(async () => {
if (root) await act(async () => root?.unmount());
host?.remove();
vi.unstubAllGlobals();
});
test("server stats requests a bounded album response", async () => {
vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
vi.stubGlobal("matchMedia", (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}));
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
await act(async () =>
root?.render(
createElement(
MantineProvider,
null,
createElement(ImmichServerStatsWidget, {
integrationIds: ["immich-id"],
options: { showUsers: true, showPhotos: true, showVideos: true, showStorage: true },
displayMode: "advanced",
width: 800,
height: 600,
isEditMode: false,
boardId: "board-id",
itemId: "item-id",
setOptions: vi.fn(),
} satisfies WidgetComponentProps<"immich-serverStats">),
),
),
);
expect(mocks.getAlbums).toHaveBeenCalledWith(
{ integrationId: "immich-id", limit: 50 },
@@ -2,11 +2,6 @@
padding: 12px;
border-radius: var(--mantine-radius-md);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-6));
transition: all 0.2s ease;
&:hover {
background-color: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-5));
}
}
.statItemDense {
@@ -1,8 +1,9 @@
"use client";
import React from "react";
import { Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { Box, Center, Group, Progress, ScrollArea, SimpleGrid, Stack, Text } from "@mantine/core";
import { IconDatabase, IconPhoto, IconUsers, IconVideo } from "@tabler/icons-react";
import { getQueryKey } from "@trpc/react-query";
import { clientApi } from "@homarr/api/client";
import { formatBytes } from "@homarr/common";
@@ -10,7 +11,9 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import type { WidgetComponentProps } from "../../definition";
import { setWidgetRuntimeQueries } from "../../definition";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../../common/query-state-indicator";
import { useWidgetRuntimeQueries } from "../../runtime-hooks";
import classes from "./component.module.css";
const MAX_ADVANCED_ALBUMS = 50;
@@ -21,7 +24,7 @@ export default function ImmichServerStatsWidget({
displayMode = "compact",
width,
height,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"immich-serverStats">) {
const t = useI18n();
const locale = useCurrentIntlLocale();
@@ -29,25 +32,32 @@ export default function ImmichServerStatsWidget({
const albumsInput = { ...input, limit: MAX_ADVANCED_ALBUMS };
const isAdvanced = displayMode === "advanced";
const albumsEnabled = isAdvanced && integrationIds.length > 0;
const { data: stats } = clientApi.widget.immich.getServerStats.useQuery(input);
const { data: albums = [] } = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
const statsQuery = clientApi.widget.immich.getServerStats.useQuery(input);
const stats = getUsableWidgetQueryData(statsQuery);
const albumsQuery = clientApi.widget.immich.getAlbums.useQuery(albumsInput, {
enabled: albumsEnabled,
staleTime: 15 * 60 * 1000,
});
setWidgetRuntimeQueries(widgetStateRef, [
{ path: ["widget", "immich", "getServerStats"], input },
...(albumsEnabled ? [{ path: ["widget", "immich", "getAlbums"], input: albumsInput }] : []),
const albums = albumsQuery.data ?? [];
useWidgetRuntimeQueries(widgetRuntimeRef, [
getQueryKey(clientApi.widget.immich.getServerStats, input, "query"),
...(albumsEnabled ? [getQueryKey(clientApi.widget.immich.getAlbums, albumsInput, "query")] : []),
]);
if (isInitialWidgetQueryPending(statsQuery)) return <WidgetQueryLoadingState />;
if (!stats) return <WidgetEmptyState />;
const statVisibility = getImmichStatVisibility(options, isAdvanced);
const statCount =
Number(options.showUsers) + Number(options.showPhotos) + Number(options.showVideos) + Number(options.showStorage);
Number(statVisibility.showUsers) +
Number(statVisibility.showPhotos) +
Number(statVisibility.showVideos) +
Number(statVisibility.showStorage);
const statsLayout = getImmichStatsLayout(width, height, statCount, isAdvanced);
const statsContent = (
<SimpleGrid cols={statsLayout.columns} spacing={statsLayout.dense ? 4 : "sm"}>
{options.showUsers && (
{statVisibility.showUsers && (
<StatItem
icon={<IconUsers size={20} />}
label={t("widget.immich-serverStats.users")}
@@ -55,7 +65,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showPhotos && (
{statVisibility.showPhotos && (
<StatItem
icon={<IconPhoto size={20} />}
label={t("widget.immich-serverStats.photos")}
@@ -63,7 +73,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showVideos && (
{statVisibility.showVideos && (
<StatItem
icon={<IconVideo size={20} />}
label={t("widget.immich-serverStats.videos")}
@@ -71,7 +81,7 @@ export default function ImmichServerStatsWidget({
dense={statsLayout.dense}
/>
)}
{options.showStorage && (
{statVisibility.showStorage && (
<StatItem
icon={<IconDatabase size={20} />}
label={t("widget.immich-serverStats.storage")}
@@ -84,8 +94,13 @@ export default function ImmichServerStatsWidget({
if (!isAdvanced) {
return (
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center">
<Stack gap="md" h="100%" p={statsLayout.dense ? "xs" : "md"} justify="center" pos="relative">
{statsContent}
{statsQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("widget.immich-serverStats.name")} />
</Box>
)}
</Stack>
);
}
@@ -95,22 +110,41 @@ export default function ImmichServerStatsWidget({
return (
<Stack gap="lg" h="100%" p="lg">
{statsContent}
<Group justify="space-between" wrap="nowrap">
<Text size="xs" c="dimmed">
{t("widget.immich-serverStats.albumLimit", { count: MAX_ADVANCED_ALBUMS })}
</Text>
<WidgetQueryErrorIndicator
error={statsQuery.error ?? albumsQuery.error}
label={t("widget.immich-serverStats.name")}
/>
</Group>
<ScrollArea style={{ flex: 1, minHeight: 0 }}>
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString(locale)}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
{isInitialWidgetQueryPending(albumsQuery) ? (
<WidgetQueryLoadingState />
) : albumsQuery.error && albumsQuery.data === undefined ? (
<Center h="100%" p="md">
<WidgetQueryErrorIndicator error={albumsQuery.error} label={t("widget.immich-serverStats.name")} />
</Center>
) : albums.length === 0 ? (
<WidgetEmptyState />
) : (
<SimpleGrid cols={width >= 900 ? 2 : 1} spacing="xs">
{sortedAlbums.map((album) => (
<Stack key={album.id} gap={4} p="xs">
<Group justify="space-between" wrap="nowrap">
<Text size="sm" fw={600} truncate>
{album.albumName}
</Text>
<Text size="xs" c="dimmed">
{album.assetCount.toLocaleString(locale)}
</Text>
</Group>
<Progress value={(album.assetCount / Math.max(maxAssets, 1)) * 100} size="sm" />
</Stack>
))}
</SimpleGrid>
)}
</ScrollArea>
</Stack>
);
@@ -163,3 +197,13 @@ export const getImmichStatsLayout = (width: number, height: number, itemCount: n
if (height < 220) return { columns: Math.min(count, 2), dense: true };
return { columns: Math.min(count, width >= 280 ? 2 : 1), dense };
};
export const getImmichStatVisibility = (
options: WidgetComponentProps<"immich-serverStats">["options"],
isAdvanced: boolean,
) => ({
showUsers: isAdvanced || options.showUsers,
showPhotos: isAdvanced || options.showPhotos,
showVideos: isAdvanced || options.showVideos,
showStorage: isAdvanced || options.showStorage,
});
+4
View File
@@ -74,8 +74,12 @@ export type {
WidgetOptionsSettings,
WidgetQueryMatcher,
WidgetQueryMatcherScope,
WidgetRuntimeActions,
WidgetRuntimeRef,
WidgetRuntimeState,
} from "./definition";
export {
createWidgetRuntimeState,
getWidgetQueryKeys,
getWidgetRuntimeQueries,
normalizeWidgetQuery,
@@ -35,10 +35,23 @@
}
}
.indexerLink {
display: flex;
align-items: center;
align-self: stretch;
min-width: 0;
flex: 1;
}
@media (any-pointer: coarse) {
.testAction {
opacity: 1;
}
.indexerRow {
min-height: 44px;
padding-block: 8px;
}
}
@media (prefers-reduced-motion: reduce) {
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo } from "react";
import { useMemo } from "react";
import { ActionIcon, Anchor, Badge, Card, Flex, Group, ScrollArea, Stack, Text, Tooltip } from "@mantine/core";
import { IconCircleCheck, IconCircleX, IconReportSearch, IconTestPipe } from "@tabler/icons-react";
import combineClasses from "clsx";
@@ -10,6 +10,9 @@ import { useRequiredBoard } from "@homarr/boards/context";
import { useI18n } from "@homarr/translation/client";
import type { WidgetComponentProps } from "../definition";
import { useWidgetRuntimeActions } from "../runtime-hooks";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData } from "../common/query-state";
import actionTargetClasses from "../common/action-target.module.css";
import classes from "./component.module.css";
@@ -18,36 +21,27 @@ export default function IndexerManagerWidget({
integrationIds,
width,
height,
displayMode,
isEditMode,
widgetStateRef,
widgetRuntimeRef,
}: WidgetComponentProps<"indexerManager">) {
const t = useI18n();
const { data: indexersData = [] } = clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds });
const indexersData =
getUsableWidgetQueryData(clientApi.widget.indexerManager.getIndexersStatus.useQuery({ integrationIds })) ?? [];
const utils = clientApi.useUtils();
const { mutate: testAll, isPending } = clientApi.widget.indexerManager.testAllIndexers.useMutation({
onSettled: () => void utils.widget.indexerManager.getIndexersStatus.invalidate(),
});
const board = useRequiredBoard();
const isAdvanced = displayMode === "advanced";
const hasSmallWidth = !isAdvanced && width < 256;
const isDense = !isAdvanced && (width < 280 || height < 180);
const hasSmallWidth = width < 256;
const isDense = width < 280 || height < 180;
const showHealthCounts = width >= 200 && height >= 100;
const allIndexers = useMemo(() => indexersData.flatMap((entry) => entry.indexers), [indexersData]);
const unavailableCount = allIndexers.filter(
(indexer) => indexer.status === false || indexer.enabled === false,
).length;
useEffect(() => {
if (!widgetStateRef) return;
widgetStateRef.current = {
...widgetStateRef.current,
testAllIndexers: () => testAll({ integrationIds }),
};
return () => {
if (widgetStateRef.current) delete widgetStateRef.current.testAllIndexers;
};
}, [integrationIds, testAll, widgetStateRef]);
useWidgetRuntimeActions(widgetRuntimeRef, { testAllIndexers: () => testAll({ integrationIds }) });
return (
<Flex
@@ -70,7 +64,7 @@ export default function IndexerManagerWidget({
{t("widget.indexerManager.title")}
</Text>
)}
{isAdvanced && (
{showHealthCounts && (
<Group gap={4} wrap="nowrap">
<Badge size="xs" color="green" variant="light">
{allIndexers.length - unavailableCount}
@@ -110,64 +104,61 @@ export default function IndexerManagerWidget({
<ScrollArea className="indexer-manager-list-scroll-area" h="100%" scrollbars="y">
{indexersData.map(({ integrationId, integrationName, indexers, error }) => (
<Stack gap={4} className={`indexer-manager-${integrationId}-list-container`} p={0} key={integrationId}>
{(isAdvanced || indexersData.length > 1) && (
{indexersData.length > 1 && (
<Group justify="space-between" wrap="nowrap" py={4}>
<Text size="xs" fw={600} truncate="end">
{integrationName}
</Text>
{error && (
<Tooltip label={error} multiline maw={360}>
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
</Tooltip>
<Badge size="xs" color="red" variant="light">
{t("common.error")}
</Badge>
)}
</Group>
)}
{indexers.map((indexer) => (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Anchor
className="indexer-manager-line-anchor"
href={indexer.url}
target={options.openIndexerSiteInNewTab ? "_blank" : "_self"}
rel={options.openIndexerSiteInNewTab ? "noopener noreferrer" : undefined}
style={{ minWidth: 0, flex: 1 }}
{indexers.map((indexer) => {
const href = getSafeApplicationUrl(indexer.url);
return (
<Group
className={`indexer-manager-line indexer-manager-${indexer.name} ${classes.indexerRow}`}
key={indexer.id}
justify="space-between"
gap="xs"
wrap="nowrap"
>
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
<Anchor
className={combineClasses("indexer-manager-line-anchor", classes.indexerLink)}
component={href ? "a" : "span"}
href={href}
target={href ? (options.openIndexerSiteInNewTab ? "_blank" : "_self") : undefined}
rel={href && options.openIndexerSiteInNewTab ? SAFE_NEW_TAB_REL : undefined}
title={href}
>
{indexer.name}
</Text>
</Anchor>
{isAdvanced && (
<Text size="xs" c="dimmed" truncate="end" style={{ flex: 1 }}>
{indexer.url}
</Text>
)}
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="#d9534f"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="#2ecc71"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
))}
<Text
className="indexer-manager-line-anchor-text"
c="dimmed"
size={hasSmallWidth ? "xs" : "sm"}
truncate="end"
>
{indexer.name}
</Text>
</Anchor>
{indexer.status === false || indexer.enabled === false ? (
<IconCircleX
className="indexer-manager-line-status-icon indexer-manager-line-icon-disabled"
color="var(--mantine-color-red-6)"
size={hasSmallWidth ? 12 : 16}
/>
) : (
<IconCircleCheck
className="indexer-manager-line-status-icon indexer-manager-line-icon-enabled"
color="var(--mantine-color-green-6)"
size={hasSmallWidth ? 12 : 16}
/>
)}
</Group>
);
})}
</Stack>
))}
</ScrollArea>
+13 -12
View File
@@ -5,31 +5,32 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
const createOptions = () =>
optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
export const { definition, componentLoader } = createWidgetDefinition("indexerManager", {
supportsAdvancedFocus: false,
icon: IconReportSearch,
contextActions: ({ widgetStateRef, context }) => [
contextActions: ({ widgetRuntimeRef, context }) => [
{
key: "test-all-indexers",
label: (t) => t("widget.indexerManager.testAll"),
label: "widget.indexerManager.testAll",
icon: IconTestPipe,
disabled:
context.isEditMode ||
!context.canInteractWithSelectedIntegrations ||
typeof widgetStateRef?.current?.testAllIndexers !== "function",
typeof widgetRuntimeRef.current.actions.testAllIndexers !== "function",
onClick: () => {
const action = widgetStateRef?.current?.testAllIndexers;
if (typeof action === "function") action();
widgetRuntimeRef.current.actions.testAllIndexers?.();
},
},
],
refetchInterval: null,
createOptions() {
return optionsBuilder.from((factory) => ({
openIndexerSiteInNewTab: factory.switch({
defaultValue: true,
}),
}));
},
createOptions,
supportedIntegrations: getIntegrationKindsByCategory("indexerManager"),
errors: {
INTERNAL_SERVER_ERROR: {
@@ -28,7 +28,11 @@ import { useCurrentIntlLocale, useI18n } from "@homarr/translation/client";
import type { TablerIcon } from "@homarr/ui";
import { OverflowBadge } from "@homarr/ui";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { getUsableWidgetQueryData, isInitialWidgetQueryPending } from "../common/query-state";
import { WidgetQueryErrorIndicator, WidgetQueryLoadingState } from "../common/query-state-indicator";
import classes from "./component.module.css";
export default function MediaReleasesWidget({
@@ -38,23 +42,33 @@ export default function MediaReleasesWidget({
height,
displayMode,
}: WidgetComponentProps<"mediaReleases">) {
const { data: releases = [] } = clientApi.widget.mediaRelease.getMediaReleases.useQuery({
integrationIds,
});
const t = useI18n();
const releasesQuery = clientApi.widget.mediaRelease.getMediaReleases.useQuery({ integrationIds });
const releases = getUsableWidgetQueryData(releasesQuery);
if (isInitialWidgetQueryPending(releasesQuery)) return <WidgetQueryLoadingState />;
if (!releases || releases.length === 0) return <WidgetEmptyState />;
const isAdvanced = displayMode === "advanced";
return (
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
<Box h="100%" pos="relative">
<ScrollArea h="100%">
<SimpleGrid cols={isAdvanced ? Math.max(1, Math.floor(width / 360)) : 1} p="xs" spacing="sm">
{releases.map((item, index) => (
<Fragment key={`${item.integration.id}:${item.id}`}>
{!isAdvanced && index !== 0 && options.layout === "poster" && <Divider />}
<Item item={item} options={options} isAdvanced={isAdvanced} width={width} height={height} />
</Fragment>
))}
</SimpleGrid>
</ScrollArea>
{releasesQuery.error && (
<Box pos="absolute" top={4} right={4}>
<WidgetQueryErrorIndicator error={releasesQuery.error} label={t("widget.mediaReleases.name")} />
</Box>
)}
</Box>
);
}
@@ -86,6 +100,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
const showPoster = (isAdvanced || options.layout === "poster") && !isTiny;
const showExtendedMetadata = isAdvanced || (!isCompact && width >= 360);
const showSide = isAdvanced || (!isTiny && (options.showType || options.showSource));
const href = getSafeApplicationUrl(item.href);
return (
<Tooltip
@@ -102,10 +117,10 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
>
<UnstyledButton
className={classes.item}
component="a"
href={item.href}
target="_blank"
rel="noopener noreferrer"
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
rel={href ? SAFE_NEW_TAB_REL : undefined}
pos="relative"
p={isAdvanced ? "sm" : options.layout === "poster" ? 4 : isTiny ? 4 : 6}
h="100%"
@@ -14,6 +14,8 @@ import { openMediaRequestSearch } from "@homarr/spotlight";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../../common/application-url";
import { getUsableWidgetQueryData } from "../../common/query-state";
import actionTargetClasses from "../../common/action-target.module.css";
import { IntegrationErrorIndicator } from "../../common/integration-error-indicator";
import type { WidgetComponentProps } from "../../definition";
@@ -27,25 +29,27 @@ export default function MediaServerWidget({
options,
width,
height,
displayMode,
}: WidgetComponentProps<"mediaRequests-requestList">) {
const interactIntegrationIds = new Set(
useIntegrationsWithInteractAccess()
.filter(({ id }) => integrationIds.includes(id))
.map(({ id }) => id),
);
const { data: mediaRequestData } = clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
});
const mediaRequestData = getUsableWidgetQueryData(
clientApi.widget.mediaRequests.getLatestRequests.useQuery({
integrationIds,
statuses:
options.statusFilter.length > 0
? options.statusFilter
: ["pending", "approved", "declined", "failed", "completed"],
recentDays: options.recentDays,
}),
);
if (!mediaRequestData) return <WidgetEmptyState />;
const { requests: mediaRequests, failedIntegrations } = mediaRequestData;
if (mediaRequests.length === 0 && failedIntegrations.length === 0) throw new NoIntegrationDataError();
const showIntegrationSource = new Set(mediaRequests.map(({ integrationId }) => integrationId)).size > 1;
return (
<Stack className={searchClasses.searchRoot} gap={0}>
@@ -65,9 +69,9 @@ export default function MediaServerWidget({
<MediaRequestCard
key={`${mediaRequest.integrationId}-${mediaRequest.id}`}
request={mediaRequest}
isTiny={displayMode !== "advanced" && (width <= 256 || height < 96)}
isDense={displayMode !== "advanced" && (width < 340 || height < 150)}
isAdvanced={displayMode === "advanced"}
isTiny={width <= 256 || height < 96}
isDense={width < 340 || height < 150}
showIntegrationSource={showIntegrationSource}
canInteract={interactIntegrationIds.has(mediaRequest.integrationId)}
options={options}
/>
@@ -100,14 +104,23 @@ interface MediaRequestCardProps {
request: RouterOutputs["widget"]["mediaRequests"]["getLatestRequests"]["requests"][number];
isTiny: boolean;
isDense: boolean;
isAdvanced: boolean;
showIntegrationSource: boolean;
canInteract: boolean;
options: WidgetComponentProps<"mediaRequests-requestList">["options"];
}
const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, options }: MediaRequestCardProps) => {
const MediaRequestCard = ({
request,
isTiny,
isDense,
showIntegrationSource,
canInteract,
options,
}: MediaRequestCardProps) => {
const board = useRequiredBoard();
const t = useScopedI18n("widget.mediaRequests-requestList");
const requestHref = getSafeApplicationUrl(request.href);
const requestedByHref = getSafeApplicationUrl(request.requestedBy?.link);
return (
<Card
@@ -148,10 +161,11 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
<Group gap="xs" justify="space-between" wrap="nowrap" className="mediaRequests-list-item-top-group">
<Anchor
className="mediaRequests-list-item-info-second-line mediaRequests-list-item-media-title"
href={request.href}
component={requestHref ? "a" : "span"}
href={requestHref}
c="var(--mantine-color-text)"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
target={requestHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz={isTiny ? "xs" : "sm"}
fw={600}
title={request.name}
@@ -186,7 +200,7 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
{t(`availability.${request.availability}`)}
</Badge>
)}
{isAdvanced && (
{showIntegrationSource && !isTiny && (
<Badge size="xs" variant="outline">
{request.integration.name}
</Badge>
@@ -201,13 +215,14 @@ const MediaRequestCard = ({ request, isTiny, isDense, isAdvanced, canInteract, o
/>
<Anchor
className="mediaRequests-list-item-request-user-name"
href={request.requestedBy?.link}
component={requestedByHref ? "a" : "span"}
href={requestedByHref}
c="dimmed"
target={options.linksTargetNewTab ? "_blank" : "_self"}
rel={options.linksTargetNewTab ? "noopener noreferrer" : undefined}
target={requestedByHref ? (options.linksTargetNewTab ? "_blank" : "_self") : undefined}
rel={requestedByHref && options.linksTargetNewTab ? SAFE_NEW_TAB_REL : undefined}
fz="xs"
truncate="end"
style={{ minWidth: 0, maxWidth: isAdvanced ? 180 : 100 }}
style={{ minWidth: 0, maxWidth: isDense ? 100 : 180 }}
>
{(request.requestedBy?.displayName ?? "") || "unknown"}
</Anchor>
@@ -252,7 +267,7 @@ const DecisionButtons = ({ requestId, integrationId, canInteract, alwaysVisible
className={`mediaRequests-list-item-pending-buttons ${classes.pendingActions} ${alwaysVisible ? classes.pendingActionsVisible : ""}`}
gap={4}
wrap="nowrap"
title={error?.message}
aria-invalid={Boolean(error)}
>
<Tooltip label={t("pending.approve")}>
<ActionIcon
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more