From f9da71a19bdfa589aafe26b164d0e0782208ac0a Mon Sep 17 00:00:00 2001 From: Thomas Date: Wed, 1 Jul 2026 13:57:39 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20quick=20wins=20=E2=80=94=20calendar,=20a?= =?UTF-8?q?bout,=20glances,=20admin=20UX,=20releases,=20iframe,=20notes=20?= =?UTF-8?q?(#6116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/extensions.json | 2 +- Dockerfile | 1 + .../app/[locale]/manage/tools/api/page.tsx | 28 ++++--- .../app/[locale]/manage/tools/backup/page.tsx | 14 ++-- .../manage/users/groups/[id]/layout.tsx | 2 + .../components/board/sections/item.module.css | 5 ++ .../navigation/dynamic-breadcrumb.tsx | 9 ++- apps/nextjs/src/versions/package-reader.ts | 22 ++++-- .../src/glances/glances-integration.ts | 51 ++++++++++++- .../lidarr/lidarr-integration.ts | 58 +++++++------- .../integrations/test/glances-unit.spec.ts | 59 ++++++++++++-- .../integrations/test/lidarr-unit.spec.ts | 76 +++++++++++++++++++ packages/widgets/src/app/app.module.css | 2 + packages/widgets/src/app/component.tsx | 1 + packages/widgets/src/notebook/notebook.tsx | 16 +++- packages/widgets/src/releases/component.tsx | 4 +- 16 files changed, 283 insertions(+), 67 deletions(-) create mode 100644 packages/integrations/test/lidarr-unit.spec.ts diff --git a/.vscode/extensions.json b/.vscode/extensions.json index f0cdbe63c..8f1b6afce 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,7 +1,7 @@ { "recommendations": [ "dbaeumer.vscode-eslint", - "esbenp.prettier-vscode", + "oxc.oxc-vscode", "yoavbls.pretty-ts-errors", "million.million-lint", "lokalise.i18n-ally" diff --git a/Dockerfile b/Dockerfile index 3cf6c8ec9..e7b5b679c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -42,6 +42,7 @@ RUN mkdir -p /var/cache/nginx && \ COPY --from=builder /app/apps/nextjs/next.config.ts . COPY --from=builder /app/apps/nextjs/package.json . +COPY --from=builder /app/pnpm-workspace.yaml ./pnpm-workspace.yaml COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node diff --git a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx index bdd86cc7c..db8050ff1 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/api/page.tsx @@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common"; import { getScopedI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; import { ApiKeysManagement } from "./components/api-keys"; import { ApiPageTabs } from "./components/api-page-tabs"; @@ -79,17 +80,20 @@ export default async function ApiPage() { const toolGroups = getMcpToolGroups(); return ( - - } - authenticationPanel={} - baseUrl={baseUrl} - hasApiKeys={apiKeys.length > 0} - toolGroups={toolGroups} - /> - + <> + + + } + authenticationPanel={} + baseUrl={baseUrl} + hasApiKeys={apiKeys.length > 0} + toolGroups={toolGroups} + /> + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx index 80a794b57..037c11677 100644 --- a/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx +++ b/apps/nextjs/src/app/[locale]/manage/tools/backup/page.tsx @@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env"; import { getScopedI18n } from "@homarr/translation/server"; import { createMetaTitle } from "~/metadata"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { BackupExportCard } from "./_components/backup-export-card"; import { BackupImportCard } from "./_components/backup-import-card"; @@ -31,10 +32,13 @@ export default async function BackupPage() { const t = await getScopedI18n("management.page.tool.backup"); return ( - - {t("title")} - - - + <> + + + {t("title")} + + + + ); } diff --git a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx index fd6766417..3c8724068 100644 --- a/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx +++ b/apps/nextjs/src/app/[locale]/manage/users/groups/[id]/layout.tsx @@ -7,6 +7,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server"; import { Link } from "@homarr/ui"; import { ManageContainer } from "~/components/manage/manage-container"; +import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb"; import { NavigationLink } from "./_navigation"; interface LayoutProps { @@ -24,6 +25,7 @@ export default async function Layout(props: PropsWithChildren) { return ( + diff --git a/apps/nextjs/src/components/board/sections/item.module.css b/apps/nextjs/src/components/board/sections/item.module.css index f679c62ca..a7168659e 100644 --- a/apps/nextjs/src/components/board/sections/item.module.css +++ b/apps/nextjs/src/components/board/sections/item.module.css @@ -10,3 +10,8 @@ background-color: var(--background-color) !important; border-color: var(--border-color) !important; } + +.itemCard:global(.iframe-wrapper) { + background-color: transparent !important; + border-color: transparent !important; +} diff --git a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx index d200aa966..2ed6bc5f8 100644 --- a/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx +++ b/apps/nextjs/src/components/navigation/dynamic-breadcrumb.tsx @@ -14,6 +14,8 @@ interface DynamicBreadcrumbProps { nonInteractable?: string[]; } +const categorySegments = new Set(["tools"]); + /** * Breadcrumb is client side rendered. Elements are automatically * calculated and translated using dynamic keys. @@ -60,8 +62,11 @@ export const DynamicBreadcrumb = ({ const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; const mappedValue = dynamicMappings?.get(pathnamePart); + const isNonInteractable = + nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart); + if (mappedValue) { - if (nonInteractable?.includes(pathnamePart)) { + if (isNonInteractable) { return {mappedValue}; } return ( @@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({ ); } - if (nonInteractable?.includes(pathnamePart)) { + if (isNonInteractable) { return {t(`${translationKey}.label` as TranslationKeys)}; } diff --git a/apps/nextjs/src/versions/package-reader.ts b/apps/nextjs/src/versions/package-reader.ts index d62c97b20..bf1cd6405 100644 --- a/apps/nextjs/src/versions/package-reader.ts +++ b/apps/nextjs/src/versions/package-reader.ts @@ -35,14 +35,22 @@ export const getDependenciesAsync = async (): Promise = type DependencyCatalog = Map; const parseDependencyCatalogsAsync = async (): Promise => { const currentDir = path.dirname(fileURLToPath(import.meta.url)); - const workspaceConfigContent = await fsPromises.readFile( - path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"), - "utf-8", - ); - const workspaceConfig: unknown = parseYaml(workspaceConfigContent); - const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig); + const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"); - return new Map(Object.entries(parseResult.catalog ?? {})); + try { + const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8"); + const workspaceConfig: unknown = parseYaml(workspaceConfigContent); + const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig); + return new Map(Object.entries(parseResult.catalog ?? {})); + } catch (error) { + // The workspace config is absent in Docker (only the runner stage is shipped), + // so a missing file is expected. Surface any other failure (e.g. malformed YAML). + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return new Map(); + } + + throw error; + } }; const workspaceConfigCatalogSchema = z.object({ diff --git a/packages/integrations/src/glances/glances-integration.ts b/packages/integrations/src/glances/glances-integration.ts index 6364ebcb7..5915b44b2 100644 --- a/packages/integrations/src/glances/glances-integration.ts +++ b/packages/integrations/src/glances/glances-integration.ts @@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni if (session == null) throw new Error("Session was unexpectitly null"); - const stats = await this.getAllStatsAsync(); + const [stats, cpuTemp] = await Promise.all([this.getAllStatsAsync(), this.getCpuTempAsync()]); return { cpuUtilization: stats.cpu.total, @@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni cpuModelName: stats.quicklook?.cpu_name ?? "Unknown", loadAverage: null, smart: [], - cpuTemp: undefined, + cpuTemp, gpu: stats.gpu.map((gpu) => ({ gpuId: gpu.gpu_id, name: gpu.name, @@ -80,6 +80,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni return await response.text(); } + private async getCpuTempAsync(): Promise { + // CPU temperature is optional data, so a transport error or payload drift on + // /api/4/sensors must not fail the whole getSystemInfoAsync() call. + try { + const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/sensors")); + + if (!response.ok) { + return undefined; + } + + const sensors = await sensorsSchema.parseAsync(await response.json()); + return parseGlancesCpuTempFromSensors(sensors); + } catch { + return undefined; + } + } + private async getAllStatsAsync() { const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all")); @@ -173,3 +190,33 @@ const allSchema = z.object({ ) .default([]), }); + +const sensorsSchema = z.array( + z.object({ + label: z.string(), + value: z.number(), + type: z.string().optional(), + }), +); + +const cpuTempLabelPriority = ["CPU", "Package id 0"] as const; + +export const parseGlancesCpuTempFromSensors = (sensors: z.infer): number | undefined => { + const temperatureSensors = sensors.filter( + (sensor) => sensor.type === "temperature_core" || sensor.type === "temperature", + ); + + for (const label of cpuTempLabelPriority) { + const match = temperatureSensors.find((sensor) => sensor.label === label); + if (match) { + return match.value; + } + } + + const firstCore = temperatureSensors[0]; + if (firstCore) { + return firstCore.value; + } + + return undefined; +}; diff --git a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts index 0bac3ec30..5b16b3ea8 100644 --- a/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts +++ b/packages/integrations/src/media-organizer/lidarr/lidarr-integration.ts @@ -69,36 +69,24 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati private getLinksForLidarrCalendarEvent = (event: z.infer) => { const links: CalendarLink[] = []; + // titleSlug is only present when Lidarr returns it; guard so a missing value + // neither breaks the link nor drops the event. + if (event.artist.titleSlug) { + links.push({ + href: this.externalUrl(`/artist/${event.artist.titleSlug}`).toString(), + name: "Lidarr", + logo: "/images/apps/lidarr.svg", + color: undefined, + isDark: true, + }); + } + for (const link of event.artist.links) { - switch (link.name) { - case "vgmdb": - links.push({ - href: link.url, - name: "VgmDB", - color: "#f5c518", - isDark: false, - logo: "/images/apps/vgmdb.svg", - }); - break; - case "imdb": - links.push({ - href: link.url, - name: "IMDb", - color: "#f5c518", - isDark: false, - logo: "/images/apps/imdb.png", - }); - break; - case "last": - links.push({ - href: link.url, - name: "LastFM", - color: "#cf222a", - isDark: false, - logo: "/images/apps/lastfm.svg", - }); - break; + const externalLink = lidarrExternalCalendarLinks[link.name]; + if (!externalLink) { + continue; } + links.push({ ...externalLink, href: link.url }); } return links; @@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati ): z.infer["images"][number] | undefined => { const flatImages = [...event.images]; - const sortedImages = flatImages.sort( + const sortedImages = flatImages.toSorted( (imageA, imageB) => mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType), ); @@ -149,6 +137,16 @@ const lidarrCalendarEventSchema = z.object({ title: z.string(), overview: z.string().optional(), images: lidarrCalendarEventImageSchema, - artist: z.object({ links: z.array(z.object({ url: z.string().url(), name: z.string() })), artistName: z.string() }), + artist: z.object({ + links: z.array(z.object({ url: z.string().url(), name: z.string() })), + artistName: z.string(), + titleSlug: z.string().optional(), + }), releaseDate: z.string().transform((value) => new Date(value)), }); + +const lidarrExternalCalendarLinks: Record> = { + vgmdb: { name: "VgmDB", color: "#f5c518", isDark: false, logo: "/images/apps/vgmdb.svg" }, + imdb: { name: "IMDb", color: "#f5c518", isDark: false, logo: "/images/apps/imdb.png" }, + last: { name: "LastFM", color: "#cf222a", isDark: false, logo: "/images/apps/lastfm.svg" }, +}; diff --git a/packages/integrations/test/glances-unit.spec.ts b/packages/integrations/test/glances-unit.spec.ts index 121ab594a..07f908e9e 100644 --- a/packages/integrations/test/glances-unit.spec.ts +++ b/packages/integrations/test/glances-unit.spec.ts @@ -1,11 +1,11 @@ // @vitest-environment node -import { describe, expect, test, vi } from "vitest"; +import { beforeEach, describe, expect, test, vi } from "vitest"; import { createDb } from "@homarr/db/test"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; -import { GlancesIntegration } from "../src/glances/glances-integration"; +import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration"; // ─── module mocks ──────────────────────────────────────────────────────────── @@ -64,6 +64,19 @@ function makeResponse(body: unknown) { } as never; } +function mockGlancesFetch(statsBody: unknown, sensorsBody: unknown) { + mockFetch.mockImplementation(((url: string | URL) => { + const urlString = url.toString(); + if (urlString.endsWith("/api/4/all")) { + return Promise.resolve(makeResponse(statsBody)); + } + if (urlString.endsWith("/api/4/sensors")) { + return Promise.resolve(makeResponse(sensorsBody)); + } + return Promise.reject(new Error(`Unexpected fetch URL: ${urlString}`)); + }) as typeof fetchWithTrustedCertificatesAsync); +} + const integrationInput = { id: "test-glances", name: "Glances", @@ -75,8 +88,12 @@ const integrationInput = { // ─── tests ─────────────────────────────────────────────────────────────────── describe("GlancesIntegration schema", () => { + beforeEach(() => { + mockFetch.mockReset(); + }); + test("parses stats when quicklook is present", async () => { - mockFetch.mockResolvedValueOnce(makeResponse({ ...baseStats, quicklook: { cpu_name: "Apple M1" } })); + mockGlancesFetch({ ...baseStats, quicklook: { cpu_name: "Apple M1" } }, []); const integration = new GlancesIntegration(integrationInput); const result = await integration.getSystemInfoAsync(); @@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => { }); test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => { - mockFetch.mockResolvedValueOnce(makeResponse(baseStats)); + mockGlancesFetch(baseStats, []); const integration = new GlancesIntegration(integrationInput); const result = await integration.getSystemInfoAsync(); @@ -95,9 +112,41 @@ describe("GlancesIntegration schema", () => { }); test("does not throw a Zod parse error when quicklook is missing", async () => { - mockFetch.mockResolvedValueOnce(makeResponse(baseStats)); + mockGlancesFetch(baseStats, []); const integration = new GlancesIntegration(integrationInput); await expect(integration.getSystemInfoAsync()).resolves.not.toThrow(); }); + + test("reads CPU temperature from sensors", async () => { + mockGlancesFetch(baseStats, [ + { label: "CPU", unit: "C", value: 58, type: "temperature_core" }, + { label: "Core 0", unit: "C", value: 54, type: "temperature_core" }, + ]); + + const integration = new GlancesIntegration(integrationInput); + const result = await integration.getSystemInfoAsync(); + + expect(result.cpuTemp).toBe(58); + }); +}); + +describe("parseGlancesCpuTempFromSensors", () => { + test("prefers CPU label over core sensors", () => { + const result = parseGlancesCpuTempFromSensors([ + { label: "Core 0", value: 54, type: "temperature_core" }, + { label: "CPU", value: 58, type: "temperature_core" }, + ]); + + expect(result).toBe(58); + }); + + test("falls back to Package id 0", () => { + const result = parseGlancesCpuTempFromSensors([ + { label: "Core 0", value: 54, type: "temperature_core" }, + { label: "Package id 0", value: 62, type: "temperature_core" }, + ]); + + expect(result).toBe(62); + }); }); diff --git a/packages/integrations/test/lidarr-unit.spec.ts b/packages/integrations/test/lidarr-unit.spec.ts new file mode 100644 index 000000000..b788565a6 --- /dev/null +++ b/packages/integrations/test/lidarr-unit.spec.ts @@ -0,0 +1,76 @@ +// @vitest-environment node + +import { describe, expect, test, vi } from "vitest"; + +import { createDb } from "@homarr/db/test"; +import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; + +import { LidarrIntegration } from "../src/media-organizer/lidarr/lidarr-integration"; + +vi.mock("@homarr/db", async (importActual) => { + const actual = await importActual(); + return { ...actual, db: createDb() }; +}); + +vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => { + const actual = await importActual(); + return { + ...actual, + getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]), + }; +}); + +vi.mock("@homarr/core/infrastructure/http", () => ({ + fetchWithTrustedCertificatesAsync: vi.fn(), +})); + +const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync); + +const integrationInput = { + id: "test-lidarr", + name: "Lidarr", + url: "http://localhost:8686", + decryptedSecrets: [{ kind: "apiKey" as const, value: "test-key" }], + externalUrl: null, +}; + +describe("LidarrIntegration calendar", () => { + test("includes Lidarr artist link in calendar events", async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + json: () => + Promise.resolve([ + { + title: "Test Album", + overview: "Overview", + images: [], + artist: { + artistName: "Test Artist", + titleSlug: "test-artist", + links: [{ name: "last", url: "https://last.fm/music/Test+Artist" }], + }, + releaseDate: "2026-01-01T00:00:00Z", + }, + ]), + } as never); + + const integration = new LidarrIntegration(integrationInput); + const events = await integration.getCalendarEventsAsync(new Date("2026-01-01"), new Date("2026-01-31")); + + expect(events[0]?.links[0]).toMatchObject({ + name: "Lidarr", + href: "http://localhost:8686/artist/test-artist", + logo: "/images/apps/lidarr.svg", + }); + // The existing external artist link must survive the Lidarr-link prepend. + expect(events[0]?.links).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "LastFM", + href: "https://last.fm/music/Test+Artist", + logo: "/images/apps/lastfm.svg", + }), + ]), + ); + }); +}); diff --git a/packages/widgets/src/app/app.module.css b/packages/widgets/src/app/app.module.css index e1477f431..b14962396 100644 --- a/packages/widgets/src/app/app.module.css +++ b/packages/widgets/src/app/app.module.css @@ -8,6 +8,8 @@ transition: scale 0.2s ease-in-out; user-select: none; -webkit-touch-callout: none; + -webkit-user-drag: none; + touch-action: manipulation; } .appWithUrl:hover > .appIcon { diff --git a/packages/widgets/src/app/component.tsx b/packages/widgets/src/app/component.tsx index 054a2da6b..21ca86efd 100644 --- a/packages/widgets/src/app/component.tsx +++ b/packages/widgets/src/app/component.tsx @@ -87,6 +87,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget justify="center" align="center" gap={isColumnLayout ? 0 : "sm"} + onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined} > {options.showTitle && ( diff --git a/packages/widgets/src/notebook/notebook.tsx b/packages/widgets/src/notebook/notebook.tsx index 97721daf7..229b3730a 100644 --- a/packages/widgets/src/notebook/notebook.tsx +++ b/packages/widgets/src/notebook/notebook.tsx @@ -1,5 +1,6 @@ "use client"; +import type { MouseEvent as ReactMouseEvent } from "react"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionIcon, @@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W setIsEditing(handleEditToggleCallback); }, [setIsEditing, handleEditToggleCallback]); + const handleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + if (!canChange || isEditing) return; + // Ignore double-clicks bubbling up from interactive controls (e.g. the + // edit/save ActionIcon), which would otherwise toggle edit mode twice. + if (event.target instanceof Element && event.target.closest("button, a")) { + return; + } + setIsEditing(handleEditToggleCallback); + }, + [canChange, isEditing, setIsEditing, handleEditToggleCallback], + ); + return ( - + {hasError ? ( @@ -564,7 +564,7 @@ const ExpandedDisplay = ({ {formatter.relativeTime(repository.createdAt, { now, - style: "narrow", + style: "long", })}