fix: quick wins — calendar, about, glances, admin UX, releases, iframe, notes (#6116)
This commit is contained in:
1 parent
b83c8b6546
commit
f9da71a19b
16 files changed
+283
-67
No files matched your search
Vendored
+1
-1
@@ -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"
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in new issue
Block a user