Compare commits

..
Author SHA1 Message Date
Afonso Jorge Ramos 59f3033a63 fix(truenas): fall back when the dataset response fails validation 2026-08-20 14:29:44 +02:00
Afonso Jorge Ramos 9231611746 docs(truenas): clarify how storage values are derived 2026-08-20 14:23:12 +02:00
Afonso Jorge Ramos 79d28f97ce fix(truenas): keep reporting pools without usable dataset space 2026-08-20 14:23:08 +02:00
Thomas f1c49d1fbf fix(startup): only chown runtime paths for custom PUID/PGID (#6598) 2026-08-19 08:29:17 +02:00
Thomas 95c66c61eb fix(widgets): stop Health Monitoring system tab firing NOT_FOUND when Proxmox is mixed in (#6623) 2026-08-18 19:56:47 +02:00
Thomas ec2f8c6c2c fix(widgets): match OMV disk temperature via normalized partition names (#6615) (#6619)
OMV reports filesystem devices (sda1) without the /dev/ prefix, so the
partition-suffix normalization never applied and filesystem-to-SMART
matching returned no temperature. Strip partition suffixes from
unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized
matcher in both the health monitoring and system disks widgets.
2026-08-18 19:39:27 +02:00
15 changed files with 302 additions and 136 deletions

No files matched your search

@@ -27,7 +27,7 @@ import Alert from '@theme/Admonition';
]}
/>
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.
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.
### Adding the integration
<AddingIntegration />
+4 -12
View File
@@ -12,14 +12,10 @@ 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}$/;
@@ -178,26 +174,22 @@ export async function POST(req: Request) {
const sidecar = `${dbPath}${suffix}`;
if (fs.existsSync(sidecar)) fs.unlinkSync(sidecar);
} catch (cleanupErr) {
logger.warn("Failed to remove SQLite sidecar file after restore", { suffix, dbPath, cause: cleanupErr });
console.error(`Failed to remove ${suffix} file:`, cleanupErr);
}
}
setTimeout(() => {
logger.info("Database restored, restarting server...");
console.log("Database restored, restarting server...");
process.exit(0);
}, 500);
return NextResponse.json({ success: true, message: "Database restored. Server is restarting..." });
} catch (error) {
logger.error(new ErrorWithMetadata("Backup restore failed", { dbPath }, { cause: error }));
console.error("[backup/import] Restore failed:", error);
const message = error instanceof Error ? error.message : "Unknown error";
return NextResponse.json({ error: `Restore failed: ${message}` }, { status: 500 });
} finally {
try {
tempDb?.close();
} catch (closeErr) {
logger.warn("Failed to close temporary database after restore", { cause: closeErr });
}
tempDb?.close();
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
@@ -3,13 +3,8 @@ 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;
@@ -61,7 +56,7 @@ export async function GET() {
},
);
} catch (error) {
logger.error(new ErrorWithMetadata("Failed to read migration files", {}, { cause: error }));
console.error("[backup/migrations] Failed to read migrations:", 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, useEffect, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text, ThemeIcon } from "@mantine/core";
import { useCallback, useRef, useState } from "react";
import { Alert, Button, Group, Loader, rem, Stack, Text } from "@mantine/core";
import type { FileWithPath } from "@mantine/dropzone";
import { Dropzone, MIME_TYPES } from "@mantine/dropzone";
import { IconAlertTriangle, IconArrowRight, IconCheck, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import { IconAlertTriangle, IconArrowRight, IconFileZip, IconUpload, IconX } from "@tabler/icons-react";
import "@mantine/dropzone/styles.css";
@@ -30,17 +30,8 @@ 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];
@@ -54,10 +45,6 @@ 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);
@@ -78,20 +65,9 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
if (!animDoneRef.current) return;
onRestoreComplete?.();
setStep("complete");
redirectTimerRef.current = window.setTimeout(() => {
window.location.assign("/auth/login");
}, 2000);
window.location.reload();
}, [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");
@@ -113,8 +89,7 @@ export const DatabaseRestoreFlow = ({ variant = "card", onRestoreComplete }: Dat
apiErrorRef.current = `Server returned ${response.status}`;
}
}
} catch (error) {
console.error("Restore request failed", error);
} catch {
apiErrorRef.current = t("failed.title");
} finally {
apiDoneRef.current = true;
@@ -237,23 +212,6 @@ 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" | "complete" | "error";
export type RestoreStep = "upload" | "preview" | "confirm" | "restoring" | "error";
export const PREVIEW_TABLE_KEYS = ["board", "user", "app", "integration", "item", "media", "search_engine"] as const;
@@ -168,7 +168,6 @@ 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,10 +59,12 @@ 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, result }
: { msg: "result", id: payload.id, result };
? { jsonrpc: "2.0", id: payload.id, ...body }
: { msg: "result", id: payload.id, ...body };
this.respond(JSON.stringify(envelope));
}
@@ -91,7 +93,10 @@ 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([]),
@@ -167,6 +172,11 @@ 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({
@@ -188,6 +198,7 @@ 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 () => {
@@ -230,6 +241,112 @@ 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,6 +15,20 @@ 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;
@@ -108,9 +122,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
.map((pool) => {
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
return {
...pool,
name: pool.name,
status: pool.status,
healthy: pool.healthy,
allocated: pool.allocated,
size: pool.size,
free: pool.free,
};
}
@@ -119,39 +134,75 @@ 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: poolsWithUsableSpace.length,
activeCount: activePools.length,
});
return poolsWithUsableSpace;
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;
}
/**
@@ -292,11 +343,16 @@ 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: z.object({ parsed: z.number().min(0).nullable() }),
available: z.object({ parsed: z.number().min(0).nullable() }),
used: poolDatasetPropertySchema,
available: poolDatasetPropertySchema,
}),
);
+1 -2
View File
@@ -5276,8 +5276,7 @@
},
"complete": {
"title": "Restore Complete",
"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"
"message": "Database has been restored successfully. The server is restarting — please refresh the page in a few seconds."
},
"failed": {
"title": "Restore Failed"
@@ -1,10 +1,6 @@
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", () => {
@@ -13,6 +9,12 @@ 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");
@@ -143,4 +145,36 @@ 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,10 +5,16 @@ 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 => {
@@ -28,18 +34,14 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
return `${integrationId}:${volumeName}`;
};
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
export 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,16 +18,18 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
integration.kind === "proxmox" || integration.kind === "mock";
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
const { data: integrations = [] } = clientApi.integration.byIds.useQuery(props.integrationIds);
const { data: integrations, isPending, isError } = clientApi.integration.byIds.useQuery(props.integrationIds);
const t = useI18n();
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
if (isPending || isError) return null;
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, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { filterStorageVolumes, storageDeviceNamesMatch } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
@@ -296,12 +296,7 @@ interface SmartData {
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
return fileSystems
.map((fileSystem) => {
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
const smartDisk = smartData.find(
(smart) =>
smart.deviceName === fileSystem.deviceName ||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
);
const smartDisk = smartData.find((smart) => storageDeviceNamesMatch(smart.deviceName, fileSystem.deviceName));
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 } from "../filter-storage-volumes";
import { filterStorageVolumes, storageDeviceNamesMatch } 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) => smart.deviceName === item.deviceName);
const smartItem = smart.find((smart) => storageDeviceNamesMatch(smart.deviceName, item.deviceName));
return (
<SystemDiskCard
+25 -8
View File
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
# 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
# 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
echo "Changing owner to $PUID:$PGID, done."
fi