fix: quick wins — calendar, about, glances, admin UX, releases, iframe, notes (#6116)

This commit is contained in:
Thomas authored and GitHub committed 2026-07-01 13:57:39 +02:00
1 parent b83c8b6546
commit f9da71a19b
16 files changed
+283 -67

No files matched your search

+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"recommendations": [ "recommendations": [
"dbaeumer.vscode-eslint", "dbaeumer.vscode-eslint",
"esbenp.prettier-vscode", "oxc.oxc-vscode",
"yoavbls.pretty-ts-errors", "yoavbls.pretty-ts-errors",
"million.million-lint", "million.million-lint",
"lokalise.i18n-ally" "lokalise.i18n-ally"
+1
View File
@@ -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/next.config.ts .
COPY --from=builder /app/apps/nextjs/package.json . 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 COPY --from=builder /app/node_modules/better-sqlite3/build/Release/better_sqlite3.node /app/build/better_sqlite3.node
@@ -10,6 +10,7 @@ import { extractBaseUrlFromHeaders } from "@homarr/common";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { extractMcpTools } from "~/app/api/mcp/_extract-tools"; import { extractMcpTools } from "~/app/api/mcp/_extract-tools";
import { ApiKeysManagement } from "./components/api-keys"; import { ApiKeysManagement } from "./components/api-keys";
import { ApiPageTabs } from "./components/api-page-tabs"; import { ApiPageTabs } from "./components/api-page-tabs";
@@ -79,17 +80,20 @@ export default async function ApiPage() {
const toolGroups = getMcpToolGroups(); const toolGroups = getMcpToolGroups();
return ( return (
<Stack> <>
<ApiPageTabs <DynamicBreadcrumb />
documentationLabel={t("documentation.label")} <Stack>
apiKeyLabel={t("apiKey.label")} <ApiPageTabs
mcpLabel={t("mcp.label")} documentationLabel={t("documentation.label")}
documentationPanel={<ScalarApiReference document={document} />} apiKeyLabel={t("apiKey.label")}
authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />} mcpLabel={t("mcp.label")}
baseUrl={baseUrl} documentationPanel={<ScalarApiReference document={document} />}
hasApiKeys={apiKeys.length > 0} authenticationPanel={<ApiKeysManagement apiKeys={apiKeys} />}
toolGroups={toolGroups} baseUrl={baseUrl}
/> hasApiKeys={apiKeys.length > 0}
</Stack> toolGroups={toolGroups}
/>
</Stack>
</>
); );
} }
@@ -6,6 +6,7 @@ import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { getScopedI18n } from "@homarr/translation/server"; import { getScopedI18n } from "@homarr/translation/server";
import { createMetaTitle } from "~/metadata"; import { createMetaTitle } from "~/metadata";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { BackupExportCard } from "./_components/backup-export-card"; import { BackupExportCard } from "./_components/backup-export-card";
import { BackupImportCard } from "./_components/backup-import-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"); const t = await getScopedI18n("management.page.tool.backup");
return ( return (
<Stack> <>
<Title>{t("title")}</Title> <DynamicBreadcrumb />
<BackupExportCard /> <Stack>
<BackupImportCard /> <Title>{t("title")}</Title>
</Stack> <BackupExportCard />
<BackupImportCard />
</Stack>
</>
); );
} }
@@ -7,6 +7,7 @@ import { getI18n, getScopedI18n } from "@homarr/translation/server";
import { Link } from "@homarr/ui"; import { Link } from "@homarr/ui";
import { ManageContainer } from "~/components/manage/manage-container"; import { ManageContainer } from "~/components/manage/manage-container";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { NavigationLink } from "./_navigation"; import { NavigationLink } from "./_navigation";
interface LayoutProps { interface LayoutProps {
@@ -24,6 +25,7 @@ export default async function Layout(props: PropsWithChildren<LayoutProps>) {
return ( return (
<ManageContainer size="xl"> <ManageContainer size="xl">
<DynamicBreadcrumb dynamicMappings={new Map([[params.id, group.name]])} />
<Grid> <Grid>
<GridCol span={12}> <GridCol span={12}>
<Group justify="space-between" align="center"> <Group justify="space-between" align="center">
@@ -10,3 +10,8 @@
background-color: var(--background-color) !important; background-color: var(--background-color) !important;
border-color: var(--border-color) !important; border-color: var(--border-color) !important;
} }
.itemCard:global(.iframe-wrapper) {
background-color: transparent !important;
border-color: transparent !important;
}
@@ -14,6 +14,8 @@ interface DynamicBreadcrumbProps {
nonInteractable?: string[]; nonInteractable?: string[];
} }
const categorySegments = new Set(["tools"]);
/** /**
* Breadcrumb is client side rendered. Elements are automatically * Breadcrumb is client side rendered. Elements are automatically
* calculated and translated using dynamic keys. * calculated and translated using dynamic keys.
@@ -60,8 +62,11 @@ export const DynamicBreadcrumb = ({
const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`; const translationKey = `${pathnameParts.slice(0, index + 1).join(".")}`;
const mappedValue = dynamicMappings?.get(pathnamePart); const mappedValue = dynamicMappings?.get(pathnamePart);
const isNonInteractable =
nonInteractable?.includes(pathnamePart) === true || categorySegments.has(pathnamePart);
if (mappedValue) { if (mappedValue) {
if (nonInteractable?.includes(pathnamePart)) { if (isNonInteractable) {
return <Text key={href}>{mappedValue}</Text>; return <Text key={href}>{mappedValue}</Text>;
} }
return ( return (
@@ -71,7 +76,7 @@ export const DynamicBreadcrumb = ({
); );
} }
if (nonInteractable?.includes(pathnamePart)) { if (isNonInteractable) {
return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>; return <Text key={href}>{t(`${translationKey}.label` as TranslationKeys)}</Text>;
} }
+15 -7
View File
@@ -35,14 +35,22 @@ export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> =
type DependencyCatalog = Map<string, string>; type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => { const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url)); const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigContent = await fsPromises.readFile( const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml"),
"utf-8",
);
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
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({ const workspaceConfigCatalogSchema = z.object({
@@ -35,7 +35,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
if (session == null) throw new Error("Session was unexpectitly null"); 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 { return {
cpuUtilization: stats.cpu.total, cpuUtilization: stats.cpu.total,
@@ -58,7 +58,7 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
cpuModelName: stats.quicklook?.cpu_name ?? "Unknown", cpuModelName: stats.quicklook?.cpu_name ?? "Unknown",
loadAverage: null, loadAverage: null,
smart: [], smart: [],
cpuTemp: undefined, cpuTemp,
gpu: stats.gpu.map((gpu) => ({ gpu: stats.gpu.map((gpu) => ({
gpuId: gpu.gpu_id, gpuId: gpu.gpu_id,
name: gpu.name, name: gpu.name,
@@ -80,6 +80,23 @@ export class GlancesIntegration extends Integration implements ISystemHealthMoni
return await response.text(); return await response.text();
} }
private async getCpuTempAsync(): Promise<number | undefined> {
// 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() { private async getAllStatsAsync() {
const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all")); const response = await fetchWithTrustedCertificatesAsync(this.url("/api/4/all"));
@@ -173,3 +190,33 @@ const allSchema = z.object({
) )
.default([]), .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<typeof sensorsSchema>): 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;
};
@@ -69,36 +69,24 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => { private getLinksForLidarrCalendarEvent = (event: z.infer<typeof lidarrCalendarEventSchema>) => {
const links: CalendarLink[] = []; 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) { for (const link of event.artist.links) {
switch (link.name) { const externalLink = lidarrExternalCalendarLinks[link.name];
case "vgmdb": if (!externalLink) {
links.push({ continue;
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;
} }
links.push({ ...externalLink, href: link.url });
} }
return links; return links;
@@ -109,7 +97,7 @@ export class LidarrIntegration extends Integration implements ICalendarIntegrati
): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => { ): z.infer<typeof lidarrCalendarEventSchema>["images"][number] | undefined => {
const flatImages = [...event.images]; const flatImages = [...event.images];
const sortedImages = flatImages.sort( const sortedImages = flatImages.toSorted(
(imageA, imageB) => (imageA, imageB) =>
mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType), mediaOrganizerPriorities.indexOf(imageA.coverType) - mediaOrganizerPriorities.indexOf(imageB.coverType),
); );
@@ -149,6 +137,16 @@ const lidarrCalendarEventSchema = z.object({
title: z.string(), title: z.string(),
overview: z.string().optional(), overview: z.string().optional(),
images: lidarrCalendarEventImageSchema, 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)), releaseDate: z.string().transform((value) => new Date(value)),
}); });
const lidarrExternalCalendarLinks: Record<string, Omit<CalendarLink, "href">> = {
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" },
};
@@ -1,11 +1,11 @@
// @vitest-environment node // @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 { createDb } from "@homarr/db/test";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http"; import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { GlancesIntegration } from "../src/glances/glances-integration"; import { GlancesIntegration, parseGlancesCpuTempFromSensors } from "../src/glances/glances-integration";
// ─── module mocks ──────────────────────────────────────────────────────────── // ─── module mocks ────────────────────────────────────────────────────────────
@@ -64,6 +64,19 @@ function makeResponse(body: unknown) {
} as never; } 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 = { const integrationInput = {
id: "test-glances", id: "test-glances",
name: "Glances", name: "Glances",
@@ -75,8 +88,12 @@ const integrationInput = {
// ─── tests ─────────────────────────────────────────────────────────────────── // ─── tests ───────────────────────────────────────────────────────────────────
describe("GlancesIntegration schema", () => { describe("GlancesIntegration schema", () => {
beforeEach(() => {
mockFetch.mockReset();
});
test("parses stats when quicklook is present", async () => { 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 integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync(); const result = await integration.getSystemInfoAsync();
@@ -86,7 +103,7 @@ describe("GlancesIntegration schema", () => {
}); });
test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => { test("returns Unknown cpuModelName when quicklook is absent (macOS scenario)", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats)); mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput); const integration = new GlancesIntegration(integrationInput);
const result = await integration.getSystemInfoAsync(); 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 () => { test("does not throw a Zod parse error when quicklook is missing", async () => {
mockFetch.mockResolvedValueOnce(makeResponse(baseStats)); mockGlancesFetch(baseStats, []);
const integration = new GlancesIntegration(integrationInput); const integration = new GlancesIntegration(integrationInput);
await expect(integration.getSystemInfoAsync()).resolves.not.toThrow(); 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);
});
}); });
@@ -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<typeof import("@homarr/db")>();
return { ...actual, db: createDb() };
});
vi.mock("@homarr/core/infrastructure/certificates", async (importActual) => {
const actual = await importActual<typeof import("@homarr/core/infrastructure/certificates")>();
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",
}),
]),
);
});
});
+2
View File
@@ -8,6 +8,8 @@
transition: scale 0.2s ease-in-out; transition: scale 0.2s ease-in-out;
user-select: none; user-select: none;
-webkit-touch-callout: none; -webkit-touch-callout: none;
-webkit-user-drag: none;
touch-action: manipulation;
} }
.appWithUrl:hover > .appIcon { .appWithUrl:hover > .appIcon {
+1
View File
@@ -87,6 +87,7 @@ export default function AppWidget({ options, isEditMode, height, width }: Widget
justify="center" justify="center"
align="center" align="center"
gap={isColumnLayout ? 0 : "sm"} gap={isColumnLayout ? 0 : "sm"}
onContextMenu={isEditMode ? (e) => e.preventDefault() : undefined}
> >
<Stack gap={0}> <Stack gap={0}>
{options.showTitle && ( {options.showTitle && (
+15 -1
View File
@@ -1,5 +1,6 @@
"use client"; "use client";
import type { MouseEvent as ReactMouseEvent } from "react";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
ActionIcon, ActionIcon,
@@ -263,8 +264,21 @@ export function Notebook({ options, setOptions, isEditMode, boardId, itemId }: W
setIsEditing(handleEditToggleCallback); setIsEditing(handleEditToggleCallback);
}, [setIsEditing, handleEditToggleCallback]); }, [setIsEditing, handleEditToggleCallback]);
const handleDoubleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>) => {
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 ( return (
<Box h="100%"> <Box h="100%" onDoubleClick={handleDoubleClick}>
<RichTextEditor <RichTextEditor
p={0} p={0}
mt={0} mt={0}
+2 -2
View File
@@ -247,7 +247,7 @@ export default function ReleasesWidget({ options }: WidgetComponentProps<"releas
!hasError && !hasError &&
formatter.relativeTime(repository.latestReleaseAt, { formatter.relativeTime(repository.latestReleaseAt, {
now, now,
style: "narrow", style: "long",
})} })}
</Text> </Text>
{hasError ? ( {hasError ? (
@@ -564,7 +564,7 @@ const ExpandedDisplay = ({
<Text className="releases-repository-expanded-createdAt-date" span fw={700}> <Text className="releases-repository-expanded-createdAt-date" span fw={700}>
{formatter.relativeTime(repository.createdAt, { {formatter.relativeTime(repository.createdAt, {
now, now,
style: "narrow", style: "long",
})} })}
</Text> </Text>
</Text> </Text>