feat: modernize widget experience
This commit is contained in:
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ Advanced view is a complete PatchMon posture view. It always shows the complianc
|
||||
]}
|
||||
/>
|
||||
|
||||
<WidgetInteractionGuide />
|
||||
<WidgetInteractionGuide advancedView={false} />
|
||||
|
||||
### Adding the widget
|
||||
|
||||
|
||||
@@ -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'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'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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
@@ -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,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,
|
||||
}),
|
||||
);
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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,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,
|
||||
},
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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")}…`} />;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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: "" }),
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in new issue
Block a user