Compare commits

..
Author SHA1 Message Date
Thomas Camlong 6befc5ad41 fix(backup): log restore errors via the central logger and harden cleanup
Replace raw console.error calls in the backup import/migrations routes with
createLogger + ErrorWithMetadata so restore failures flow through the standard
log transports (console + Redis). Wrap the temporary DB close in finally so a
close failure cannot 500 a successful restore, and log client-side restore and
analysis failures to the browser console.
2026-08-18 11:14:04 +02:00
Thomas Camlong 2a76bb185b fix(backup): show explicit sign-in prompt after database restore
Restoring a backup replaces the database, invalidating the database-backed
session (Auth.js session strategy: database). The old code silently reloaded
the page, leaving users on an empty board or a login screen with no context.

Replace the opaque reload with an explicit completion state that informs the
user their session was invalidated and auto-redirects to the sign-in page
(bypassing bfcache so the stale board cannot be restored).
2026-08-18 11:11:23 +02:00
15 changed files with 136 additions and 302 deletions

No files matched your search

@@ -27,7 +27,7 @@ import Alert from '@theme/Admonition';
]}
/>
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
The storage values shown by these widgets use the root ZFS dataset's usable `used` and `available` space, including RAIDZ parity overhead and dataset limits.
### Adding the integration
<AddingIntegration />
+12 -4
View File
@@ -12,10 +12,14 @@ import { auth } from "@homarr/auth/next";
import { env } from "@homarr/common/env";
import { DB_CASING } from "@homarr/core/infrastructure/db/constants";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { db } from "@homarr/db";
import { findMigrationsFolder } from "../shared";
const logger = createLogger({ module: "backupImportRoute" });
const REQUIRED_ZIP_ENTRIES = ["db.sqlite", "metadata.json"] as const;
const ALGORITHM = "aes-256-cbc";
const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/;
@@ -174,22 +178,26 @@ export async function POST(req: Request) {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
logger.warn("Failed to remove SQLite sidecar file after restore", { suffix, dbPath, cause: cleanupErr });
}
}
setTimeout(() => {
console.log("Database restored, restarting server...");
logger.info("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
console.error("[backup/import] Restore failed:", error);
logger.error(new ErrorWithMetadata("Backup restore failed", { dbPath }, { cause: error }));
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
tempDb?.close();
try {
tempDb?.close();
} catch (closeErr) {
logger.warn("Failed to close temporary database after restore", { cause: closeErr });
}
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
@@ -3,8 +3,13 @@ import path from "path";
import { NextResponse } from "next/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { findMigrationsFolder } from "../shared";
const logger = createLogger({ module: "backupMigrationsRoute" });
interface JournalEntry {
idx: number;
version: string;
@@ -56,7 +61,7 @@ export async function GET() {
},
);
} catch (error) {
console.error("[backup/migrations] Failed to read migrations:", error);
logger.error(new ErrorWithMetadata("Failed to read migration files", {}, { cause: error }));
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Failed to read migrations: ${message}` }, { status: 500 });
}
@@ -1,10 +1,10 @@
"use client";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text, ThemeIcon } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import { IconAlertTriangle, IconArrowRight, IconCheck, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
@@ -30,8 +30,17 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
const apiDoneRef = useRef(false);
const animDoneRef = useRef(false);
const apiErrorRef = useRef<string | null>(null);
const redirectTimerRef = useRef<number | null>(null);
const { analysis, loading, error: analysisError, migrationProgress, analyzeFile, reset } = useBackupAnalysis();
useEffect(() => {
return () => {
if (redirectTimerRef.current !== null) {
window.clearTimeout(redirectTimerRef.current);
}
};
}, []);
const handleFileDrop = useCallback(
(files: FileWithPath[]) => {
const droppedFile = files[0];
@@ -45,6 +54,10 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
);
const handleClear = useCallback(() => {
if (redirectTimerRef.current !== null) {
window.clearTimeout(redirectTimerRef.current);
redirectTimerRef.current = null;
}
setFile(null);
setStep("upload");
setImportError(null);
@@ -65,9 +78,20 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
if (!animDoneRef.current) return;
onRestoreComplete?.();
window.location.reload();
setStep("complete");
redirectTimerRef.current = window.setTimeout(() => {
window.location.assign("/auth/login");
}, 2000);
}, [onRestoreComplete]);
const handleSignInAgain = useCallback(() => {
if (redirectTimerRef.current !== null) {
window.clearTimeout(redirectTimerRef.current);
redirectTimerRef.current = null;
}
window.location.assign("/auth/login");
}, []);
const handleConfirm = useCallback(async () => {
if (!file) return;
setStep("restoring");
@@ -89,7 +113,8 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch {
} catch (error) {
console.error("Restore request failed", error);
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
@@ -212,6 +237,23 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
);
}
if (step === "complete") {
return (
<Stack gap="md" align="center" py="lg">
<ThemeIcon size={64} radius="xl" color="green" variant="light">
<IconCheck size={32} style={{ width: rem(40), height: rem(40) }} stroke={1.5} />
</ThemeIcon>
<Text size="lg" fw={600} ta="center">
{t("complete.title")}
</Text>
<Text size="sm" c="dimmed" ta="center" maw={480}>
{t("complete.message")}
</Text>
<Button onClick={handleSignInAgain}>{t("complete.button")}</Button>
</Stack>
);
}
if (step === "error") {
return (
<Stack gap="md">
+1 -1
View File
@@ -25,6 +25,6 @@ export interface BackupAnalysis {
migrations: MigrationStatus;
}
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "complete" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -168,6 +168,7 @@ export const useBackupAnalysis = () => {
dbRef.current = null;
}
} catch (err) {
console.error("Backup analysis failed", err);
setError(err instanceof Error ? err.message : t("analyzeError"));
} finally {
setLoading(false);
@@ -59,12 +59,10 @@ const ws = vi.hoisted(() => {
}
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
// A responder returning an Error mimics a method the server rejects, e.g. an unsupported filter.
const body = result instanceof Error ? { error: { message: result.message } } : { result };
const envelope =
payload.jsonrpc === "2.0"
? { jsonrpc: "2.0", id: payload.id, ...body }
: { msg: "result", id: payload.id, ...body };
? { jsonrpc: "2.0", id: payload.id, result }
: { msg: "result", id: payload.id, result };
this.respond(JSON.stringify(envelope));
}
@@ -93,10 +91,7 @@ const ws = vi.hoisted(() => {
};
});
const logs = vi.hoisted(() => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }));
vi.mock("ws", () => ({ WebSocket: ws.FakeWebSocket }));
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
@@ -172,11 +167,6 @@ const happyResponder = (method: string) => {
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
// `pool.query` reports 500 allocated of a 1000 byte pool, leaving 500 free.
const physicalFallbackFileSystem = [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }];
const ERROR_MESSAGE_LIMIT = 200;
let integrationCounter = 0;
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
new TrueNasIntegration({
@@ -198,7 +188,6 @@ describe("TrueNasIntegration", () => {
ws.constructedUrls.length = 0;
ws.failures.length = 0;
ws.setResponder(happyResponder);
logs.warn.mockClear();
});
test("fetches and maps system info over the JSON-RPC API", async () => {
@@ -241,112 +230,6 @@ describe("TrueNasIntegration", () => {
});
});
test("falls back to physical pool space when the root dataset is not returned", async () => {
ws.setResponder((method) => (method === "pool.dataset.query" ? [] : happyResponder(method)));
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// Dropping the pool would also hide its health status.
expect(result.smart).toMatchObject([{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" }]);
});
test("falls back to physical pool space when the root dataset reports no usable values", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: null }, available: null }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the root dataset omits the requested properties", async () => {
// Datasets that cannot report space, e.g. while locked, come back without `used` and `available`.
ws.setResponder((method) =>
method === "pool.dataset.query" ? [{ id: "tank", locked: true }] : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
});
test("falls back to physical pool space when the dataset response fails schema validation", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query" ? { unexpected: "not an array" } : happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A malformed payload must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("falls back to physical pool space when the dataset query is rejected", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error("[EINVAL] filters: Value error, Invalid operation: in")
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem).toMatchObject(physicalFallbackFileSystem);
// A rejected dataset query must not take the rest of the system info down with it.
expect(result).toMatchObject({ cpuTemp: 45, memAvailableInBytes: 6_000_000_000, uptime: 3600 });
});
test("logs the truncated rejection reason rather than the generic wrapper message", async () => {
// TrueNAS answers a rejected method with its Python traceback, which runs to several kilobytes.
ws.setResponder((method) =>
method === "pool.dataset.query"
? new Error(`[EINVAL] query-filters ${"x".repeat(5000)}`)
: happyResponder(method),
);
const integration = createIntegration(credentials);
await integration.getSystemInfoAsync();
const logged = logs.warn.mock.calls.find(([message]) => String(message).includes("Could not retrieve"));
const reason = String((logged?.[1] as { error?: string } | undefined)?.error);
// The decorator would otherwise surface only "An unknown error occured while executing Integration method".
expect(reason).toContain("[EINVAL] query-filters");
expect(reason).toHaveLength(ERROR_MESSAGE_LIMIT + 1); // truncated, plus the ellipsis
});
test("keeps reporting a pool that is unhealthy but has no usable dataset space", async () => {
ws.setResponder((method) => {
if (method === "pool.query")
return [
{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 },
{ name: "backup", status: "DEGRADED", healthy: false, free: 10, size: 100, allocated: 90 },
];
if (method === "pool.dataset.query") return [{ id: "tank", used: { parsed: 200 }, available: { parsed: 300 } }];
return happyResponder(method);
});
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.smart).toMatchObject([
{ deviceName: "tank", healthy: true, overallStatus: "ONLINE" },
{ deviceName: "backup", healthy: false, overallStatus: "DEGRADED" },
]);
expect(result.fileSystem).toMatchObject([
{ deviceName: "tank", available: "300", used: "200", percentage: 40 },
{ deviceName: "backup", available: "10", used: "90", percentage: 90 },
]);
});
test("reports zero usage for an empty root dataset", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
@@ -15,20 +15,6 @@ const logger = createLogger({ module: "trueNasIntegration" });
const NETWORK_MULTIPLIER = 100;
const ERROR_MESSAGE_LIMIT = 200;
/**
* The error decorator replaces a rejected request with a generic message and moves the actionable
* reason to `cause`. TrueNAS puts its middleware traceback in there, several kilobytes of it, so the
* reason is truncated before it reaches a log line that repeats on every poll.
*/
const describeRequestError = (error: unknown) => {
const cause = error instanceof Error ? error.cause : undefined;
const reason = cause instanceof Error ? cause.message : error instanceof Error ? error.message : String(error);
return reason.length > ERROR_MESSAGE_LIMIT ? `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…` : reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
@@ -122,10 +108,9 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
...pool,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
@@ -134,75 +119,39 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
})
.filter((pool) => pool !== null);
if (activePools.length === 0) return [];
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", activePools.map((pool) => pool.name)]],
{
extra: {
properties: ["used", "available"],
},
},
]);
const parsedDatasets = await poolDatasetSchema.parseAsync(datasets);
const poolsWithUsableSpace = activePools
.map((pool) => {
const dataset = parsedDatasets.find((candidate) => candidate.id === pool.name);
if (!dataset || dataset.used.parsed === null || dataset.available.parsed === null) return null;
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: dataset.used.parsed,
available: dataset.available.parsed,
};
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
activeCount: poolsWithUsableSpace.length,
});
if (activePools.length === 0) return [];
const usableSpaceByPool = await this.getUsableSpaceByPoolAsync(activePools.map((pool) => pool.name));
return activePools.map((pool) => {
const usableSpace = usableSpaceByPool.get(pool.name);
if (!usableSpace) {
logger.warn("Falling back to physical pool space, the root dataset reported no usable space", {
url: this.integration.url,
pool: pool.name,
});
}
return {
name: pool.name,
status: pool.status,
healthy: pool.healthy,
used: usableSpace?.used ?? pool.allocated,
available: usableSpace?.available ?? pool.free,
};
});
}
/**
* Returns the usable space of each pool's root ZFS dataset, keyed by pool name. Unlike the physical
* `pool.query` values, these account for RAIDZ parity overhead and dataset limits, matching `zfs list`.
* See https://github.com/homarr-labs/homarr/issues/6566
*
* A pool is left out when its usable space is unavailable, so the caller keeps reporting the pool with
* its physical values instead of dropping it, which would also hide its health status:
* - request rejected → method or `extra.properties` unsupported, or the API key lacks the dataset role
* - response rejected → the payload does not match the expected shape
* - dataset missing → the pool's root dataset was not returned by the query
* - property absent → the dataset does not report `used` / `available`, e.g. while locked
*/
private async getUsableSpaceByPoolAsync(poolNames: string[]) {
const usableSpaceByPool = new Map<string, { used: number; available: number }>();
try {
const datasets = await this.requestAsync("pool.dataset.query", [
[["id", "in", poolNames]],
{
extra: {
properties: ["used", "available"],
},
},
]);
for (const dataset of await poolDatasetSchema.parseAsync(datasets)) {
const used = dataset.used?.parsed;
const available = dataset.available?.parsed;
if (typeof used !== "number" || typeof available !== "number") continue;
usableSpaceByPool.set(dataset.id, { used, available });
}
} catch (error) {
logger.warn("Could not retrieve root dataset space, continuing with physical pool space", {
url: this.integration.url,
error: describeRequestError(error),
});
}
return usableSpaceByPool;
return poolsWithUsableSpace;
}
/**
@@ -343,16 +292,11 @@ const poolSchema = z.array(
}),
);
// Properties are absent or unparsed for datasets that do not report them, e.g. while locked,
// so they are optional here and the pool falls back to its physical values instead of failing
// the whole request, which would also drop cpu, memory and network data
const poolDatasetPropertySchema = z.object({ parsed: z.number().min(0).nullish() }).nullish();
const poolDatasetSchema = z.array(
z.object({
id: z.string(),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
used: z.object({ parsed: z.number().min(0).nullable() }),
available: z.object({ parsed: z.number().min(0).nullable() }),
}),
);
+2 -1
View File
@@ -5276,7 +5276,8 @@
},
"complete": {
"title": "Restore Complete",
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
"message": "Database has been restored successfully and the server has restarted. Your previous session was invalidated, so you need to sign in again to see the restored board.",
"button": "Sign in now"
},
"failed": {
"title": "Restore Failed"
@@ -1,6 +1,10 @@
import { describe, expect, test } from "vitest";
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
import {
filterStorageVolumes,
normalizeStorageDeviceName,
toScopedStorageVolumeValue,
} from "./filter-storage-volumes";
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
describe("normalizeStorageDeviceName", () => {
@@ -9,12 +13,6 @@ describe("normalizeStorageDeviceName", () => {
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
});
test("strips partition suffixes from unprefixed device names (OpenMediaVault)", () => {
expect(normalizeStorageDeviceName("sda1")).toBe("sda");
expect(normalizeStorageDeviceName("sda")).toBe("sda");
expect(normalizeStorageDeviceName("nvme0n1p1")).toBe("nvme0n1");
});
test("preserves Synology-style volume names", () => {
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
@@ -145,36 +143,4 @@ describe("matchFileSystemAndSmart", () => {
overallStatus: "",
});
});
test("joins unprefixed OMV partitions with their base device SMART data", () => {
const result = matchFileSystemAndSmart(
[
{ deviceName: "sda1", used: "3.13 GiB", available: "26853056512", percentage: 12 },
{ deviceName: "sdb1", used: "396.24 GiB", available: "1542365618176", percentage: 22 },
],
[
{ deviceName: "sda", temperature: 0, overallStatus: "BAD_STATUS" },
{ deviceName: "sdb", temperature: 33, overallStatus: "GOOD" },
],
);
expect(result).toEqual([
{
deviceName: "sda",
used: "3.13 GiB",
available: "26853056512",
percentage: 12,
temperature: 0,
overallStatus: "BAD_STATUS",
},
{
deviceName: "sdb",
used: "396.24 GiB",
available: "1542365618176",
percentage: 22,
temperature: 33,
overallStatus: "GOOD",
},
]);
});
});
@@ -5,16 +5,10 @@ interface StorageVolumeEntry {
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// SCSI/SATA/VirtIO without device node prefix: sda1 -> sda (OMV reports unprefixed names)
{ pattern: /^((?:sd|vd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// NVMe without device node prefix: nvme0n1p2 -> nvme0n1
{ pattern: /^(nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC without device node prefix: mmcblk0p1 -> mmcblk0
{ pattern: /^(mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
@@ -34,14 +28,18 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
return `${integrationId}:${volumeName}`;
};
export const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
return (
leftDeviceName === rightDeviceName ||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
);
};
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
const matchesVisibleStorageVolume = (
visibleVolume: string,
integrationId: string,
deviceName: string,
): boolean => {
const separatorIndex = visibleVolume.indexOf(":");
if (separatorIndex === -1) {
return storageDeviceNamesMatch(visibleVolume, deviceName);
@@ -18,18 +18,16 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations, isPending, isError } = clientApi.integration.byIds.useQuery(props.integrationIds);
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
if (isPending || isError) return null;
const clusterIntegrationId = integrations?.find(isClusterIntegration)?.id;
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
if (!clusterIntegrationId) {
return <SystemHealthMonitoring {...props} />;
}
const otherIntegrationIds = (integrations ?? [])
const otherIntegrationIds = integrations
// We want to have the mock integration also in the system tab, so we use it for both
.filter((integration) => integration.kind !== "proxmox")
.map((integration) => integration.id);
@@ -36,7 +36,7 @@ import { formatBytes } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
@@ -296,7 +296,12 @@ interface SmartData {
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const smartDisk = smartData.find((smart) => storageDeviceNamesMatch(smart.deviceName, fileSystem.deviceName));
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
const smartDisk = smartData.find(
(smart) =>
smart.deviceName === fileSystem.deviceName ||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
);
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -140,7 +140,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
return (
<Stack gap="xs" p="xs" h="100%">
{fileSystem.map((item) => {
const smartItem = smart.find((smart) => storageDeviceNamesMatch(smart.deviceName, item.deviceName));
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
return (
<SystemDiskCard
+8 -25
View File
@@ -7,31 +7,14 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# Only chown the paths the non-root user must actually write to at runtime.
# Recursively chowning the whole /app tree is extremely slow on network- or
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
# bundle and its node_modules contain tens of thousands of tiny files. This
# turned a ~10s step into 15+ minutes for some users (#2190). Application
# code, migrations and native modules are only read/executed, so they stay
# owned by root and do not need chowning.
echo "Changing owner to $PUID:$PGID..."
# Application data volume (db, redis dumps, trusted certificates).
# Ensure the known subdirectories exist and migrate ownership of any
# existing content, so a persistent volume with stale or root-owned
# entries stays writable after a PUID/PGID change. These trees are small
# (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
chown "${PUID}:${PGID}" /appdata
chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates
# Next.js runtime cache (image optimization, fetch cache, ...)
mkdir -p /app/apps/nextjs/.next/cache
chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache
# nginx runtime directories and the rendered configuration
chown -R "${PUID}:${PGID}" /var/cache/nginx
chown -R "${PUID}:${PGID}" /var/log/nginx
chown -R "${PUID}:${PGID}" /var/lib/nginx
chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid
chown -R "${PUID}:${PGID}" /etc/nginx
# The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
chown -R $PUID:$PGID /var/cache/nginx
chown -R $PUID:$PGID /var/log/nginx
chown -R $PUID:$PGID /var/lib/nginx
chown -R $PUID:$PGID /run/nginx/nginx.pid
chown -R $PUID:$PGID /etc/nginx
echo "Changing owner to $PUID:$PGID, done."
fi