From 14c7e58e1760cd283bcb5492ee681820545815ca Mon Sep 17 00:00:00 2001 From: ajnart Date: Tue, 11 Aug 2026 11:38:29 +0200 Subject: [PATCH] perf: reduce widget and runtime overhead --- apps/nextjs/next.config.ts | 1 + .../src/immich/immich-integration.ts | 20 +- .../immich/test/immich-integration.spec.ts | 86 ++++++++ .../src/umami/test/umami-integration.spec.ts | 196 ++++++++++++++++++ .../src/umami/umami-integration.ts | 186 ++++++++--------- .../src/media-transcoding.spec.ts | 52 +++++ .../request-handler/src/media-transcoding.ts | 11 +- packages/ui/src/components/overflow-badge.tsx | 44 ++-- .../widgets/src/anchor-note/component.tsx | 119 +---------- .../widgets/src/anchor-note/content.spec.ts | 27 +++ packages/widgets/src/anchor-note/content.ts | 40 ++++ packages/widgets/src/anchor-note/editor.tsx | 83 ++++++++ .../src/beszel-system-table/component.tsx | 36 ++-- .../widgets/src/calendar/calender-day.tsx | 47 +++-- .../widgets/src/dns-hole/controls/index.ts | 2 +- .../widgets/src/dns-hole/summary/index.ts | 2 +- packages/widgets/src/downloads/index.ts | 2 +- packages/widgets/src/firewall/index.ts | 2 +- .../widgets/src/health-monitoring/index.ts | 2 +- .../src/immich/album-carousel/component.tsx | 1 + packages/widgets/src/manifest.spec.ts | 19 ++ .../src/media-releases/component.module.css | 5 + .../widgets/src/media-releases/component.tsx | 2 + packages/widgets/src/media-server/index.ts | 2 +- packages/widgets/src/refetch-intervals.ts | 32 +-- packages/widgets/src/system-disks/index.ts | 2 +- .../widgets/src/system-resources/index.ts | 2 +- packages/widgets/src/tracearr/index.ts | 2 +- packages/widgets/src/wud/component.tsx | 57 ++--- patches/docker-modem@5.0.7.patch | 27 +++ pnpm-lock.yaml | 7 +- pnpm-workspace.yaml | 1 + scripts/run.sh | 1 + 33 files changed, 779 insertions(+), 339 deletions(-) create mode 100644 packages/integrations/src/immich/test/immich-integration.spec.ts create mode 100644 packages/integrations/src/umami/test/umami-integration.spec.ts create mode 100644 packages/request-handler/src/media-transcoding.spec.ts create mode 100644 packages/widgets/src/anchor-note/content.spec.ts create mode 100644 packages/widgets/src/anchor-note/content.ts create mode 100644 packages/widgets/src/anchor-note/editor.tsx create mode 100644 patches/docker-modem@5.0.7.patch diff --git a/apps/nextjs/next.config.ts b/apps/nextjs/next.config.ts index 240a3f47e..812596cd3 100644 --- a/apps/nextjs/next.config.ts +++ b/apps/nextjs/next.config.ts @@ -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, }, diff --git a/packages/integrations/src/immich/immich-integration.ts b/packages/integrations/src/immich/immich-integration.ts index ce4892d10..8503b1eac 100644 --- a/packages/integrations/src/immich/immich-integration.ts +++ b/packages/integrations/src/immich/immich-integration.ts @@ -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; diff --git a/packages/integrations/src/immich/test/immich-integration.spec.ts b/packages/integrations/src/immich/test/immich-integration.spec.ts new file mode 100644 index 000000000..986059f20 --- /dev/null +++ b/packages/integrations/src/immich/test/immich-integration.spec.ts @@ -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>(), +})); + +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); + }); +}); diff --git a/packages/integrations/src/umami/test/umami-integration.spec.ts b/packages/integrations/src/umami/test/umami-integration.spec.ts new file mode 100644 index 000000000..ccddb0da5 --- /dev/null +++ b/packages/integrations/src/umami/test/umami-integration.spec.ts @@ -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>, + ); + }); +}; + +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>, + ); + }); + + 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>; + }); + + const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [ + "signup", + "purchase", + "refund", + ]); + + expect(result).toHaveLength(3); + expect(maxInFlight).toBe(1); + }); + }); +}); diff --git a/packages/integrations/src/umami/umami-integration.ts b/packages/integrations/src/umami/umami-integration.ts index 730cd5477..86d8c67c6 100644 --- a/packages/integrations/src/umami/umami-integration.ts +++ b/packages/integrations/src/umami/umami-integration.ts @@ -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 { const authHeaders = await this.getAuthHeadersAsync(); @@ -170,32 +182,14 @@ export class UmamiIntegration extends Integration { public async getEventNamesAsync(websiteId: string): Promise { 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(); - for (const item of rawArray) { - const record = item as Record; - 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 { @@ -243,19 +237,18 @@ export class UmamiIntegration extends Integration { ): Promise { 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, ): Promise { - 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(); + 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; + const firstItem = rawArray[0] as Record; + 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(); 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( diff --git a/packages/request-handler/src/media-transcoding.spec.ts b/packages/request-handler/src/media-transcoding.spec.ts new file mode 100644 index 000000000..9edca2b0d --- /dev/null +++ b/packages/request-handler/src/media-transcoding.spec.ts @@ -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[0]; + +describe("mediaTranscodingRequestHandler", () => { + beforeEach(() => { + mediaTranscodingRequestHandler.invalidateCache(); + mocks.createIntegrationAsync.mockReset(); + }); + + test("starts independent upstream requests together", async () => { + const requestsStarted = Promise.withResolvers(); + 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; + }); +}); diff --git a/packages/request-handler/src/media-transcoding.ts b/packages/request-handler/src/media-transcoding.ts index ba6455ccd..7409b25d2 100644 --- a/packages/request-handler/src/media-transcoding.ts +++ b/packages/request-handler/src/media-transcoding.ts @@ -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, }; }, }); diff --git a/packages/ui/src/components/overflow-badge.tsx b/packages/ui/src/components/overflow-badge.tsx index 253597ea8..b333278bb 100644 --- a/packages/ui/src/components/overflow-badge.tsx +++ b/packages/ui/src/components/overflow-badge.tsx @@ -19,23 +19,35 @@ export function OverflowBadge({ radius: "sm", ...props, }; - return ( - + + const hasOverflow = data.length > overflowCount; + const badges = data.slice(0, overflowCount).map((item) => ( + + {item} + + )); + const overflowTrigger = hasOverflow ? ( + + + +{data.length - overflowCount} + + + ) : null; + + if (disablePopover || !hasOverflow) { + return ( - {data.slice(0, overflowCount).map((item) => ( - - {item} - - ))} - {data.length > overflowCount && ( - - - - +{data.length - overflowCount} - - - - )} + {badges} + {overflowTrigger} + + ); + } + + return ( + + + {badges} + {overflowTrigger} diff --git a/packages/widgets/src/anchor-note/component.tsx b/packages/widgets/src/anchor-note/component.tsx index 0d7625c91..337855e64 100644 --- a/packages/widgets/src/anchor-note/component.tsx +++ b/packages/widgets/src/anchor-note/component.tsx @@ -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; -type ReactQuillOnChange = NonNullable; - -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; - -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( - (_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 }} > - diff --git a/packages/widgets/src/anchor-note/content.spec.ts b/packages/widgets/src/anchor-note/content.spec.ts new file mode 100644 index 000000000..8f2bc6ba8 --- /dev/null +++ b/packages/widgets/src/anchor-note/content.spec.ts @@ -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"}]}'); + }); +}); diff --git a/packages/widgets/src/anchor-note/content.ts b/packages/widgets/src/anchor-note/content.ts new file mode 100644 index 000000000..3791a7220 --- /dev/null +++ b/packages/widgets/src/anchor-note/content.ts @@ -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; + +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(); diff --git a/packages/widgets/src/anchor-note/editor.tsx b/packages/widgets/src/anchor-note/editor.tsx new file mode 100644 index 000000000..45baa78bd --- /dev/null +++ b/packages/widgets/src/anchor-note/editor.tsx @@ -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["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( + (_html, _delta, source, editor) => { + if (readOnly || source !== "user") return; + onChange(stringifyDelta(editor.getContents())); + }, + [onChange, readOnly], + ); + + return ( + + ); +} diff --git a/packages/widgets/src/beszel-system-table/component.tsx b/packages/widgets/src/beszel-system-table/component.tsx index 828a659c9..074ce8883 100644 --- a/packages/widgets/src/beszel-system-table/component.tsx +++ b/packages/widgets/src/beszel-system-table/component.tsx @@ -89,6 +89,22 @@ const getSizeConfig = (width: number): SizeConfig => { }; }; +const PercentCell = ({ value, size }: { value: number; size: SizeConfig }) => { + return ( + + + {formatPercent(value)} + + + + ); +}; + export default function BeszelSystemTableWidget({ options, integrationIds, @@ -165,20 +181,6 @@ export default function BeszelSystemTableWidget({ }); }, [filteredSystems, sortStatus]); - const PercentCell = ({ value }: { value: number }) => ( - - - {formatPercent(value)} - - - - ); - const columns = useMemo((): DataTableColumn[] => { const cols: (DataTableColumn | false)[] = [ { @@ -211,7 +213,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, - render: (record) => , + render: (record) => , }, visibleMetricKeys.has("showMemory") && { accessor: "memory", @@ -223,7 +225,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, - render: (record) => , + render: (record) => , }, visibleMetricKeys.has("showDisk") && { accessor: "disk", @@ -255,7 +257,7 @@ export default function BeszelSystemTableWidget({ ), sortable: true, - render: (record) => , + render: (record) => , }, visibleMetricKeys.has("showLoadAvg") && { accessor: "loadAvg", diff --git a/packages/widgets/src/calendar/calender-day.tsx b/packages/widgets/src/calendar/calender-day.tsx index 650356d2c..75fb09570 100644 --- a/packages/widgets/src/calendar/calender-day.tsx +++ b/packages/widgets/src/calendar/calender-day.tsx @@ -22,6 +22,30 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C const shouldScaleDown = minAxisSize < 350; const isSmall = rootHeight < 256; + const cell = ( + + + {date.getDate()} + + + + ); + + if (disabled) return cell; + return ( - - - - {date.getDate()} - - - - + {cell} diff --git a/packages/widgets/src/dns-hole/controls/index.ts b/packages/widgets/src/dns-hole/controls/index.ts index 8e5a3ee02..5c3170c15 100644 --- a/packages/widgets/src/dns-hole/controls/index.ts +++ b/packages/widgets/src/dns-hole/controls/index.ts @@ -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({ diff --git a/packages/widgets/src/dns-hole/summary/index.ts b/packages/widgets/src/dns-hole/summary/index.ts index dcc77a041..b6d9ceceb 100644 --- a/packages/widgets/src/dns-hole/summary/index.ts +++ b/packages/widgets/src/dns-hole/summary/index.ts @@ -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({ diff --git a/packages/widgets/src/downloads/index.ts b/packages/widgets/src/downloads/index.ts index 7ac9d1182..f2cc93be3 100644 --- a/packages/widgets/src/downloads/index.ts +++ b/packages/widgets/src/downloads/index.ts @@ -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) => ({ diff --git a/packages/widgets/src/firewall/index.ts b/packages/widgets/src/firewall/index.ts index 2a6b24854..7a013f07f 100644 --- a/packages/widgets/src/firewall/index.ts +++ b/packages/widgets/src/firewall/index.ts @@ -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(() => ({})); }, diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts index aee05d02e..7cfcdbcd6 100644 --- a/packages/widgets/src/health-monitoring/index.ts +++ b/packages/widgets/src/health-monitoring/index.ts @@ -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) => ({ diff --git a/packages/widgets/src/immich/album-carousel/component.tsx b/packages/widgets/src/immich/album-carousel/component.tsx index b8f388013..b105603f3 100644 --- a/packages/widgets/src/immich/album-carousel/component.tsx +++ b/packages/widgets/src/immich/album-carousel/component.tsx @@ -237,6 +237,7 @@ function Carousel({ { [...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); + } + }); }); diff --git a/packages/widgets/src/media-releases/component.module.css b/packages/widgets/src/media-releases/component.module.css index 602f43ba1..bb6bf9f87 100644 --- a/packages/widgets/src/media-releases/component.module.css +++ b/packages/widgets/src/media-releases/component.module.css @@ -17,6 +17,11 @@ } } +.item[data-defer-rendering] { + content-visibility: auto; + contain-intrinsic-size: auto 88px; +} + @media (prefers-reduced-motion: reduce) { .item { transition: none; diff --git a/packages/widgets/src/media-releases/component.tsx b/packages/widgets/src/media-releases/component.tsx index 9893a91fb..df9d0f409 100644 --- a/packages/widgets/src/media-releases/component.tsx +++ b/packages/widgets/src/media-releases/component.tsx @@ -117,6 +117,7 @@ const Item = ({ item, options, isAdvanced, width, height }: ItemProps) => { > { src={item.imageUrls.poster} alt="" radius="xs" + loading="lazy" style={{ flexShrink: 0 }} /> )} diff --git a/packages/widgets/src/media-server/index.ts b/packages/widgets/src/media-server/index.ts index 559b029ee..5dfac266d 100644 --- a/packages/widgets/src/media-server/index.ts +++ b/packages/widgets/src/media-server/index.ts @@ -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 }), diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index b1bf0e410..d6a5160cc 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -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; diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts index d25f23b58..0780d1e6d 100644 --- a/packages/widgets/src/system-disks/index.ts +++ b/packages/widgets/src/system-disks/index.ts @@ -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( diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts index 7c0e84fdc..362a528aa 100644 --- a/packages/widgets/src/system-resources/index.ts +++ b/packages/widgets/src/system-resources/index.ts @@ -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) => ({ diff --git a/packages/widgets/src/tracearr/index.ts b/packages/widgets/src/tracearr/index.ts index 13851a846..61f88cf4f 100644 --- a/packages/widgets/src/tracearr/index.ts +++ b/packages/widgets/src/tracearr/index.ts @@ -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({ diff --git a/packages/widgets/src/wud/component.tsx b/packages/widgets/src/wud/component.tsx index a1f74b097..1c744d2cd 100644 --- a/packages/widgets/src/wud/component.tsx +++ b/packages/widgets/src/wud/component.tsx @@ -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 ( - - ); + return ; } 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 ; 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 = ( ); - const tinyContent = showRing ? ( + const tinyContent = options.showRing ? ( @@ -115,12 +102,7 @@ const WudWidgetContent = ({ return ( - {Boolean(statsQuery.error) && ( - - - - )} - {showTitle && !isTiny && ( + {options.showTitle && !isTiny && ( @@ -140,12 +122,12 @@ const WudWidgetContent = ({ tinyContent ) : options.layout === "horizontal" ? ( - {showRing && ring} + {options.showRing && ring} {summary} ) : ( - {showRing && ring} + {options.showRing && ring} {summary} )} @@ -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 ? ( + + {versionText} + + ) : null; + const showVersionTooltip = versionText !== null && !isDigestUpdate && versionText !== fullVersionText; return ( @@ -213,19 +200,13 @@ const UpdateCard = ({ {update.name} - {versionText && ( - - - {versionText} - - - )} - {href && ( + {showVersionTooltip ? {versionBadge} : versionBadge} + {update.link && ( =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 diff --git a/scripts/run.sh b/scripts/run.sh index 6357c723c..bea52be56 100644 --- a/scripts/run.sh +++ b/scripts/run.sh @@ -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=$!