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",
})}