fix: preserve dev fixes in v2 and add grid fuzzing

This commit is contained in:
ajnart committed 2026-09-10 19:58:53 +02:00
1 parent 7c00c18a42
commit c18548f7c2
18 files changed
+526 -31

No files matched your search

@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
headers: this.getAuthHeaders(),
headers: {
...this.getAuthHeaders(),
"Content-Type": "application/json",
},
body: JSON.stringify(body),
method: "POST",
});
@@ -158,6 +158,8 @@ const happyResponder = (method: string) => {
];
case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
case "pool.dataset.query":
return [{ id: "tank", used: { parsed: 500 }, available: { parsed: 500 } }];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
@@ -214,7 +216,7 @@ describe("TrueNasIntegration", () => {
loadAverage: null,
gpu: [],
network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size.
// `available` is the usable free space of the root dataset, not the total pool size.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
@@ -236,10 +238,14 @@ describe("TrueNasIntegration", () => {
expect([...gates.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
});
expect(gates.has("reporting.netdata_get_data")).toBe(false);
expect(gates.has("pool.dataset.query")).toBe(false);
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
gates.get("pool.query")?.resolve(happyResponder("pool.query"));
await vi.waitFor(() => expect(gates.has("pool.dataset.query")).toBe(true));
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
await expect(resultPromise).resolves.toMatchObject({
@@ -15,6 +15,22 @@ 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;
let reason = String(error);
if (error instanceof Error) reason = error.message;
if (cause instanceof Error) reason = cause.message;
if (reason.length > ERROR_MESSAGE_LIMIT) return `${reason.slice(0, ERROR_MESSAGE_LIMIT)}…`;
return reason;
};
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
private client?: TrueNasClient;
@@ -48,12 +64,17 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
memAvailableInBytes,
memUsedInBytes,
fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool
used: `${dataset.allocated}`,
percentage: (dataset.allocated / dataset.size) * 100,
})),
fileSystem: datasets.map((dataset) => {
const total = dataset.used + dataset.available;
let percentage = 0;
if (total > 0) percentage = (dataset.used / total) * 100;
return {
deviceName: dataset.name,
available: `${dataset.available}`,
used: `${dataset.used}`,
percentage,
};
}),
availablePkgUpdates: 0,
network: {
up: upload * NETWORK_MULTIPLIER,
@@ -108,9 +129,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,
};
}
@@ -118,12 +140,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null;
})
.filter((pool) => pool !== null);
logger.debug("Retrieved pools", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
});
return activePools;
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;
}
/**
@@ -264,6 +350,19 @@ 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,
}),
);
const reportingNetDataSchema = z.array(
z.object({
name: z.string(),
@@ -37,7 +37,11 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
const systemInfo = await this.getSystemInformationAsync();
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
const cpuCount = systemInfo.info.cpu.cores;
const cpuCount = systemInfo.metrics.cpu.cpus.length;
let cpuUtilizationNormalized = 0;
if (cpuCount > 0) {
cpuUtilizationNormalized = cpuUtilization / cpuCount;
}
const totalMemory = systemInfo.metrics.memory.total;
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
@@ -46,7 +50,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
return {
version: systemInfo.info.os.release,
cpuModelName: systemInfo.info.cpu.brand,
cpuUtilization: cpuUtilization / cpuCount,
cpuUtilization: cpuUtilizationNormalized,
memUsedInBytes: usedMemory,
memAvailableInBytes: totalMemory - usedMemory,
uptime: dayjs().diff(uptime, "seconds"),