diff --git a/apps/docs/docs/integrations/truenas/index.mdx b/apps/docs/docs/integrations/truenas/index.mdx index 5d29a7019..5e57f3a89 100644 --- a/apps/docs/docs/integrations/truenas/index.mdx +++ b/apps/docs/docs/integrations/truenas/index.mdx @@ -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 diff --git a/e2e/onboarding.spec.ts b/e2e/onboarding.spec.ts index 39f473c20..d97bae59d 100644 --- a/e2e/onboarding.spec.ts +++ b/e2e/onboarding.spec.ts @@ -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(); diff --git a/packages/integrations/src/truenas/test/truenas-integration.spec.ts b/packages/integrations/src/truenas/test/truenas-integration.spec.ts index 91ab0662c..d0856630e 100644 --- a/packages/integrations/src/truenas/test/truenas-integration.spec.ts +++ b/packages/integrations/src/truenas/test/truenas-integration.spec.ts @@ -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" }]); diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts index 21f526201..c5ba02c76 100644 --- a/packages/integrations/src/truenas/truenas-integration.ts +++ b/packages/integrations/src/truenas/truenas-integration.ts @@ -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(),