Compare commits

...
4 changed files with 79 additions and 43 deletions

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 ### Adding the integration
<AddingIntegration /> <AddingIntegration />
-36
View File
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db"; import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => { 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 () => { test("External provider onboarding setup should be successful", async () => {
// Arrange // Arrange
const { db, localMountPath } = await createSqliteDbFileAsync(); const { db, localMountPath } = await createSqliteDbFileAsync();
@@ -148,6 +148,14 @@ const happyResponder = (method: string) => {
]; ];
case "pool.query": case "pool.query":
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }]; 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": case "interface.query":
return [{ id: "eth0", name: "eth0" }]; return [{ id: "eth0", name: "eth0" }];
case "reporting.netdata_get_data": case "reporting.netdata_get_data":
@@ -191,6 +199,18 @@ describe("TrueNasIntegration", () => {
// JSON-RPC does not use the legacy "connect" session handshake. // JSON-RPC does not use the legacy "connect" session handshake.
expect(ws.sentPayloads.some((payload) => payload.msg === "connect")).toBe(false); 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({ expect(result).toMatchObject({
version: "TrueNAS-SCALE-24.10", version: "TrueNAS-SCALE-24.10",
cpuModelName: "Intel Xeon", cpuModelName: "Intel Xeon",
@@ -204,12 +224,25 @@ describe("TrueNasIntegration", () => {
loadAverage: null, loadAverage: null,
gpu: [], gpu: [],
network: { up: 20_000, down: 10_000 }, network: { up: 20_000, down: 10_000 },
// `available` is the free space left on the pool (free = 500), not its total size. // `available` and `used` come from the root dataset, not the physical pool.
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }], fileSystem: [{ deviceName: "tank", available: "300", used: "200", percentage: 40 }],
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }], 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 () => { test("authenticates with an API key when one is configured", async () => {
const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]); const integration = createIntegration([{ kind: "apiKey", value: "api-key-123" }]);
@@ -49,9 +49,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
memUsedInBytes, memUsedInBytes,
fileSystem: datasets.map((dataset) => ({ fileSystem: datasets.map((dataset) => ({
deviceName: dataset.name, deviceName: dataset.name,
available: `${dataset.free}`, // free space left on the pool available: `${dataset.available}`,
used: `${dataset.allocated}`, used: `${dataset.used}`,
percentage: (dataset.allocated / dataset.size) * 100, percentage:
dataset.used + dataset.available === 0 ? 0 : (dataset.used / (dataset.used + dataset.available)) * 100,
})), })),
availablePkgUpdates: 0, availablePkgUpdates: 0,
network: { network: {
@@ -117,12 +118,40 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
return null; return null;
}) })
.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: 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( const reportingNetDataSchema = z.array(
z.object({ z.object({
name: z.string(), name: z.string(),