Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59f3033a63 | ||
|
|
9231611746 | ||
|
|
79d28f97ce | ||
|
|
f1c49d1fbf | ||
|
|
95c66c61eb | ||
|
|
ec2f8c6c2c |
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
|
### Adding the integration
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|||||||
@@ -59,10 +59,12 @@ const ws = vi.hoisted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = responder(payload.method as string, (payload.params as unknown[] | undefined) ?? []);
|
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 =
|
const envelope =
|
||||||
payload.jsonrpc === "2.0"
|
payload.jsonrpc === "2.0"
|
||||||
? { jsonrpc: "2.0", id: payload.id, result }
|
? { jsonrpc: "2.0", id: payload.id, ...body }
|
||||||
: { msg: "result", id: payload.id, result };
|
: { msg: "result", id: payload.id, ...body };
|
||||||
this.respond(JSON.stringify(envelope));
|
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("ws", () => ({ WebSocket: ws.FakeWebSocket }));
|
||||||
|
vi.mock("@homarr/core/infrastructure/logs", () => ({ createLogger: () => logs }));
|
||||||
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
vi.mock("@homarr/core/infrastructure/certificates", () => ({
|
||||||
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
getAllTrustedCertificatesAsync: vi.fn().mockResolvedValue([]),
|
||||||
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
getTrustedCertificateHostnamesAsync: vi.fn().mockResolvedValue([]),
|
||||||
@@ -167,6 +172,11 @@ const happyResponder = (method: string) => {
|
|||||||
|
|
||||||
const emptyAggregations = () => ({ min: {}, mean: {}, max: {} });
|
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;
|
let integrationCounter = 0;
|
||||||
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
const createIntegration = (decryptedSecrets: IntegrationSecret[]) =>
|
||||||
new TrueNasIntegration({
|
new TrueNasIntegration({
|
||||||
@@ -188,6 +198,7 @@ describe("TrueNasIntegration", () => {
|
|||||||
ws.constructedUrls.length = 0;
|
ws.constructedUrls.length = 0;
|
||||||
ws.failures.length = 0;
|
ws.failures.length = 0;
|
||||||
ws.setResponder(happyResponder);
|
ws.setResponder(happyResponder);
|
||||||
|
logs.warn.mockClear();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("fetches and maps system info over the JSON-RPC API", async () => {
|
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 () => {
|
test("reports zero usage for an empty root dataset", async () => {
|
||||||
ws.setResponder((method) =>
|
ws.setResponder((method) =>
|
||||||
method === "pool.dataset.query"
|
method === "pool.dataset.query"
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ const logger = createLogger({ module: "trueNasIntegration" });
|
|||||||
|
|
||||||
const NETWORK_MULTIPLIER = 100;
|
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 {
|
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
private client?: TrueNasClient;
|
private client?: TrueNasClient;
|
||||||
|
|
||||||
@@ -108,9 +122,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
.map((pool) => {
|
.map((pool) => {
|
||||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||||
return {
|
return {
|
||||||
...pool,
|
name: pool.name,
|
||||||
|
status: pool.status,
|
||||||
|
healthy: pool.healthy,
|
||||||
allocated: pool.allocated,
|
allocated: pool.allocated,
|
||||||
size: pool.size,
|
|
||||||
free: pool.free,
|
free: pool.free,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -119,39 +134,75 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
})
|
})
|
||||||
.filter((pool) => pool !== null);
|
.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", {
|
logger.debug("Retrieved pools", {
|
||||||
url: this.integration.url,
|
url: this.integration.url,
|
||||||
totalCount: result.length,
|
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(
|
const poolDatasetSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
id: z.string(),
|
id: z.string(),
|
||||||
used: z.object({ parsed: z.number().min(0).nullable() }),
|
used: poolDatasetPropertySchema,
|
||||||
available: z.object({ parsed: z.number().min(0).nullable() }),
|
available: poolDatasetPropertySchema,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
|
|
||||||
import {
|
import { filterStorageVolumes, normalizeStorageDeviceName, toScopedStorageVolumeValue } from "./filter-storage-volumes";
|
||||||
filterStorageVolumes,
|
|
||||||
normalizeStorageDeviceName,
|
|
||||||
toScopedStorageVolumeValue,
|
|
||||||
} from "./filter-storage-volumes";
|
|
||||||
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
|
import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
|
||||||
|
|
||||||
describe("normalizeStorageDeviceName", () => {
|
describe("normalizeStorageDeviceName", () => {
|
||||||
@@ -13,6 +9,12 @@ describe("normalizeStorageDeviceName", () => {
|
|||||||
expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
|
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", () => {
|
test("preserves Synology-style volume names", () => {
|
||||||
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
|
expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
|
||||||
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
|
expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
|
||||||
@@ -143,4 +145,36 @@ describe("matchFileSystemAndSmart", () => {
|
|||||||
overallStatus: "",
|
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 }> = [
|
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
|
||||||
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
|
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
|
||||||
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
|
{ 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
|
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
|
||||||
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
{ 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
|
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
|
||||||
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
{ 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 => {
|
export const normalizeStorageDeviceName = (deviceName: string): string => {
|
||||||
@@ -28,18 +34,14 @@ export const toScopedStorageVolumeValue = (integrationId: string, value: string)
|
|||||||
return `${integrationId}:${volumeName}`;
|
return `${integrationId}:${volumeName}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
|
export const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
|
||||||
return (
|
return (
|
||||||
leftDeviceName === rightDeviceName ||
|
leftDeviceName === rightDeviceName ||
|
||||||
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
|
normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const matchesVisibleStorageVolume = (
|
const matchesVisibleStorageVolume = (visibleVolume: string, integrationId: string, deviceName: string): boolean => {
|
||||||
visibleVolume: string,
|
|
||||||
integrationId: string,
|
|
||||||
deviceName: string,
|
|
||||||
): boolean => {
|
|
||||||
const separatorIndex = visibleVolume.indexOf(":");
|
const separatorIndex = visibleVolume.indexOf(":");
|
||||||
if (separatorIndex === -1) {
|
if (separatorIndex === -1) {
|
||||||
return storageDeviceNamesMatch(visibleVolume, deviceName);
|
return storageDeviceNamesMatch(visibleVolume, deviceName);
|
||||||
|
|||||||
@@ -18,16 +18,18 @@ const isClusterIntegration = (integration: { kind: IntegrationKind }) =>
|
|||||||
integration.kind === "proxmox" || integration.kind === "mock";
|
integration.kind === "proxmox" || integration.kind === "mock";
|
||||||
|
|
||||||
export default function HealthMonitoringWidget(props: WidgetComponentProps<"healthMonitoring">) {
|
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 t = useI18n();
|
||||||
|
|
||||||
const clusterIntegrationId = integrations.find(isClusterIntegration)?.id;
|
if (isPending || isError) return null;
|
||||||
|
|
||||||
|
const clusterIntegrationId = integrations?.find(isClusterIntegration)?.id;
|
||||||
|
|
||||||
if (!clusterIntegrationId) {
|
if (!clusterIntegrationId) {
|
||||||
return <SystemHealthMonitoring {...props} />;
|
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
|
// We want to have the mock integration also in the system tab, so we use it for both
|
||||||
.filter((integration) => integration.kind !== "proxmox")
|
.filter((integration) => integration.kind !== "proxmox")
|
||||||
.map((integration) => integration.id);
|
.map((integration) => integration.id);
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ import { formatBytes } from "@homarr/common";
|
|||||||
import type { TranslationFunction } from "@homarr/translation";
|
import type { TranslationFunction } from "@homarr/translation";
|
||||||
import { useI18n } from "@homarr/translation/client";
|
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 { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
import type { WidgetComponentProps } from "../definition";
|
||||||
import { CpuRing } from "./rings/cpu-ring";
|
import { CpuRing } from "./rings/cpu-ring";
|
||||||
@@ -296,12 +296,7 @@ interface SmartData {
|
|||||||
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
|
export const matchFileSystemAndSmart = (fileSystems: FileSystem[], smartData: SmartData[]) => {
|
||||||
return fileSystems
|
return fileSystems
|
||||||
.map((fileSystem) => {
|
.map((fileSystem) => {
|
||||||
const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
|
const smartDisk = smartData.find((smart) => storageDeviceNamesMatch(smart.deviceName, fileSystem.deviceName));
|
||||||
const smartDisk = smartData.find(
|
|
||||||
(smart) =>
|
|
||||||
smart.deviceName === fileSystem.deviceName ||
|
|
||||||
normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useI18n } from "@homarr/translation/client";
|
|||||||
|
|
||||||
import { WidgetEmptyState } from "../common/empty-state";
|
import { WidgetEmptyState } from "../common/empty-state";
|
||||||
import type { WidgetComponentProps } from "../definition";
|
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";
|
import { NoIntegrationDataError } from "../errors/no-data-integration";
|
||||||
|
|
||||||
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
|
||||||
@@ -140,7 +140,7 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
|
|||||||
return (
|
return (
|
||||||
<Stack gap="xs" p="xs" h="100%">
|
<Stack gap="xs" p="xs" h="100%">
|
||||||
{fileSystem.map((item) => {
|
{fileSystem.map((item) => {
|
||||||
const smartItem = smart.find((smart) => smart.deviceName === item.deviceName);
|
const smartItem = smart.find((smart) => storageDeviceNamesMatch(smart.deviceName, item.deviceName));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SystemDiskCard
|
<SystemDiskCard
|
||||||
|
|||||||
+25
-8
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
|
|||||||
echo "Starting with UID='$PUID', GID='$PGID'"
|
echo "Starting with UID='$PUID', GID='$PGID'"
|
||||||
|
|
||||||
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
|
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
|
# Only chown the paths the non-root user must actually write to at runtime.
|
||||||
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
|
# Recursively chowning the whole /app tree is extremely slow on network- or
|
||||||
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
|
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
|
||||||
chown -R $PUID:$PGID /var/cache/nginx
|
# bundle and its node_modules contain tens of thousands of tiny files. This
|
||||||
chown -R $PUID:$PGID /var/log/nginx
|
# turned a ~10s step into 15+ minutes for some users (#2190). Application
|
||||||
chown -R $PUID:$PGID /var/lib/nginx
|
# code, migrations and native modules are only read/executed, so they stay
|
||||||
chown -R $PUID:$PGID /run/nginx/nginx.pid
|
# owned by root and do not need chowning.
|
||||||
chown -R $PUID:$PGID /etc/nginx
|
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."
|
echo "Changing owner to $PUID:$PGID, done."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user