Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space

This commit is contained in:
Thomas authored and GitHub committed 2026-08-15 20:20:35 +02:00
commit a1f91eb385
4 files changed
+79 -43

No files matched your search

@@ -27,6 +27,8 @@ 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.
### Adding the integration
<AddingIntegration />
-36
View File
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => {
test("Credentials onboarding should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
@@ -148,6 +148,14 @@ 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: 200 },
available: { parsed: 300 },
},
];
case "interface.query":
return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data":
@@ -191,6 +199,18 @@ describe("TrueNasIntegration", () => {
// JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false);
const datasetPayload = ws.sentPayloads.find((payload) => payload.method === "pool.dataset.query");
expect(datasetPayload).toMatchObject({
params: [
[["id", "in", ["tank"]]],
{
extra: {
properties: ["used", "available"],
},
},
],
});
expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon",
@@ -204,12 +224,25 @@ 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.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
// `available` and `used` come from the root dataset, not the physical pool.
fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
});
});
test("reports zero usage for an empty root dataset", async () => {
ws.setResponder((method) =>
method === "pool.dataset.query"
? [{ id: "tank", used: { parsed: 0 }, available: { parsed: 0 } }]
: happyResponder(method),
);
const integration = createIntegration(credentials);
const result = await integration.getSystemInfoAsync();
expect(result.fileSystem[0]?.percentage).toBe(0);
});
test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
@@ -49,9 +49,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
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,
available: `${dataset.available}`,
used: `${dataset.used}`,
percentage:
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
})),
availablePkgUpdates: 0,
network: {
@@ -117,12 +118,40 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return 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", {
url: this.integration.url,
totalCount: result.length,
activeCount: activePools.length,
activeCount: poolsWithUsableSpace.length,
});
return activePools;
return poolsWithUsableSpace;
}
/**
@@ -263,6 +292,14 @@ const poolSchema = z.array(
}),
);
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() }),
}),
);
const reportingNetDataSchema = z.array(
z.object({
name: z.string(),