Compare commits

...
61 changed files with 1397 additions and 1054 deletions

No files matched your search

+8
View File
@@ -84,3 +84,11 @@ apps/docs/.docusaurus
apps/docs/build
.pnpm-store
benchmark-results
# local performance investigation (never ship benchmark data or restored backups)
.widget-performance/
.bench/
homarr-backup*.zip
*.sqlite-wal
*.sqlite-shm
/scripts/benchmarks/
+1
View File
@@ -46,6 +46,7 @@ const nextConfig: NextConfig = {
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
preloadEntriesOnStart: false,
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
},
@@ -39,12 +39,21 @@ interface ItemProps {
}
export const SectionContentItem = ({ item, integrations }: ItemProps) => {
const minWidth = useMinSize(item, "x");
const minHeight = useMinSize(item, "y");
if (item.type === "section") return <ContainerSectionContentItem item={item} />;
return (
<FixedGridItem key={item.id} item={item} minWidth={minWidth} minHeight={minHeight}>
{item.type === "item" && item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
{item.type === "item" ? <BoardItemContent item={item} /> : <BoardContainerSection section={item} />}
<FixedGridItem item={item}>
{item.kind !== "app" && <WidgetHoverOverlay item={item} integrations={integrations} />}
<BoardItemContent item={item} />
</FixedGridItem>
);
};
const ContainerSectionContentItem = ({ item }: { item: ContainerSectionItem }) => {
const { minWidth, minHeight } = useMinSize(item);
return (
<FixedGridItem item={item} minWidth={minWidth} minHeight={minHeight}>
<BoardContainerSection section={item} />
</FixedGridItem>
);
};
@@ -52,14 +61,18 @@ export const SectionContentItem = ({ item, integrations }: ItemProps) => {
/**
* Calculates a container's minimum width or height from its direct content.
*/
export const useMinSize = (item: ContainerSectionItem | SectionItem, direction: "x" | "y") => {
export const useMinSize = (item: ContainerSectionItem) => {
const { items, innerSections } = useSectionItems(item.id);
if (item.type === "item") return undefined;
const size = direction === "x" ? "width" : "height";
return Math.max(
1,
...items.map((childItem) => childItem[`${direction}Offset`] + childItem[size]),
...innerSections.map((childSection) => childSection[`${direction}Offset`] + childSection[size]),
);
return {
minWidth: Math.max(
1,
...items.map((child) => child.xOffset + child.width),
...innerSections.map((child) => child.xOffset + child.width),
),
minHeight: Math.max(
1,
...items.map((child) => child.yOffset + child.height),
...innerSections.map((child) => child.yOffset + child.height),
),
};
};
-509
View File
@@ -1,509 +0,0 @@
import type { Browser, BrowserContext, Page } from "@playwright/test";
import { chromium, expect } from "@playwright/test";
import { createId } from "@paralleldrive/cuid2";
import { eq } from "drizzle-orm";
import { stringify } from "superjson";
import { afterAll, beforeAll, describe, test } from "vitest";
import { hashPasswordAsync } from "../packages/auth/security";
import {
apps,
boardUserPermissions,
boards,
itemLayouts,
items,
layouts,
sections,
users,
} from "../packages/db/schema/sqlite";
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" };
const disabledModifierCredentials = { username: "modifier", password: "Comp(exP4sswOrd" };
const viewerCredentials = { username: "viewer", password: "Comp(exP4sswOrd" };
const boardName = "advanced-interactions-e2e";
describe("Board advanced interactions", () => {
let browser: Browser;
let baseUrl: string;
let stopContainerAsync: (() => Promise<void>) | undefined;
beforeAll(async () => {
const { db, localMountPath } = await createSqliteDbFileAsync();
const { userId: ownerId } = await seedAdminUserAsync(db, ownerCredentials);
await seedInteractionBoardAsync(db, ownerId);
await seedCredentialsUserAsync(db, {
...disabledModifierCredentials,
enableRightClickOnWidgets: false,
boardPermission: "modify",
});
await seedCredentialsUserAsync(db, {
...viewerCredentials,
enableRightClickOnWidgets: true,
boardPermission: "view",
});
const homarrContainer = await createHomarrContainer({
environment: { AUTH_PROVIDERS: "credentials" },
mounts: { "/appdata": localMountPath },
}).start();
stopContainerAsync = async () => {
await homarrContainer.stop();
};
baseUrl = `http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`;
browser = await chromium.launch();
}, 120_000);
afterAll(async () => {
await browser?.close();
await stopContainerAsync?.();
});
test("opens advanced view from Shift-hover, keyboard, and the widget context menu", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials);
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
const advancedViewTrigger = widget.getByRole("button", { name: "Open 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("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
const header = page.locator("header[data-advanced-focus-background]");
const boardMain = page.locator("main[data-advanced-focus-background]");
await compactSurface.evaluate((element) => {
element.setAttribute("data-lifecycle-probe", "same-instance");
});
await widget.hover();
await advancedViewTrigger.focus();
await page.keyboard.down("Shift");
await expect(previewSurface).toBeVisible({ timeout: 2_000 });
await expect(previewSurface).toHaveCSS("animation-name", "none");
const compactBounds = await widget.boundingBox();
const advancedBounds = await previewSurface.boundingBox();
if (!compactBounds || !advancedBounds) throw new Error("Advanced widget has no bounds");
expect(advancedBounds?.width).toBeGreaterThan(compactBounds?.width ?? 0);
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(advancedViewTrigger).toBeFocused();
await expect(header).not.toHaveAttribute("inert", "");
await expect(boardMain).not.toHaveAttribute("inert", "");
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(previewSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(portalledMenuItem).toBeHidden();
await expect(previewSurface).toBeVisible();
const otherBounds = await otherWidget.boundingBox();
expect(otherBounds).not.toBeNull();
if (!otherBounds) throw new Error("Bookmarks widget has no bounds");
await previewSurface.evaluate(
(element, position) => {
const probe = document.createElement("button");
probe.dataset.interactivePreviewProbe = "true";
probe.style.cssText = `position:absolute;z-index:20;width:40px;height:40px;left:${position.left}px;top:${position.top}px`;
element.append(probe);
},
{
left: otherBounds.x + otherBounds.width / 2 - advancedBounds.x - 20,
top: otherBounds.y + otherBounds.height / 2 - advancedBounds.y - 20,
},
);
const interactivePreviewProbe = previewSurface.locator("[data-interactive-preview-probe='true']");
await interactivePreviewProbe.hover();
await expect(previewSurface).toBeVisible();
await expect(bookmarksPreviewSurface).toBeHidden();
await interactivePreviewProbe.evaluate((element) => element.remove());
await page.mouse.move(compactBounds.x + 4, compactBounds.y + 4);
await page.mouse.move(otherBounds.x + otherBounds.width / 2, otherBounds.y + otherBounds.height / 2);
await expect(previewSurface).toBeVisible();
await expect(bookmarksPreviewSurface).toBeHidden();
await page.mouse.move(4, 4);
await expect(previewSurface).toBeHidden();
await otherWidget.hover({ position: { x: 4, y: 4 } });
await expect(bookmarksPreviewSurface).toBeVisible({ timeout: 2_000 });
await page.keyboard.up("Shift");
await expect(bookmarksPreviewSurface).toBeHidden();
await expect(dimmingOverlay).toBeHidden();
await expect(compactSurface).toHaveAttribute("data-lifecycle-probe", "same-instance");
await advancedViewTrigger.focus();
await page.keyboard.press("Shift+Enter");
await expect(manualSurface).toBeVisible();
await expect(manualSurface).toHaveAttribute("aria-modal", "true");
await expect(header).toHaveAttribute("inert", "");
await expect(boardMain).toHaveAttribute("inert", "");
expect(await manualSurface.evaluate((element) => element.closest("[data-grid-item-id]"))).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 manualSurface.evaluate((element) => {
const mainViewport = document.createElement("div");
mainViewport.dataset.scrollbars = "y";
mainViewport.style.cssText = "height:80px;overflow-y:auto";
const probe = document.createElement("div");
probe.dataset.shiftScrollProbe = "true";
probe.style.cssText = "height:80px;overflow-y:auto";
probe.innerHTML = '<svg data-shift-scroll-target="true"></svg><div style="height:600px"></div>';
probe.addEventListener("wheel", (event) => event.stopPropagation());
mainViewport.append(probe);
mainViewport.insertAdjacentHTML("beforeend", '<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 = manualSurface.locator("[data-shift-scroll-probe='true']");
await page.keyboard.down("Shift");
const pixelEventCancelled = await scrollProbe.evaluate(
(element) =>
!element.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 120 }),
),
);
expect(pixelEventCancelled).toBe(true);
await scrollProbe.evaluate((element) => {
element.scrollTop = 0;
});
const lineEventCancelled = await scrollProbe
.locator("[data-shift-scroll-target='true']")
.evaluate(
(element) =>
!element.dispatchEvent(
new WheelEvent("wheel", { bubbles: true, cancelable: true, shiftKey: true, deltaX: 3, deltaMode: 1 }),
),
);
await page.keyboard.up("Shift");
expect(lineEventCancelled).toBe(true);
expect(await scrollProbe.evaluate((element) => element.scrollTop)).toBeGreaterThanOrEqual(48);
await scrollProbe.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(
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 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(portalledMenuItem).toBeHidden();
await expect(manualSurface).toBeVisible();
await page.keyboard.press("Escape");
await expect(manualSurface).toBeHidden();
await expect(advancedViewTrigger).toBeFocused();
await expect(header).not.toHaveAttribute("inert", "");
await expect(boardMain).not.toHaveAttribute("inert", "");
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(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(advancedViewTrigger).toBeFocused();
for (const compactOnlyKind of ["app", "iframe"]) {
const compactOnlyWidget = page
.locator(`[data-grid-item-id][data-kind='${compactOnlyKind}'] [data-advanced-focus-source]`)
.first();
await expect(compactOnlyWidget.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
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("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("[data-grid-item-id][data-kind='iframe'] [data-advanced-focus-source]").first();
const iframe = iframeSlot.locator("iframe");
await iframe.evaluate((element) => {
(element as HTMLIFrameElement).srcdoc = '<label>State <input id="state" value="initial"></label>';
(element as HTMLIFrameElement).dataset.advancedFocusProbe = "true";
});
const frame = page.frameLocator("iframe[data-advanced-focus-probe='true']");
await frame.locator("#state").fill("changed");
await expect(iframeSlot.locator("[data-advanced-focus-trigger]")).toHaveCount(0);
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");
const bookmarksSlot = page
.locator("[data-grid-item-id][data-kind='bookmarks'] [data-advanced-focus-source]")
.first();
await bookmarksSlot.getByRole("button", { name: "Open advanced view" }).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("dialog", { name: "Bookmarks advanced view" })).toBeHidden();
await expect(page.locator("[data-grid-item-id][data-kind='iframe'] iframe")).toBeVisible();
} finally {
await context.close();
}
}, 60_000);
test("keeps advanced controls below the fixed header on short screens", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, ownerCredentials, { width: 1366, height: 768 });
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.getByRole("button", { name: "Open advanced view" }).focus();
await page.keyboard.press("Shift+Enter");
const closeButton = page.getByRole("button", { name: "Close advanced view" });
const bounds = await closeButton.boundingBox();
expect(bounds).not.toBeNull();
if (!bounds) return;
expect(bounds.x).toBeGreaterThanOrEqual(0);
expect(bounds.y).toBeGreaterThanOrEqual(60);
expect(bounds.width).toBeGreaterThanOrEqual(44);
expect(bounds.height).toBeGreaterThanOrEqual(44);
expect(bounds.x + bounds.width).toBeLessThanOrEqual(1366);
expect(bounds.y + bounds.height).toBeLessThanOrEqual(768);
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();
if (surfaceBounds && contentBounds) {
expect(contentBounds.y - surfaceBounds.y).toBeLessThan(32);
expect(bounds.x >= surfaceBounds.x + surfaceBounds.width || bounds.x + bounds.width <= surfaceBounds.x).toBe(
true,
);
}
await closeButton.click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeHidden();
} finally {
await context.close();
}
}, 60_000);
test("honors the user setting gate even when the user can modify the board", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, disabledModifierCredentials);
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await expect(page.getByRole("menuitem", { name: "Open advanced view" })).toHaveCount(0);
} finally {
await context.close();
}
}, 60_000);
test("keeps read-only advanced viewing", async () => {
const { context, page } = await openBoardAsync(browser, baseUrl, viewerCredentials);
try {
const widget = page.locator("[data-grid-item-id][data-kind='clock'] [data-advanced-focus-source]").first();
await widget.click({ button: "right" });
await page.getByRole("menuitem", { name: "Open advanced view" }).click();
await expect(page.getByRole("dialog", { name: "Date and time advanced view" })).toBeVisible();
await page.keyboard.press("Escape");
} finally {
await context.close();
}
}, 60_000);
});
const openBoardAsync = async (
browser: Browser,
baseUrl: string,
credentials: { username: string; password: string },
viewport = { width: 1280, height: 900 },
): Promise<{ context: BrowserContext; page: Page }> => {
const context = await browser.newContext({ reducedMotion: "reduce", viewport });
const page = await context.newPage();
await loginAsync({ page, baseUrl, credentials, destination: `/boards/${boardName}` });
await expect(page.locator(".clock-wrapper").first()).toBeVisible({
timeout: 15_000,
});
return { context, page };
};
const seedInteractionBoardAsync = async (db: SqliteDatabase, ownerId: string) => {
const boardId = createId();
const baseLayoutId = createId();
const desktopLayoutId = createId();
const sectionId = createId();
const clockItemId = createId();
const bookmarksItemId = createId();
const iframeItemId = createId();
const appItemId = createId();
const appId = createId();
await db.insert(boards).values({ id: boardId, name: boardName, creatorId: ownerId, isPublic: false });
await db.insert(apps).values({
id: appId,
name: "Example app",
iconUrl: "/favicon.ico",
href: "https://example.com",
});
await db.insert(layouts).values([
{ id: baseLayoutId, name: "Base", columnCount: 12, breakpoint: 0, boardId },
{ id: desktopLayoutId, name: "Desktop", columnCount: 12, breakpoint: 1000, boardId },
]);
await db.insert(sections).values({ id: sectionId, kind: "empty", xOffset: 0, yOffset: 0, boardId });
await db.insert(items).values([
{
id: clockItemId,
kind: "clock",
boardId,
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",
boardId,
options: stringify({ embedUrl: "http://127.0.0.1:9", allowScrolling: true }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
{
id: appItemId,
kind: "app",
boardId,
options: stringify({ appId, openInNewTab: true, showTitle: true, pingEnabled: false }),
advancedOptions: stringify({ title: null, customCssClasses: [], borderColor: "" }),
},
]);
await db.insert(itemLayouts).values(
[baseLayoutId, desktopLayoutId].flatMap((layoutId) => [
{
itemId: clockItemId,
sectionId,
layoutId,
xOffset: 0,
yOffset: 0,
width: 4,
height: 3,
},
{
itemId: bookmarksItemId,
sectionId,
layoutId,
xOffset: 0,
yOffset: 3,
width: 4,
height: 3,
},
{
itemId: iframeItemId,
sectionId,
layoutId,
xOffset: 8,
yOffset: 0,
width: 4,
height: 3,
},
{
itemId: appItemId,
sectionId,
layoutId,
xOffset: 4,
yOffset: 0,
width: 3,
height: 3,
},
]),
);
await db
.update(users)
.set({ homeBoardId: boardId, completedBoardTour: true, completedManageTour: true })
.where(eq(users.id, ownerId));
};
const seedCredentialsUserAsync = async (
db: SqliteDatabase,
input: {
username: string;
password: string;
enableRightClickOnWidgets: boolean;
boardPermission: "view" | "modify";
},
) => {
const userId = createId();
const board = await db.query.boards.findFirst({ where: eq(boards.name, boardName) });
if (!board) throw new Error("Interaction board was not seeded");
await db.insert(users).values({
id: userId,
name: input.username,
provider: "credentials",
password: await hashPasswordAsync(input.password),
enableRightClickOnWidgets: input.enableRightClickOnWidgets,
homeBoardId: board.id,
completedBoardTour: true,
completedManageTour: true,
});
await db.insert(boardUserPermissions).values({
boardId: board.id,
userId,
permission: input.boardPermission,
});
};
@@ -11,11 +11,14 @@ import { filteringStatusSchema, statsResponseSchema, statusResponseSchema } from
export class AdGuardHomeIntegration extends Integration implements DnsHoleSummaryIntegration {
public async getSummaryAsync(): Promise<DnsHoleSummary> {
const statsResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/stats"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
const headers = {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
};
const [statsResponse, statusResponse, filteringStatusResponse] = await Promise.all([
fetchWithTrustedCertificatesAsync(this.url("/control/stats"), { headers }),
fetchWithTrustedCertificatesAsync(this.url("/control/status"), { headers }),
fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), { headers }),
]);
if (!statsResponse.ok) {
throw new Error(
@@ -23,24 +26,12 @@ export class AdGuardHomeIntegration extends Integration implements DnsHoleSummar
);
}
const statusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!statusResponse.ok) {
throw new Error(
`Failed to fetch status for ${this.integration.name} (${this.integration.id}): ${statusResponse.statusText}`,
);
}
const filteringStatusResponse = await fetchWithTrustedCertificatesAsync(this.url("/control/filtering/status"), {
headers: {
Authorization: `Basic ${this.getAuthorizationHeaderValue()}`,
},
});
if (!filteringStatusResponse.ok) {
throw new Error(
`Failed to fetch filtering status for ${this.integration.name} (${this.integration.id}): ${filteringStatusResponse.statusText}`,
@@ -0,0 +1,95 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { AdGuardHomeIntegration } from "../adguard-home-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new AdGuardHomeIntegration({
id: "adguard-1",
name: "AdGuard Home",
url: "https://adguard.example.com",
externalUrl: null,
decryptedSecrets: [
{ kind: "username", value: "admin" },
{ kind: "password", value: "secret" },
],
});
describe("AdGuardHomeIntegration", () => {
beforeEach(() => vi.clearAllMocks());
test("starts all summary requests before awaiting any response", async () => {
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
const gate = Promise.withResolvers<Response>();
requestGates.set(path, gate);
return gate.promise;
});
const resultPromise = integration.getSummaryAsync();
await vi.waitFor(() => {
expect([...requestGates.keys()]).toEqual(["/control/stats", "/control/status", "/control/filtering/status"]);
});
requestGates.get("/control/stats")?.resolve(jsonResponse(stats));
requestGates.get("/control/status")?.resolve(jsonResponse(status));
requestGates.get("/control/filtering/status")?.resolve(jsonResponse(filteringStatus));
await expect(resultPromise).resolves.toMatchObject({
status: "enabled",
adsBlockedToday: 20,
dnsQueriesToday: 100,
domainsBeingBlocked: 12,
});
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
const stats = {
time_units: "hours",
top_queried_domains: [],
top_clients: [],
top_blocked_domains: [],
dns_queries: [40, 60],
blocked_filtering: [5, 15],
replaced_safebrowsing: [],
replaced_parental: [],
num_dns_queries: 100,
num_blocked_filtering: 20,
num_replaced_safebrowsing: 0,
num_replaced_safesearch: 0,
num_replaced_parental: 0,
avg_processing_time: 0.01,
};
const status = {
version: "1.0.0",
language: "en",
dns_addresses: ["0.0.0.0"],
dns_port: 53,
http_port: 3000,
protection_enabled: true,
dhcp_available: false,
running: true,
};
const filteringStatus = {
filters: [
{ url: "https://example.com/filter.txt", name: "Enabled", id: 1, rules_count: 12, enabled: true },
{ url: "https://example.com/disabled.txt", name: "Disabled", id: 2, rules_count: 50, enabled: false },
],
};
@@ -26,13 +26,16 @@ export class DashDotIntegration extends Integration implements ISystemHealthMoni
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const info = await this.getInfoAsync();
const cpuLoad = await this.getCurrentCpuLoadAsync();
const memoryLoad = await this.getCurrentMemoryLoadAsync();
const storageLoad = await this.getCurrentStorageLoadAsync();
const networkLoad = await this.getCurrentNetworkLoadAsync();
const gpuLoad = await this.getCurrentGpuLoadAsync();
const [info, cpuLoad, memoryLoad, storageLoad, networkLoad, gpuLoad] = await Promise.all([
this.getInfoAsync(),
this.getCurrentCpuLoadAsync(),
this.getCurrentMemoryLoadAsync(),
this.getCurrentStorageLoadAsync(),
this.getCurrentNetworkLoadAsync(),
this.getCurrentGpuLoadAsync(),
]);
// Read history after the CPU request has persisted its newest sample.
const channel = this.getChannel();
const history = await channel.getSliceUntilTimeAsync(dayjs().subtract(15, "minutes").toDate());
@@ -0,0 +1,114 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { createChannelEventHistoryOld } from "../../../../redis/src/lib/channel";
import { DashDotIntegration } from "../dashdot-integration";
vi.mock("@homarr/redis", () => ({}));
vi.mock("@homarr/core/infrastructure/http", () => ({ fetchWithTrustedCertificatesAsync: vi.fn() }));
vi.mock("../../../../redis/src/lib/channel", () => ({ createChannelEventHistoryOld: vi.fn() }));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const mockCreateHistory = vi.mocked(createChannelEventHistoryOld);
const channel = {
pushAsync: vi.fn(),
getSliceUntilTimeAsync: vi.fn(),
};
const integration = new DashDotIntegration({
id: "dashdot-1",
name: "DashDot",
url: "https://dashdot.example.com",
externalUrl: null,
decryptedSecrets: [],
});
describe("DashDotIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
mockCreateHistory.mockReturnValue(channel as never);
});
test("starts independent requests together and reads history after the CPU sample is persisted", async () => {
const requestGates = new Map<string, ReturnType<typeof Promise.withResolvers<Response>>>();
const persistenceGate = Promise.withResolvers<void>();
const order: string[] = [];
mockFetch.mockImplementation((url) => {
const path = new URL(url.toString()).pathname;
const gate = Promise.withResolvers<Response>();
requestGates.set(path, gate);
return gate.promise;
});
channel.pushAsync.mockImplementation(async () => {
order.push("push-start");
await persistenceGate.promise;
order.push("push-end");
});
channel.getSliceUntilTimeAsync.mockImplementation(async () => {
order.push("history");
return [cpuLoad];
});
const resultPromise = integration.getSystemInfoAsync();
await vi.waitFor(() => {
expect([...requestGates.keys()]).toEqual([
"/info",
"/load/cpu",
"/load/ram",
"/load/storage",
"/load/network",
"/load/gpu",
]);
});
requestGates.get("/info")?.resolve(jsonResponse(serverInfo));
requestGates.get("/load/cpu")?.resolve(jsonResponse(cpuLoad));
requestGates.get("/load/ram")?.resolve(jsonResponse({ load: 4_000 }));
requestGates.get("/load/storage")?.resolve(jsonResponse([250]));
requestGates.get("/load/network")?.resolve(jsonResponse({ up: 20, down: 10 }));
requestGates.get("/load/gpu")?.resolve(jsonResponse({ layout: [{ load: 30, memory: 40 }] }));
await vi.waitFor(() => expect(channel.pushAsync).toHaveBeenCalledOnce());
expect(channel.getSliceUntilTimeAsync).not.toHaveBeenCalled();
persistenceGate.resolve();
const result = await resultPromise;
expect(order).toEqual(["push-start", "push-end", "history"]);
expect(result).toMatchObject({
cpuUtilization: 15,
memUsedInBytes: 4_000,
memAvailableInBytes: 12_000,
network: { up: 20, down: 10 },
fileSystem: [{ used: "250", available: "750", percentage: 25 }],
gpu: [{ memoryUtilization: 40, processorUtilization: 30 }],
});
});
});
const jsonResponse = (body: unknown) => new Response(JSON.stringify(body), { status: 200 });
const cpuLoad = [
{ load: 10, temp: 40 },
{ load: 20, temp: 50 },
];
const serverInfo = {
os: { distro: "Linux", kernel: "6.0", release: "1", uptime: 60 },
cpu: { brand: "Intel", model: "Test CPU" },
ram: { size: 16_000 },
storage: [{ size: 1_000, disks: [{ device: "sda", brand: "Test", type: "SSD" }] }],
gpu: { layout: [{ brand: "Test GPU" }] },
};
@@ -23,9 +23,11 @@ export class NzbGetIntegration extends Integration implements IDownloadClientInt
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const queue = await this.nzbGetApiCallAsync("listgroups");
const history = await this.nzbGetApiCallAsync("history");
const nzbGetStatus = await this.nzbGetApiCallAsync("status");
const [queue, history, nzbGetStatus] = await Promise.all([
this.nzbGetApiCallAsync("listgroups"),
this.nzbGetApiCallAsync("history"),
this.nzbGetApiCallAsync("status"),
]);
const status: DownloadClientStatus = {
paused: nzbGetStatus.DownloadPaused,
rates: { down: nzbGetStatus.DownloadRate },
@@ -0,0 +1,75 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../../base/types";
import { NzbGetIntegration } from "../nzbget-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [
{ kind: "username", value: "test-user" },
{ kind: "password", value: "test-password" },
];
const createIntegration = () =>
new NzbGetIntegration({
id: "test-nzbget",
name: "Test NZBGet",
url: "https://nzbget.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
describe("NzbGetIntegration.getClientJobsAndStatusAsync", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("starts queue, history, and status requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedMethods: string[] = [];
const results = {
listgroups: [],
history: [],
status: { DownloadPaused: false, DownloadRate: 0 },
} as const;
mockFetch.mockImplementation(async (_url, init) => {
const method = (JSON.parse(String(init?.body)) as { method: keyof typeof results }).method;
startedMethods.push(method);
await releaseRequests.promise;
return new Response(JSON.stringify({ result: results[method] }), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
try {
await vi.waitFor(() => {
expect(startedMethods).toEqual(expect.arrayContaining(["listgroups", "history", "status"]));
expect(startedMethods).toHaveLength(3);
});
} finally {
releaseRequests.resolve();
}
await expect(resultPromise).resolves.toStrictEqual({
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
items: [],
});
});
});
@@ -25,12 +25,16 @@ export class SabnzbdIntegration extends Integration implements IDownloadClientIn
public async getClientJobsAndStatusAsync(input: { limit: number }): Promise<DownloadClientJobsAndStatus> {
const type = "usenet";
const { queue } = await queueSchema.parseAsync(
await this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }),
);
const { history } = await historySchema.parseAsync(
await this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }),
);
const [queueResult, historyResult] = await Promise.all([
this.sabNzbApiCallAsync("queue", { limit: input.limit.toString() }).then((result) =>
queueSchema.parseAsync(result),
),
this.sabNzbApiCallAsync("history", { limit: input.limit.toString() }).then((result) =>
historySchema.parseAsync(result),
),
]);
const { queue } = queueResult;
const { history } = historyResult;
const status: DownloadClientStatus = {
paused: queue.paused,
rates: { down: Math.floor(Number(queue.kbpersec) * 1024) }, //Actually rounded kiBps ()
@@ -0,0 +1,71 @@
// @vitest-environment node
import { Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import type { IntegrationSecret } from "../../../base/types";
import { SabnzbdIntegration } from "../sabnzbd-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "test-api-key" }];
const createIntegration = () =>
new SabnzbdIntegration({
id: "test-sabnzbd",
name: "Test SABnzbd",
url: "https://sabnzbd.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
describe("SabnzbdIntegration.getClientJobsAndStatusAsync", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("starts queue and history requests together", async () => {
const releaseRequests = Promise.withResolvers<void>();
const startedModes: string[] = [];
const results = {
queue: { queue: { paused: false, kbpersec: "0", slots: [] } },
history: { history: { slots: [] } },
} as const;
mockFetch.mockImplementation(async (url) => {
const mode = new URL(String(url)).searchParams.get("mode") as keyof typeof results;
startedModes.push(mode);
await releaseRequests.promise;
return new Response(JSON.stringify(results[mode]), {
status: 200,
headers: { "content-type": "application/json" },
});
});
const resultPromise = createIntegration().getClientJobsAndStatusAsync({ limit: 10 });
try {
await vi.waitFor(() => {
expect(startedModes).toEqual(expect.arrayContaining(["queue", "history"]));
expect(startedModes).toHaveLength(2);
});
} finally {
releaseRequests.resolve();
}
await expect(resultPromise).resolves.toStrictEqual({
status: { paused: false, rates: { down: 0 }, types: ["usenet"] },
items: [],
});
});
});
@@ -45,7 +45,7 @@ export interface ImmichAsset {
}
const logger = createLogger({ module: "immich-integration" });
const randomPhotoCount = 50;
const carouselPhotoCount = 50;
export class ImmichIntegration extends Integration {
constructor(integration: IntegrationInput) {
@@ -74,7 +74,7 @@ export class ImmichIntegration extends Integration {
const assets = await searchRandom(
{
randomSearchDto: {
size: randomPhotoCount,
size: carouselPhotoCount,
type: AssetTypeEnum.Image,
visibility: AssetVisibility.Timeline,
},
@@ -125,13 +125,15 @@ export class ImmichIntegration extends Integration {
let nextPage: string | null = null;
do {
const metadataSearchDto: MetadataSearchDto = { albumIds: [albumId] };
if (nextPage !== null) {
metadataSearchDto.page = Number(nextPage);
}
const searchResult = await searchAssets({ metadataSearchDto }, requestOptions);
assets.push(...searchResult.assets.items);
nextPage = searchResult.assets.nextPage;
const metadataSearchDto: MetadataSearchDto = {
albumIds: [albumId],
type: AssetTypeEnum.Image,
};
if (nextPage !== null) metadataSearchDto.page = Number(nextPage);
const result = await searchAssets({ metadataSearchDto }, requestOptions);
assets.push(...result.assets.items);
nextPage = result.assets.nextPage;
} while (nextPage !== null);
return assets;
@@ -0,0 +1,86 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { IntegrationSecret } from "../../base/types";
import { ImmichIntegration } from "../immich-integration";
const mocks = vi.hoisted(() => ({
getAlbumInfo: vi.fn(),
searchAssets: vi.fn(),
createImageAsync: vi.fn<(url: string) => Promise<string>>(),
}));
vi.mock("@immich/sdk", () => ({
AssetMediaSize: { Preview: "preview" },
AssetTypeEnum: { Image: "IMAGE" },
AssetVisibility: { Timeline: "timeline" },
getAlbumInfo: mocks.getAlbumInfo,
getAllAlbums: vi.fn(),
getMyUser: vi.fn(),
getServerStatistics: vi.fn(),
init: vi.fn(),
searchAssets: mocks.searchAssets,
searchRandom: vi.fn(),
searchUsers: vi.fn(),
}));
vi.mock("@homarr/image-proxy", () => ({
ImageProxy: class {
createImageAsync = mocks.createImageAsync;
},
}));
const secrets: IntegrationSecret[] = [{ kind: "apiKey", value: "immich-token" }];
const integration = new ImmichIntegration({
id: "test-immich",
name: "Test Immich",
url: "https://immich.example.com",
externalUrl: null,
decryptedSecrets: secrets,
});
const createAsset = (index: number) => ({
id: `asset-${index}`,
type: "IMAGE",
thumbhash: null,
fileCreatedAt: "2026-01-01T00:00:00.000Z",
fileModifiedAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
});
describe("ImmichIntegration.getAlbumAsync", () => {
beforeEach(() => {
mocks.getAlbumInfo.mockReset();
mocks.searchAssets.mockReset();
mocks.createImageAsync.mockClear();
});
test("keeps every image across paginated album results", async () => {
mocks.getAlbumInfo.mockResolvedValue({ id: "album-id", albumName: "Large album" });
mocks.searchAssets
.mockResolvedValueOnce({
assets: {
items: Array.from({ length: 6 }, (_, index) => createAsset(index)),
nextPage: "2",
},
})
.mockResolvedValueOnce({
assets: {
items: Array.from({ length: 6 }, (_, index) => createAsset(index + 6)),
nextPage: null,
},
});
mocks.createImageAsync.mockImplementation(async (url) => `proxied:${url}`);
const album = await integration.getAlbumAsync("album-id");
expect(mocks.searchAssets).toHaveBeenCalledTimes(2);
expect(mocks.searchAssets.mock.calls.map(([input]) => input)).toStrictEqual([
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE" } },
{ metadataSearchDto: { albumIds: ["album-id"], type: "IMAGE", page: 2 } },
]);
expect(album.assets.map(({ id }) => id)).toStrictEqual(Array.from({ length: 12 }, (_, index) => `asset-${index}`));
expect(mocks.createImageAsync).toHaveBeenCalledTimes(12);
});
});
@@ -1,5 +1,12 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
vi.hoisted(() => {
process.env.SKIP_ENV_VALIDATION = "true";
process.env.SECRET_ENCRYPTION_KEY = "ff3f4f7ce30e870c9630de9e5d244ffa81101a24ed0dfe5f064beb53a7e684f1";
});
import { IntegrationRequestError } from "../../base/errors/http/integration-request-error";
import type { IntegrationSecret } from "../../base/types";
@@ -8,7 +15,7 @@ const ws = vi.hoisted(() => {
const constructedUrls: string[] = [];
// A failure with a `code` emits a coded TLS-style error; without one it mimics a missing endpoint (HTTP 404).
const failures: { path: string; code?: string }[] = [];
let responder: (method: string, params: unknown[]) => unknown = () => undefined;
let responder: (method: string, params: unknown[]) => unknown | Promise<unknown> = () => undefined;
type Listener = (...args: unknown[]) => void;
@@ -58,12 +65,15 @@ const ws = vi.hoisted(() => {
return;
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
void Promise.resolve(responder(payload.method as string, (payload.params as unknown[] | undefined) ?? [])).then(
(result) => {
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
},
);
}
close() {
@@ -85,7 +95,7 @@ const ws = vi.hoisted(() => {
sentPayloads,
constructedUrls,
failures,
setResponder: (fn: (method: string, params: unknown[]) => unknown) => {
setResponder: (fn: (method: string, params: unknown[]) => unknown | Promise<unknown>) => {
responder = fn;
},
};
@@ -210,6 +220,34 @@ describe("TrueNasIntegration", () => {
});
});
test("starts independent system requests together while preserving the network interface dependency", async () => {
const gates = new Map<string, ReturnType<typeof Promise.withResolvers<unknown>>>();
ws.setResponder((method) => {
if (method.startsWith("auth.")) return true;
const gate = Promise.withResolvers<unknown>();
gates.set(method, gate);
return gate.promise;
});
const integration = createIntegration(credentials);
const resultPromise = integration.getSystemInfoAsync();
await vi.waitFor(() => {
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
});
expect(gates.has("reporting.netdata_get_data")).toBe(false);
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
await expect(resultPromise).resolves.toMatchObject({
version: "TrueNAS-SCALE-24.10",
network: { up: 20_000, down: 10_000 },
});
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
@@ -24,15 +24,16 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
}
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
const systemInformation = await this.getSystemInformationAsync();
const reporting = await this.getReportingAsync();
const [systemInformation, reporting, datasets, netdata] = await Promise.all([
this.getSystemInformationAsync(),
this.getReportingAsync(),
this.getPoolsAsync(),
this.getReportingNetdataAsync(),
]);
const cpuData = this.extractLatestReportingData(reporting, "cpu");
const cpuTempData = this.extractLatestReportingData(reporting, "cputemp");
const memoryData = this.extractLatestReportingData(reporting, "memory");
const datasets = await this.getPoolsAsync();
const netdata = await this.getReportingNetdataAsync();
const upload = this.extractNetworkTrafficData(netdata, 2); // Index 2 is "sent"
const download = this.extractNetworkTrafficData(netdata, 1); // Index 1 is "received"
@@ -0,0 +1,199 @@
// @vitest-environment node
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { UmamiIntegration } from "../umami-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const PAGE_SIZE = 5_000;
const WEBSITE_ID = "site-1";
interface RequestedUrl {
path: string;
params: URLSearchParams;
}
const requestedUrls: RequestedUrl[] = [];
const setupMockFetch = (handler: (requested: RequestedUrl) => unknown) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const parsed = new URL(urlString);
const requested = { path: parsed.pathname, params: parsed.searchParams };
requestedUrls.push(requested);
return Promise.resolve(
new Response(JSON.stringify(handler(requested) ?? []), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
});
};
const createIntegration = () =>
new UmamiIntegration({
id: "test-umami",
name: "Test Umami",
url: "https://umami.example.com/api",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
const rawEvent = (index: number, eventName: string, hour: number) => ({
id: `event-${index}`,
websiteId: WEBSITE_ID,
sessionId: `session-${index}`,
eventType: 2,
eventName,
createdAt: `2026-08-09T${String(hour).padStart(2, "0")}:30:00.000Z`,
});
describe("UmamiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
requestedUrls.length = 0;
});
describe("getEventNamesAsync", () => {
test("reads names from the aggregated metrics endpoint", async () => {
setupMockFetch(({ path }) => {
if (path.endsWith("/metrics")) {
return [
{ x: "signup", y: 12 },
{ x: "purchase", y: 3 },
{ x: "signup", y: 1 },
];
}
throw new Error(`unexpected request to ${path}`);
});
expect(await createIntegration().getEventNamesAsync(WEBSITE_ID)).toEqual(["purchase", "signup"]);
expect(requestedUrls).toHaveLength(1);
expect(requestedUrls[0]?.path).toContain("/metrics");
expect(requestedUrls[0]?.params.get("type")).toBe("event");
expect(requestedUrls[0]?.params.get("limit")).toBe("5000");
expect(requestedUrls.some((requested) => requested.path.endsWith("/events"))).toBe(false);
});
test("drops empty names and returns them sorted", async () => {
setupMockFetch(() => [
{ x: "beta", y: 1 },
{ x: null, y: 5 },
{ x: "alpha", y: 2 },
]);
expect(await createIntegration().getEventNamesAsync(WEBSITE_ID)).toEqual(["alpha", "beta"]);
});
});
describe("getMultiEventTimeSeriesAsync", () => {
test("pages through raw event records and aggregates across pages", async () => {
const firstPage = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
const secondPage = Array.from({ length: 5 }, (_, index) => rawEvent(PAGE_SIZE + index, "signup", 11));
setupMockFetch(({ path, params }) => {
if (!path.endsWith("/events")) return [];
return { data: params.get("page") === "1" ? firstPage : secondPage };
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(requestedUrls.map((requested) => requested.params.get("page"))).toEqual(["1", "2"]);
expect(requestedUrls[0]?.params.get("pageSize")).toBe(String(PAGE_SIZE));
expect(requestedUrls[0]?.params.get("event")).toBe("signup");
expect(requestedUrls[0]?.params.get("eventName")).toBe("signup");
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(PAGE_SIZE + 5);
expect(result[0]?.dataPoints).toHaveLength(2);
});
test("stops after one page when the instance returns aggregated buckets", async () => {
setupMockFetch(() => [
{ x: "2026-08-09 10:00:00", y: 4 },
{ x: "2026-08-09 11:00:00", y: 7 },
]);
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(requestedUrls).toHaveLength(1);
expect(result[0]?.dataPoints).toEqual([
{ x: "2026-08-09 10:00:00", y: 4 },
{ x: "2026-08-09 11:00:00", y: 7 },
]);
});
test("stops instead of double counting when the instance ignores the page parameter", async () => {
const page = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
setupMockFetch(() => ({ data: page }));
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(requestedUrls).toHaveLength(2);
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(PAGE_SIZE);
});
test("discards partial data when a later page fails", async () => {
const firstPage = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const parsed = new URL(urlString);
const isSecondPage = parsed.searchParams.get("page") === "2";
return Promise.resolve(
new Response(isSecondPage ? "upstream failure" : JSON.stringify({ data: firstPage }), {
status: isSecondPage ? 500 : 200,
headers: { "content-type": isSecondPage ? "text/plain" : "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result[0]?.dataPoints).toEqual([]);
});
test("probes beyond the record ceiling without counting the sentinel page", async () => {
setupMockFetch(({ params }) => {
const page = Number(params.get("page"));
return {
data: Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(page * PAGE_SIZE + index, "signup", 10)),
};
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(requestedUrls).toHaveLength(21);
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(20 * PAGE_SIZE);
});
test("requests event series one at a time", async () => {
let inFlight = 0;
let maxInFlight = 0;
mockFetch.mockImplementation(async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight--;
return new Response(JSON.stringify([{ x: "2026-08-09 10:00:00", y: 1 }]), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [
"signup",
"purchase",
"refund",
]);
expect(result).toHaveLength(3);
expect(maxInFlight).toBe(1);
});
});
});
@@ -29,9 +29,22 @@ import {
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
const UMAMI_EVENT_NAME_LIMIT = 5_000;
const UMAMI_EVENT_PAGE_SIZE = 5_000;
const UMAMI_EVENT_PAGE_MAX = 20;
const logger = createLogger({ module: "umami-integration" });
const pad = (number: number) => String(number).padStart(2, "0");
const extractDataArray = (json: unknown): unknown[] => {
if (Array.isArray(json)) return json;
if (json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)) {
return (json as { data: unknown[] }).data;
}
return [];
};
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
@@ -170,32 +183,21 @@ export class UmamiIntegration extends Integration {
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const endAt = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const metrics = await this.getWebsiteMetricsAsync(
websiteId,
startAt,
endAt,
"event",
authHeaders,
UMAMI_EVENT_NAME_LIMIT,
);
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
for (const metric of metrics) {
if (metric.x) names.add(metric.x);
}
return [...names].sort();
return [...names].toSorted();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
@@ -243,19 +245,18 @@ export class UmamiIntegration extends Integration {
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
const results: UmamiEventSeries[] = [];
for (const eventName of eventNames) {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
results.push({ eventName, dataPoints: dataPoints ?? [] });
}
return results;
}
@@ -306,79 +307,82 @@ export class UmamiIntegration extends Integration {
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") return `${dateString} ${pad(date.getUTCHours())}:00:00`;
if (unit === "month") return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
return `${dateString} 00:00:00`;
};
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const buckets = new Map<string, number>();
let previousFirstId: string | undefined;
let truncated = false;
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
for (let page = 1; ; page++) {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
event: eventName,
eventName,
page: page.toString(),
pageSize: UMAMI_EVENT_PAGE_SIZE.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName, page });
return undefined;
}
if (rawArray.length === 0) return undefined;
const rawArray = extractDataArray(await response.json());
if (rawArray.length === 0) break;
if (page > UMAMI_EVENT_PAGE_MAX) {
truncated = true;
break;
}
const firstItem = rawArray[0] as Record<string, unknown>;
const firstItem = rawArray[0] as Record<string, unknown>;
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
if (!("createdAt" in firstItem)) return undefined;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
const firstId =
typeof firstItem.id === "string"
? firstItem.id
: typeof firstItem.createdAt === "string"
? `${String(firstItem.sessionId ?? "")}@${firstItem.createdAt}`
: undefined;
if (page > 1 && firstId !== undefined && firstId === previousFirstId) {
logger.warn("Umami ignored event paging, stopping after the first page", { websiteId, eventName });
break;
}
previousFirstId = firstId;
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
if (rawArray.length < UMAMI_EVENT_PAGE_SIZE) break;
}
return undefined;
if (truncated) {
logger.warn("Event time series hit the page ceiling, counts are a lower bound", {
websiteId,
eventName,
maxRecords: UMAMI_EVENT_PAGE_SIZE * UMAMI_EVENT_PAGE_MAX,
});
}
if (buckets.size === 0) return undefined;
return Array.from(buckets.entries())
.toSorted(([itemA], [itemB]) => itemA.localeCompare(itemB))
.map(([x, y]) => ({ x, y }));
}
private async getWebsiteStatsAsync(
@@ -0,0 +1,60 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import type { MediaRequestStats } from "@homarr/integrations/types";
import { mediaRequestStatsRequestHandler } from "./media-request-stats";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "media-request-integration",
kind: "overseerr",
} as Parameters<typeof mediaRequestStatsRequestHandler.handler>[0];
describe("mediaRequestStatsRequestHandler", () => {
beforeEach(() => {
mediaRequestStatsRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("starts independent upstream requests together", async () => {
const requestsStarted = Promise.withResolvers<void>();
const expected = {
stats: {
total: 8,
movie: 5,
tv: 3,
pending: 1,
approved: 2,
declined: 1,
processing: 1,
available: 3,
},
users: [{ id: 1, displayName: "User", avatar: "/avatar.png", requestCount: 4, link: "/users/1" }],
} satisfies MediaRequestStats;
const getStatsAsync = vi.fn(async () => {
await requestsStarted.promise;
return expected.stats;
});
const getUsersAsync = vi.fn(async () => {
await requestsStarted.promise;
return expected.users;
});
mocks.createIntegrationAsync.mockResolvedValue({ getStatsAsync, getUsersAsync });
const resultPromise = mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
await vi.waitFor(() => {
expect(getStatsAsync).toHaveBeenCalledOnce();
expect(getUsersAsync).toHaveBeenCalledOnce();
});
requestsStarted.resolve();
await expect(resultPromise).resolves.toMatchObject({ data: expected });
});
});
@@ -11,9 +11,13 @@ export const mediaRequestStatsRequestHandler = createIntegrationRequestHandler<
>({
async requestAsync(integration, _input) {
const integrationInstance = await createIntegrationAsync(integration);
const [stats, users] = await Promise.all([
integrationInstance.getStatsAsync(),
integrationInstance.getUsersAsync(),
]);
return {
stats: await integrationInstance.getStatsAsync(),
users: await integrationInstance.getUsersAsync(),
stats,
users,
};
},
cacheTtlMs: 60_000,
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { mediaTranscodingRequestHandler } from "./media-transcoding";
const mocks = vi.hoisted(() => ({
createIntegrationAsync: vi.fn(),
}));
vi.mock("@homarr/integrations", () => ({
createIntegrationAsync: mocks.createIntegrationAsync,
}));
const integration = {
id: "tdarr-integration",
kind: "tdarr",
} as Parameters<typeof mediaTranscodingRequestHandler.handler>[0];
describe("mediaTranscodingRequestHandler", () => {
beforeEach(() => {
mediaTranscodingRequestHandler.invalidateCache();
mocks.createIntegrationAsync.mockReset();
});
test("starts independent upstream requests together", async () => {
const requestsStarted = Promise.withResolvers<void>();
const getQueueAsync = vi.fn(async () => {
await requestsStarted.promise;
return { array: [], totalCount: 0, startIndex: 0, endIndex: 0 };
});
const getWorkersAsync = vi.fn(async () => {
await requestsStarted.promise;
return [];
});
const getStatisticsAsync = vi.fn(async () => {
await requestsStarted.promise;
return {};
});
mocks.createIntegrationAsync.mockResolvedValue({ getQueueAsync, getWorkersAsync, getStatisticsAsync });
const resultPromise = mediaTranscodingRequestHandler
.handler(integration, { pageOffset: 0, pageSize: 10 })
.getDataAsync();
await vi.waitFor(() => {
expect(getQueueAsync).toHaveBeenCalledOnce();
expect(getWorkersAsync).toHaveBeenCalledOnce();
expect(getStatisticsAsync).toHaveBeenCalledOnce();
});
requestsStarted.resolve();
await resultPromise;
});
});
@@ -11,10 +11,15 @@ export const mediaTranscodingRequestHandler = createIntegrationRequestHandler<
>({
async requestAsync(integration, input) {
const integrationInstance = await createIntegrationAsync(integration);
const [queue, workers, statistics] = await Promise.all([
integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
integrationInstance.getWorkersAsync(),
integrationInstance.getStatisticsAsync(),
]);
return {
queue: await integrationInstance.getQueueAsync(input.pageOffset, input.pageSize),
workers: await integrationInstance.getWorkersAsync(),
statistics: await integrationInstance.getStatisticsAsync(),
queue,
workers,
statistics,
};
},
});
+28 -16
View File
@@ -19,23 +19,35 @@ export function OverflowBadge({
radius: "sm",
...props,
};
return (
<Popover width="content" shadow="md" disabled={disablePopover}>
const hasOverflow = data.length > overflowCount;
const badges = data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
));
const overflowTrigger = hasOverflow ? (
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
) : null;
if (disablePopover || !hasOverflow) {
return (
<Group gap={groupGap}>
{data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
))}
{data.length > overflowCount && (
<Popover.Target>
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
</Popover.Target>
)}
{badges}
{overflowTrigger}
</Group>
);
}
return (
<Popover width="content" shadow="md">
<Group gap={groupGap}>
{badges}
<Popover.Target>{overflowTrigger}</Popover.Target>
</Group>
<Popover.Dropdown>
<Stack>
@@ -34,8 +34,6 @@ import {
IconTriangleFilled,
IconZoomScan,
} from "@tabler/icons-react";
import { escapeForRegEx } from "@tiptap/react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { createId } from "@homarr/common";
@@ -63,6 +61,8 @@ interface FormValidation {
errors: FormErrors;
}
const escapeForRegEx = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
export const WidgetMultiReleasesRepositoriesInput = ({
property,
kind,
@@ -16,7 +16,6 @@ import {
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import type { ActionIconProps } from "@mantine/core";
import { ActionIcon, Card, Center, Fieldset, Loader, Stack } from "@mantine/core";
import { IconGripHorizontal } from "@tabler/icons-react";
@@ -177,21 +176,6 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
id,
});
const Handle = (props: Partial<ActionIconProps>) => {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
};
return (
<Card
padding="sm"
@@ -222,7 +206,17 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
item={item}
removeItem={removeItem}
rootAttributes={attributes}
handle={Handle}
handle={
<ActionIcon
variant="transparent"
color="gray"
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
}
/>
</Card>
);
@@ -1,60 +0,0 @@
import { describe, expect, test } from "vitest";
import { supportsAdvancedFocus } from "./definition";
import { widgetImports } from ".";
const advancedFocusWidgetKinds = [
"anchorNote",
"archiveTeamWarrior",
"audioStats",
"bazarr",
"beszelAlerts",
"beszelSystemGrid",
"beszelSystemTable",
"bookmarks",
"calendar",
"clock",
"coolify",
"downloads",
"firewall",
"healthMonitoring",
"immich-albumCarousel",
"immich-serverStats",
"indexerManager",
"mediaMissing",
"mediaReleases",
"mediaServer",
"mediaTranscoding",
"networkControllerStatus",
"networkControllerSummary",
"notebook",
"notifications",
"patchmon",
"releases",
"rssFeed",
"smartHome-entityState",
"smartHome-executeAutomation",
"speedtestTracker",
"stockPrice",
"systemDisks",
"systemResources",
"timetable",
"tracearr",
"traefik",
"umami",
"ups",
"uptimeKuma",
"weather",
"wud",
] as const;
describe("advanced focus support", () => {
test("is reserved for widgets with a distinct advanced experience", () => {
const actualAdvancedFocusKinds = Object.entries(widgetImports)
.filter(([, widgetImport]) => supportsAdvancedFocus(widgetImport.definition))
.map(([kind]) => kind)
.toSorted();
expect(actualAdvancedFocusKinds).toEqual([...advancedFocusWidgetKinds].toSorted());
});
});
+5 -114
View File
@@ -1,14 +1,9 @@
"use client";
import { startTransition, useCallback, useEffect, useMemo, useState } from "react";
import type { ComponentProps } from "react";
import dynamic from "next/dynamic";
import { ActionIcon, Badge, Button, Center, Group, Stack, Text, TextInput, Tooltip } from "@mantine/core";
import { IconEdit } from "@tabler/icons-react";
import { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type ReactQuillComponent from "react-quill-new";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { useIntegrationsWithInteractAccess } from "@homarr/auth/client";
@@ -21,102 +16,11 @@ import type { WidgetComponentProps } from "../definition";
import { getUsableWidgetQueryData } from "../common/query-state";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import actionTargetClasses from "../common/action-target.module.css";
import { storedContentToPlainText } from "./content";
import "react-quill-new/dist/quill.snow.css";
import "./anchor-note.css";
type ReactQuillProps = ComponentProps<typeof ReactQuillComponent>;
type ReactQuillOnChange = NonNullable<ReactQuillProps["onChange"]>;
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
type QuillDelta = z.infer<typeof quillDeltaSchema>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
const emptyDelta = (): DeltaStatic => new DeltaConstructor([{ insert: "\n" }]);
const toPlainTextDelta = (value: string): DeltaStatic => {
const normalized = value.endsWith("\n") ? value : `${value}\n`;
return new DeltaConstructor([{ insert: normalized }]);
};
const parseStoredContent = (content?: string | null): DeltaStatic => {
if (!content) return emptyDelta();
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) {
return new DeltaConstructor(parsedDelta.data.ops);
}
} catch {
return toPlainTextDelta(content);
}
return toPlainTextDelta(content);
};
const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
if (parsedDelta.success) {
return JSON.stringify(parsedDelta.data);
}
return JSON.stringify(emptyDelta());
};
export const storedContentToPlainText = (content?: string | null): string => {
return parseStoredContent(content)
.ops.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
};
const AnchorNoteEditor = dynamic(() => import("./editor"), { ssr: false });
const canEditPermission = (permission: AnchorNotePermission) => {
return permission === "owner" || permission === "editor";
@@ -217,17 +121,7 @@ const AnchorNoteWidgetContent = ({
return normalizedTitle !== note.title || draftContent !== (note.content ?? "");
}, [draftContent, draftTitle, note]);
const editorValue = useMemo(() => parseStoredContent(draftContent), [draftContent]);
const readOnlyValue = useMemo(() => parseStoredContent(note?.content), [note?.content]);
const plainText = useMemo(() => storedContentToPlainText(note?.content), [note?.content]);
const handleEditorChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (!isEditing || source !== "user") return;
const next = stringifyDelta(editor.getContents());
setDraftContent(next);
},
[isEditing],
);
const handleEdit = useCallback(() => {
if (!canEdit || !note) return;
@@ -382,13 +276,10 @@ const AnchorNoteWidgetContent = ({
className={`homarr-anchor-quill${isEditing ? "" : " homarr-anchor-quill--readonly"}`}
style={{ flex: 1, minHeight: 0 }}
>
<ReactQuill
theme="snow"
<AnchorNoteEditor
content={isEditing ? draftContent : note.content}
readOnly={!isEditing}
value={isEditing ? editorValue : readOnlyValue}
onChange={handleEditorChange}
modules={isEditing ? quillModules : readOnlyModules}
formats={quillFormats}
onChange={setDraftContent}
placeholder={t("emptyContent")}
/>
</div>
@@ -0,0 +1,27 @@
import { describe, expect, it } from "vitest";
import { parseStoredOperations, storedContentToPlainText, stringifyDelta } from "./content";
describe("Anchor Note stored content", () => {
it("preserves valid Delta operations and extracts their text", () => {
const content = JSON.stringify({
ops: [{ insert: "Hello ", attributes: { bold: true } }, { insert: { image: "ignored" } }, { insert: "world\n" }],
});
expect(parseStoredOperations(content)).toEqual([
{ insert: "Hello ", attributes: { bold: true } },
{ insert: { image: "ignored" } },
{ insert: "world\n" },
]);
expect(storedContentToPlainText(content)).toBe("Hello world");
});
it("keeps legacy plain text readable and normalizes an editor newline", () => {
expect(parseStoredOperations("legacy note")).toEqual([{ insert: "legacy note\n" }]);
expect(storedContentToPlainText("legacy note")).toBe("legacy note");
});
it("falls back to an empty Delta when editor output is invalid", () => {
expect(stringifyDelta({ invalid: true })).toBe('{"ops":[{"insert":"\\n"}]}');
});
});
@@ -0,0 +1,40 @@
import { z } from "zod/v4";
const quillDeltaOperationSchema = z.object({
insert: z.unknown().optional(),
attributes: z.record(z.string(), z.unknown()).optional(),
});
export const quillDeltaSchema = z.object({
ops: z.array(quillDeltaOperationSchema),
});
export type QuillDelta = z.infer<typeof quillDeltaSchema>;
const toPlainTextOperations = (value: string): QuillDelta["ops"] => [
{ insert: value.endsWith("\n") ? value : `${value}\n` },
];
export const parseStoredOperations = (content?: string | null): QuillDelta["ops"] => {
if (!content) return [{ insert: "\n" }];
try {
const parsedDelta = quillDeltaSchema.safeParse(JSON.parse(content));
if (parsedDelta.success) return parsedDelta.data.ops;
} catch {
return toPlainTextOperations(content);
}
return toPlainTextOperations(content);
};
export const stringifyDelta = (delta: unknown): string => {
const parsedDelta = quillDeltaSchema.safeParse(delta);
return JSON.stringify(parsedDelta.success ? parsedDelta.data : { ops: [{ insert: "\n" }] });
};
export const storedContentToPlainText = (content?: string | null): string =>
parseStoredOperations(content)
.map(({ insert }) => (typeof insert === "string" ? insert : ""))
.join("")
.trim();
@@ -0,0 +1,83 @@
"use client";
import { useCallback, useMemo } from "react";
import type { ComponentProps } from "react";
import ReactQuill, { Quill } from "react-quill-new";
import type { DeltaStatic } from "react-quill-new";
import type { QuillDelta } from "./content";
import { parseStoredOperations, stringifyDelta } from "./content";
import "react-quill-new/dist/quill.snow.css";
type ReactQuillOnChange = NonNullable<ComponentProps<typeof ReactQuill>["onChange"]>;
const DeltaConstructor = Quill.import("delta") as new (
ops?: QuillDelta["ops"] | { ops: QuillDelta["ops"] },
) => DeltaStatic;
const quillModules = {
toolbar: [
["bold", "italic", "underline", "strike"],
[{ header: [1, 2, 3, 4, false] }],
[{ align: [] }],
[{ list: "ordered" }, { list: "bullet" }, { list: "check" }],
[{ indent: "-1" }, { indent: "+1" }],
["blockquote", "code-block"],
["link"],
["clean"],
],
history: {
delay: 1000,
maxStack: 200,
userOnly: true,
},
};
const readOnlyModules = {
toolbar: false,
};
const quillFormats = [
"bold",
"italic",
"underline",
"strike",
"header",
"align",
"list",
"indent",
"blockquote",
"code-block",
"link",
];
interface AnchorNoteEditorProps {
content?: string | null;
readOnly: boolean;
placeholder: string;
onChange: (content: string) => void;
}
export default function AnchorNoteEditor({ content, readOnly, placeholder, onChange }: AnchorNoteEditorProps) {
const value = useMemo(() => new DeltaConstructor(parseStoredOperations(content)), [content]);
const handleChange = useCallback<ReactQuillOnChange>(
(_html, _delta, source, editor) => {
if (readOnly || source !== "user") return;
onChange(stringifyDelta(editor.getContents()));
},
[onChange, readOnly],
);
return (
<ReactQuill
theme="snow"
readOnly={readOnly}
value={value}
onChange={handleChange}
modules={readOnly ? readOnlyModules : quillModules}
formats={quillFormats}
placeholder={placeholder}
/>
);
}
+27 -12
View File
@@ -59,17 +59,9 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
return (
<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 } }}
<AppDescriptionTooltip
description={app.description}
enabled={options.descriptionDisplayMode === "tooltip" && Boolean(app.description) && !isEditMode}
>
<Flex
p={isTiny ? 4 : "sm"}
@@ -123,7 +115,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
}}
/>
</Flex>
</Tooltip.Floating>
</AppDescriptionTooltip>
{options.pingEnabled &&
!settings.forceDisableStatus &&
!board.disableStatus &&
@@ -148,6 +140,29 @@ interface AppLinkProps {
enabled: boolean;
}
const AppDescriptionTooltip = ({
description,
enabled,
children,
}: PropsWithChildren<{ description?: string | null; enabled: boolean }>) =>
enabled ? (
<Tooltip.Floating
label={description?.split("\n").map((line, index) => (
<Fragment key={index}>
{line}
<br />
</Fragment>
))}
position="right-start"
multiline
styles={{ tooltip: { maxWidth: 300 } }}
>
{children}
</Tooltip.Floating>
) : (
children
);
const AppLink = ({ href, openInNewTab, enabled, children }: PropsWithChildren<AppLinkProps>) =>
enabled ? (
<UnstyledButton
@@ -98,6 +98,22 @@ const getSizeConfig = (width: number): SizeConfig => {
};
};
const PercentCell = ({ value, size }: { value: number; size: SizeConfig }) => {
return (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
};
export default function BeszelSystemTableWidget({
options,
integrationIds,
@@ -176,20 +192,6 @@ export default function BeszelSystemTableWidget({
});
}, [systemsWithSource, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
@@ -270,7 +272,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
render: (record) => <PercentCell value={record.cpu} size={size} />,
},
visibleMetricKeys.has("showMemory") && {
accessor: "memory",
@@ -282,7 +284,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
render: (record) => <PercentCell value={record.memory} size={size} />,
},
visibleMetricKeys.has("showDisk") && {
accessor: "disk",
@@ -314,7 +316,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
render: (record) => <PercentCell value={record.gpu} size={size} />,
},
visibleMetricKeys.has("showLoadAvg") && {
accessor: "loadAvg",
@@ -31,7 +31,7 @@ function prepareRecords<T>(records: T[], timePeriod: BeszelTimePeriod, locale: s
if (timePeriod === "1m") {
return { fmt, ordered: records };
}
return { fmt, ordered: [...records].toReversed() };
return { fmt, ordered: records.toReversed() };
}
function padTimeGrid(data: Record<string, unknown>[], timePeriod: BeszelTimePeriod, locale: string) {
+2 -2
View File
@@ -28,11 +28,11 @@ export const { definition, componentLoader } = createWidgetDefinition("bookmarks
openNewTab: factory.switch({ defaultValue: true }),
withBorder: factory.switch({ defaultValue: false }),
items: factory.sortableItemList<RouterOutputs["app"]["all"][number], string>({
ItemComponent: ({ item, handle: Handle, removeItem, rootAttributes }) => {
ItemComponent: ({ item, handle, removeItem, rootAttributes }) => {
return (
<Group {...rootAttributes} tabIndex={0} justify="space-between" wrap="nowrap">
<Group wrap="nowrap">
<Handle />
{handle}
<Group>
<Avatar src={item.iconUrl} alt={item.name} />
+25 -22
View File
@@ -22,6 +22,30 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const cell = (
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
);
if (disabled) return cell;
return (
<HoverCard
position="bottom"
@@ -32,29 +56,8 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{ transition: "pop" }}
openDelay={350}
closeDelay={400}
disabled={disabled}
>
<HoverCard.Target>
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
</HoverCard.Target>
<HoverCard.Target>{cell}</HoverCard.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<CalendarEventList events={events} />
</HoverCard.Dropdown>
+21 -16
View File
@@ -45,8 +45,6 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
const onlineServers = instance.instanceInfo.servers.filter(isCoolifyServerOnline).length;
const runningApps = instance.instanceInfo.applications.filter(
(a) => parseStatus(a.status ?? "") === "running",
@@ -140,6 +138,7 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
variant="filled"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
@@ -167,20 +166,26 @@ export function InstanceCard({ instance, options, isTiny, isAdvanced, widgetKey,
)}
</Accordion>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{instance.instanceInfo.version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
</Card>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Text size="10px" c="dimmed">
v{version}
</Text>
<Text size="10px" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};
@@ -50,8 +50,6 @@ export function SingleInstanceLayout({
const baseUrl = getSafeApplicationUrl(instance.integrationUrl)?.replace(/\/+$/, "") ?? "";
const displayUrl = baseUrl ? baseUrl.replace(/^https?:\/\//, "") : "—";
const relativeTime = useTimeAgo(instance.updatedAt);
return (
<ScrollArea h="100%">
<Stack gap={0}>
@@ -87,6 +85,7 @@ export function SingleInstanceLayout({
variant="contained"
chevronPosition="right"
multiple
keepMounted={false}
value={isAdvanced ? advancedOpenSections : openSections}
onChange={isAdvanced ? setAdvancedOpenSections : setOpenSections}
>
@@ -114,24 +113,30 @@ export function SingleInstanceLayout({
)}
</Accordion>
{!hideFooter && (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{instance.instanceInfo.version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
)}
{!hideFooter && <InstanceFooter version={instance.instanceInfo.version} updatedAt={instance.updatedAt} />}
</Stack>
</ScrollArea>
);
}
const InstanceFooter = ({ version, updatedAt }: { version: string; updatedAt: Date }) => {
const t = useScopedI18n("widget.coolify");
const relativeTime = useTimeAgo(updatedAt, 60_000);
return (
<Group
justify="space-between"
p={4}
style={{ borderTop: "1px solid light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-4))" }}
>
<Group gap={2}>
<Image src={COOLIFY_ICON_URL} alt="Coolify" w={16} h={16} />
<Text size="xs" c="dimmed">
v{version}
</Text>
</Group>
<Text size="xs" c="dimmed">
{t("footer.updated", { when: relativeTime })}
</Text>
</Group>
);
};
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
supportsAdvancedFocus: false,
icon: IconDeviceGamepad,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showToggleAllButtons: factory.switch({
@@ -11,7 +11,7 @@ export const { definition, componentLoader } = createWidgetDefinition(widgetKind
supportsAdvancedFocus: false,
icon: IconAd,
queryKey: [["widget", "dnsHole"]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
usePiHoleColors: factory.switch({
+1 -1
View File
@@ -31,7 +31,7 @@ export const { definition, componentLoader } = createWidgetDefinition("downloads
integrationIds: scope.integrationIds,
limitPerIntegration: scope.options.limitPerIntegration,
}),
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(
(factory) => ({
+1 -1
View File
@@ -8,7 +8,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("firewall", {
icon: IconWall,
supportsAdvancedFocus: true,
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(() => ({}));
},
@@ -10,7 +10,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
icon: IconHeartRateMonitor,
supportsAdvancedFocus: true,
queryKeys: [[["integration", "byIds"]], [["widget", "healthMonitoring"]]],
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from(
(factory) => ({
@@ -26,6 +26,7 @@ export default function IFrameWidget({ options, isEditMode }: WidgetComponentPro
<Stack h="100%" w="100%" gap={0}>
<Box style={{ flex: 1, minHeight: 0 }}>
<iframe
loading="lazy"
style={isEditMode ? { userSelect: "none", pointerEvents: "none" } : undefined}
className={classes.iframe}
src={safeEmbedUrl}
@@ -237,6 +237,7 @@ function Carousel({
<Image
src={asset.publicLink}
alt=""
loading="lazy"
w={64}
h={44}
radius="sm"
+19
View File
@@ -150,4 +150,23 @@ describe("widget manifest promise stability", () => {
[...expectedByQueryKey.values()].map(serializePolicy).toSorted(),
);
});
it("does not poll integration handlers faster than their cache can refresh", async () => {
const definitions = await loadAllWidgetDefinitions();
const cachedWidgetKinds = [
"dnsHoleControls",
"dnsHoleSummary",
"downloads",
"firewall",
"healthMonitoring",
"mediaServer",
"systemDisks",
"systemResources",
"tracearr",
] as const;
for (const kind of cachedWidgetKinds) {
expect(definitions.get(kind)?.refetchInterval).toBe(10);
}
});
});
@@ -168,6 +168,7 @@ export default function MediaMissingWidget({
return (
<Tabs
value={activeTab}
keepMounted={false}
onChange={(value) => {
if (value === "missing" || value === "queued") setSelectedTab(value);
}}
@@ -193,12 +194,12 @@ export default function MediaMissingWidget({
{showMissing && (
<Tabs.Panel value="missing" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(missing, t("empty.missing"))}
{activeTab === "missing" && renderPanel(missing, t("empty.missing"))}
</Tabs.Panel>
)}
{showQueued && (
<Tabs.Panel value="queued" flex={1} style={{ overflow: "hidden" }}>
{renderPanel(queued, t("empty.queued"))}
{activeTab === "queued" && renderPanel(queued, t("empty.queued"))}
</Tabs.Panel>
)}
</Tabs>
@@ -17,6 +17,11 @@
}
}
.item[data-defer-rendering] {
content-visibility: auto;
contain-intrinsic-size: auto 88px;
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
@@ -125,6 +125,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
>
<UnstyledButton
className={classes.item}
data-defer-rendering={isAdvanced ? undefined : true}
component={href ? "a" : "div"}
href={href}
target={href ? "_blank" : undefined}
@@ -167,6 +168,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => {
src={item.imageUrls.poster}
alt=""
radius="xs"
loading="lazy"
style={{ flexShrink: 0 }}
/>
)}
+1 -1
View File
@@ -14,7 +14,7 @@ export const { componentLoader, definition } = createWidgetDefinition("mediaServ
integrationIds: scope.integrationIds,
showOnlyPlaying: scope.options.showOnlyPlaying,
}),
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showOnlyPlaying: factory.switch({ defaultValue: true, withDescription: true }),
+18 -29
View File
@@ -874,27 +874,30 @@ function TaskListToggle() {
);
}
function ListIndentIncrease() {
function useActiveListItemType() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
useEffect(() => {
if (!editor) return;
const updateItemType = () => setItemType(editor.isActive("taskItem") ? "taskItem" : "listItem");
updateItemType();
editor.on("selectionUpdate", updateItemType);
return () => {
editor.off("selectionUpdate", updateItemType);
};
}, [editor]);
return { editor, itemType };
}
function ListIndentIncrease() {
const { editor, itemType } = useActiveListItemType();
const tControls = useScopedI18n("widget.notebook.controls");
const handleIncreaseIndent = useCallback(() => {
editor?.chain().focus().sinkListItem(itemType).run();
}, [editor, itemType]);
useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
};
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor]);
return (
<RichTextEditor.Control
title={tControls("increaseIndent")}
@@ -907,27 +910,13 @@ function ListIndentIncrease() {
}
function ListIndentDecrease() {
const { editor } = useRichTextEditorContext();
const [itemType, setItemType] = useState("listItem");
const { editor, itemType } = useActiveListItemType();
const tControls = useScopedI18n("widget.notebook.controls");
const handleDecreaseIndent = useCallback(() => {
editor?.chain().focus().liftListItem(itemType).run();
}, [editor, itemType]);
useEffect(() => {
if (!editor) return;
const handleSelectionUpdate = ({ editor: updatedEditor }: { editor: Editor }) => {
setItemType(updatedEditor.isActive("taskItem") ? "taskItem" : "listItem");
};
editor.on("selectionUpdate", handleSelectionUpdate);
return () => {
editor.off("selectionUpdate", handleSelectionUpdate);
};
}, [editor]);
return (
<RichTextEditor.Control
title={tControls("decreaseIndent")}
+1 -2
View File
@@ -1,6 +1,5 @@
import type React from "react";
import type { DraggableAttributes, UniqueIdentifier } from "@dnd-kit/core";
import type { ActionIconProps } from "@mantine/core";
import { z } from "zod/v4";
import type { RefinementCtx, ZodType } from "zod/v4";
@@ -38,7 +37,7 @@ export interface SortableItemListInput<TItem, TOptionValue extends UniqueIdentif
item: TItem;
removeItem: () => void;
rootAttributes: DraggableAttributes;
handle: (props: Partial<Pick<ActionIconProps, "size" | "color" | "variant">>) => React.ReactNode;
handle: React.ReactNode;
}) => React.ReactNode;
uniqueIdentifier: (item: TItem) => TOptionValue;
useData: (values: TOptionValue[]) => { data: TItem[] | undefined; isLoading: boolean; error: unknown };
+6 -6
View File
@@ -7,12 +7,12 @@
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 10 },
{ queryKey: [["widget", "downloads", "getJobsAndStatuses"]], intervalSeconds: 10 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 10 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 10 },
{ queryKey: [["widget", "mediaServer", "getCurrentStreams"]], intervalSeconds: 10 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 10 },
{ queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null },
{ queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null },
+2 -1
View File
@@ -131,11 +131,12 @@ export default function ReleasesWidget({ options, itemId, width, displayMode }:
const isInitialLoading = queryResults.some(isInitialWidgetQueryPending);
const repositories = useMemo(() => {
const resultById = new Map(results.flat().map((result) => [result.id, result]));
const formattedResults = normalizedRepositories
.map((repository) => {
if (!repository.provider) return { ...repository, error: { code: "noProviderSelected" } };
const repositoryResult = results.flat().find(({ id }) => id === repository.id);
const repositoryResult = resultById.get(repository.id);
if (!repositoryResult) return { ...repository, error: { code: "noProviderResponse" } };
if (!repositoryResult.success) return { ...repository, error: repositoryResult.error };
@@ -126,6 +126,7 @@ export default function RssFeed({ options, width, height, displayMode }: WidgetC
<Group wrap="nowrap" align="flex-start" gap={isDense ? "xs" : "md"}>
{feedEntry.enclosure !== undefined && entryDisplay.showImage && (
<Image
loading="lazy"
className={classes.poster}
src={feedEntry.enclosure}
alt=""
@@ -104,8 +104,9 @@ const SystemDiskCard = ({
measure();
const observer = new ResizeObserver(measure);
observer.observe(card);
observer.observe(value);
return () => observer.disconnect();
}, [displayText, healthy]);
}, []);
const unhealthyLabel = t("widget.systemDisks.status.unhealthy");
const hasTemperature = temperature !== null && temperature !== undefined;
+1 -1
View File
@@ -8,7 +8,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemDis
supportsAdvancedFocus: true,
icon: IconServer2,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
return optionsBuilder.from(
@@ -16,7 +16,7 @@ export const { definition, componentLoader } = createWidgetDefinition("systemRes
icon: IconGraphFilled,
supportsAdvancedFocus: true,
queryKey: [["widget", "healthMonitoring"]],
refetchInterval: 5,
refetchInterval: 10,
supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
+1 -1
View File
@@ -6,7 +6,7 @@ import { optionsBuilder } from "../options";
export const { definition, componentLoader } = createWidgetDefinition("tracearr", {
icon: IconActivityHeartbeat,
supportsAdvancedFocus: true,
refetchInterval: 5,
refetchInterval: 10,
createOptions() {
return optionsBuilder.from((factory) => ({
showStreams: factory.switch({
+3 -4
View File
@@ -63,7 +63,7 @@ export function UmamiContent({
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
{ enabled: viewMode === "events" && eventNames.length > 0, ...umamiQueryOptions },
);
const multiEventSeries = multiEventQuery.data;
const multiEventSeries = getUsableWidgetQueryData(multiEventQuery);
const multiEventTotal = multiEventSeries
? multiEventSeries.flatMap(({ dataPoints }) => dataPoints).reduce((sum, { y }) => sum + y, 0)
@@ -106,10 +106,9 @@ export function UmamiContent({
const selectedView =
viewMode === "events" ? (
<UmamiEventsContent
integrationIds={integrationIds}
websiteId={websiteId}
series={multiEventSeries ?? []}
timeFrame={timeFrame}
eventNames={eventNames}
hasSelectedEvents={eventNames.length > 0}
chartType={chartType}
showXAxis={showXAxis}
/>
@@ -3,27 +3,23 @@
import { BarChart, LineChart } from "@mantine/charts";
import { Box, Group, Stack, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { UmamiEventSeries } from "@homarr/integrations/types";
import { useCurrentIntlLocale, useScopedI18n } from "@homarr/translation/client";
import { EVENT_COLORS, formatXLabel, umamiQueryOptions } from "./umami-utils";
import { getUsableWidgetQueryData } from "../common/query-state";
import { EVENT_COLORS, formatXLabel } from "./umami-utils";
interface UmamiEventsContentProps {
integrationIds: string[];
websiteId: string;
series: UmamiEventSeries[];
timeFrame: string;
eventNames: string[];
hasSelectedEvents: boolean;
chartType: string;
showXAxis: boolean;
}
export function UmamiEventsContent({
integrationIds,
websiteId,
series,
timeFrame,
eventNames,
hasSelectedEvents,
chartType,
showXAxis,
}: UmamiEventsContentProps) {
@@ -31,15 +27,7 @@ export function UmamiEventsContent({
const locale = useCurrentIntlLocale();
const tickColor = "var(--mantine-color-dimmed)";
const series =
getUsableWidgetQueryData(
clientApi.widget.umami.getMultiEventTimeSeries.useQuery(
{ integrationId: integrationIds[0] ?? "", websiteId, timeFrame, eventNames: [...eventNames].toSorted() },
umamiQueryOptions,
),
) ?? [];
if (eventNames.length === 0) {
if (!hasSelectedEvents) {
return (
<Stack align="center" justify="center" h="100%">
<Text c="dimmed" size="sm">
+7 -7
View File
@@ -205,6 +205,12 @@ const UpdateCard = ({
const versionText = isDigestUpdate
? t("updateAvailable")
: buildVersionText(truncateVersion(update.currentVersion), truncateVersion(update.newVersion));
const versionBadge = versionText ? (
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
{versionText}
</Badge>
) : null;
const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText;
return (
<Card className={combineClasses(className)} radius={radius} p="xs" style={{ overflow: "visible" }}>
@@ -213,13 +219,7 @@ const UpdateCard = ({
{update.name}
</Text>
<Group gap={4} wrap="nowrap" style={{ flexShrink: 0 }}>
{versionText && (
<Tooltip label={fullVersionText} disabled={isDigestUpdate || versionText === fullVersionText}>
<Badge size="xs" variant="light" color="gray" style={{ whiteSpace: "nowrap" }}>
{versionText}
</Badge>
</Tooltip>
)}
{showVersionTooltip ? <Tooltip label={fullVersionText}>{versionBadge}</Tooltip> : versionBadge}
{href && (
<ActionIcon
component="a"