perf: reduce widget and runtime overhead

This commit is contained in:
ajnart committed 2026-08-13 19:57:13 +02:00
1 parent 5a7607ddf7
commit 14c7e58e17
33 files changed
+779 -339

No files matched your search

+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,
},
@@ -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);
});
});
@@ -0,0 +1,196 @@
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.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,21 @@ 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_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 +182,14 @@ 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);
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 +237,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 +299,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,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>
+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}
/>
);
}
@@ -89,6 +89,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,
@@ -165,20 +181,6 @@ export default function BeszelSystemTableWidget({
});
}, [filteredSystems, 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)[] = [
{
@@ -211,7 +213,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",
@@ -223,7 +225,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",
@@ -255,7 +257,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",
+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>
@@ -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
@@ -49,7 +49,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) => ({
@@ -237,6 +237,7 @@ function Carousel({
<Image
src={asset.publicLink}
alt=""
loading="lazy"
w={64}
h={44}
radius="sm"
+19
View File
@@ -148,4 +148,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);
}
});
});
@@ -17,6 +17,11 @@
}
}
.item[data-defer-rendering] {
content-visibility: auto;
contain-intrinsic-size: auto 88px;
}
@media (prefers-reduced-motion: reduce) {
.item {
transition: none;
@@ -117,6 +117,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}
@@ -159,6 +160,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 }),
+19 -13
View File
@@ -7,24 +7,30 @@
*/
export const widgetQueryRefetchIntervals = [
{ queryKey: [["docker", "getContainers"]], intervalSeconds: 30 },
{ queryKey: [["widget", "dnsHole"]], intervalSeconds: 5 },
{ queryKey: [["widget", "downloads"]], intervalSeconds: 5 },
{ queryKey: [["widget", "firewall"]], intervalSeconds: 5 },
{ queryKey: [["widget", "healthMonitoring"]], intervalSeconds: 5 },
{ queryKey: [["widget", "mediaServer"]], intervalSeconds: 5 },
{ queryKey: [["widget", "tracearr"]], intervalSeconds: 5 },
{ queryKey: [["widget", "immich"]], intervalSeconds: null },
{ 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 },
{ queryKey: [["widget", "indexerManager"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaRelease"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft"]], intervalSeconds: null },
{ queryKey: [["widget", "mediaTranscoding", "getDataAsync"]], intervalSeconds: null },
{ queryKey: [["widget", "minecraft", "getServerStatus"]], intervalSeconds: null },
{ queryKey: [["widget", "networkController"]], intervalSeconds: null },
{ queryKey: [["widget", "paperlessNgx"]], intervalSeconds: null },
{ queryKey: [["widget", "releases"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed"]], intervalSeconds: null },
{ queryKey: [["widget", "releases", "getLatest"]], intervalSeconds: null },
{ queryKey: [["widget", "rssFeed", "getFeeds"]], intervalSeconds: null },
{ queryKey: [["widget", "speedtestTracker"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice"]], intervalSeconds: null },
{ queryKey: [["widget", "umami"]], intervalSeconds: null },
{ queryKey: [["widget", "stockPrice", "getPriceHistory"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getVisitorStats"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getActiveVisitors"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getMultiEventTimeSeries"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopPages"]], intervalSeconds: null },
{ queryKey: [["widget", "umami", "getTopReferrers"]], intervalSeconds: null },
{ queryKey: [["widget", "vpn"]], intervalSeconds: null },
{ queryKey: [["widget", "wud"]], intervalSeconds: null },
] as const;
+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({
+19 -38
View File
@@ -11,49 +11,36 @@ import type { WudContainerUpdate } from "@homarr/integrations";
import { useScopedI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import { getSafeApplicationUrl, SAFE_NEW_TAB_REL } from "../common/application-url";
import { WidgetQueryErrorIndicator } from "../common/query-state-indicator";
import type { WidgetComponentProps } from "../definition";
import classes from "./component.module.css";
export default function WudWidget({ integrationIds, options, width, displayMode }: WidgetComponentProps<"wud">) {
export default function WudWidget({ integrationIds, options, width }: WidgetComponentProps<"wud">) {
const integrationId = integrationIds[0];
if (!integrationId) return null;
return (
<WudWidgetContent
integrationId={integrationId}
options={options}
width={width}
isAdvanced={displayMode === "advanced"}
/>
);
return <WudWidgetContent integrationId={integrationId} options={options} width={width} />;
}
const WudWidgetContent = ({
integrationId,
options,
width,
isAdvanced,
}: {
integrationId: string;
options: WidgetComponentProps<"wud">["options"];
width: number;
isAdvanced: boolean;
}) => {
const t = useScopedI18n("widget.wud");
const [data, statsQuery] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const [data] = clientApi.widget.wud.getStats.useSuspenseQuery({ integrationId });
const board = useRequiredBoard();
const isTiny = !isAdvanced && width < 256;
const isTiny = width < 256;
const stats = data.stats;
if (stats.totalContainers === 0) return <WidgetEmptyState />;
const updatePercentage = Math.round((stats.updatesAvailable / stats.totalContainers) * 100);
const badgeColor = stats.updatesAvailable === 0 ? "green" : progressColor(updatePercentage);
const showTitle = isAdvanced || options.showTitle;
const showRing = isAdvanced || options.showRing;
const showUpdateList = (isAdvanced || options.showUpdateList) && stats.updates.length > 0;
const showUpdateList = options.showUpdateList && stats.updates.length > 0;
const ring = (
<RingProgress
@@ -86,7 +73,7 @@ const WudWidgetContent = ({
</Stack>
);
const tinyContent = showRing ? (
const tinyContent = options.showRing ? (
<Tooltip
label={
<Stack gap={2}>
@@ -115,12 +102,7 @@ const WudWidgetContent = ({
return (
<Stack p="xs" gap="xs" h="100%">
{Boolean(statsQuery.error) && (
<Group justify="flex-end">
<WidgetQueryErrorIndicator error={statsQuery.error} label={t("name")} />
</Group>
)}
{showTitle && !isTiny && (
{options.showTitle && !isTiny && (
<Group gap="xs" wrap="nowrap" justify="space-between" miw={0}>
<Group gap="xs" wrap="nowrap" miw={0}>
<Avatar size={20} radius="sm" src={getIconUrl("wud")} />
@@ -140,12 +122,12 @@ const WudWidgetContent = ({
tinyContent
) : options.layout === "horizontal" ? (
<Group justify="center" wrap="nowrap" gap="md">
{showRing && ring}
{options.showRing && ring}
{summary}
</Group>
) : (
<Stack align="center" gap="xs">
{showRing && ring}
{options.showRing && ring}
{summary}
</Stack>
)}
@@ -199,12 +181,17 @@ const UpdateCard = ({
className: string | undefined;
}) => {
const t = useScopedI18n("widget.wud");
const href = getSafeApplicationUrl(update.link);
const isDigestUpdate = isDigestVersion(update.newVersion);
const fullVersionText = buildVersionText(update.currentVersion, update.newVersion);
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,19 +200,13 @@ 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>
)}
{href && (
{showVersionTooltip ? <Tooltip label={fullVersionText}>{versionBadge}</Tooltip> : versionBadge}
{update.link && (
<ActionIcon
component="a"
href={href}
href={update.link}
target="_blank"
rel={SAFE_NEW_TAB_REL}
rel="noreferrer noopener"
variant="subtle"
color="gray"
size="sm"
+27
View File
@@ -0,0 +1,27 @@
diff --git a/lib/modem.js b/lib/modem.js
index b45a798d63647d55e0193fd7e82c2e36785d9b5f..d2dbf42969fc0ebf39b30c1a27373c33c2d20333 100644
--- a/lib/modem.js
+++ b/lib/modem.js
@@ -3,7 +3,6 @@ var querystring = require('querystring'),
fs = require('fs'),
path = require('path'),
url = require('url'),
- ssh = require('./ssh'),
HttpDuplex = require('./http_duplex'),
debug = require('debug')('modem'),
utils = require('./utils'),
@@ -263,8 +262,13 @@ Modem.prototype.buildRequest = function (options, context, data, callback) {
var connectionTimeoutTimer;
var finished = false;
+ // Loaded here rather than at module scope: './ssh' pulls in ssh2, whose crypto module
+ // instantiates a ~16 MiB WebAssembly memory for ChaCha20-Poly1305 as soon as it is
+ // required. Measured at +14 MiB RSS for a require alone, paid by every Homarr install
+ // even though Docker is reached over a socket and this branch never runs. require() is
+ // cached, so SSH connections pay it once and behave exactly as before.
var opts = self.protocol === 'ssh' ? Object.assign(options, {
- agent: ssh(Object.assign({}, self.sshOptions, {
+ agent: require('./ssh')(Object.assign({}, self.sshOptions, {
'host': self.host,
'port': self.port,
'username': self.username,
+4 -3
View File
@@ -695,6 +695,7 @@ overrides:
patchedDependencies:
'@types/node-unifi': 5e6ae51e2a17a7f9729bfa30b0eb3d0842a5810ac6db47603ab4a6efa1ed84c5
docker-modem@5.0.7: 4a1c50dc8b726af921a34a77c4942a768bed229123ab116baa06c4e62ad6815c
trpc-to-openapi: 2ca3c16af0fcca0c736697ad4fe553a14f794524fa9ce0d5c3e8ee4aea76090c
importers:
@@ -26480,7 +26481,7 @@ snapshots:
dependencies:
yaml: 2.9.0
docker-modem@5.0.7(supports-color@10.2.2):
docker-modem@5.0.7(patch_hash=4a1c50dc8b726af921a34a77c4942a768bed229123ab116baa06c4e62ad6815c)(supports-color@10.2.2):
dependencies:
debug: 4.4.3(supports-color@10.2.2)
readable-stream: 3.6.2
@@ -26494,7 +26495,7 @@ snapshots:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.12.5
'@grpc/proto-loader': 0.7.13
docker-modem: 5.0.7(supports-color@10.2.2)
docker-modem: 5.0.7(patch_hash=4a1c50dc8b726af921a34a77c4942a768bed229123ab116baa06c4e62ad6815c)(supports-color@10.2.2)
protobufjs: 7.4.0
tar-fs: 2.1.4
transitivePeerDependencies:
@@ -26505,7 +26506,7 @@ snapshots:
'@balena/dockerignore': 1.0.2
'@grpc/grpc-js': 1.12.5
'@grpc/proto-loader': 0.7.13
docker-modem: 5.0.7(supports-color@10.2.2)
docker-modem: 5.0.7(patch_hash=4a1c50dc8b726af921a34a77c4942a768bed229123ab116baa06c4e62ad6815c)(supports-color@10.2.2)
protobufjs: 7.4.0
tar-fs: 2.1.4
transitivePeerDependencies:
+1
View File
@@ -276,6 +276,7 @@ overrides:
"vite@>=5.0.0 <=5.4.18": ">=8.1.4"
patchedDependencies:
"@types/node-unifi": "patches/@types__node-unifi.patch"
docker-modem@5.0.7: patches/docker-modem@5.0.7.patch
"trpc-to-openapi": "patches/trpc-to-openapi.patch"
allowUnusedPatches: true
+1
View File
@@ -67,6 +67,7 @@ terminate() {
trap terminate TERM INT
export NODE_OPTIONS="--max-semi-space-size=4 ${NODE_OPTIONS:-}"
node apps/nextjs/server.js &
NEXTJS_PID=$!