Compare commits

..
Author SHA1 Message Date
ajnart 1b74d73de0 revert(version): undo version refactor and tests, keep single-file change
Keep the User-Agent change as a single edit to
packages/core/src/infrastructure/http/request.ts. Remove the @homarr/version
package, the consumer migrations, and the unit tests.
2026-08-16 15:50:35 +02:00
ajnart 44d7426e9f fix(http): normalize User-Agent injection with Headers
Object-spreading fetchOptions.headers breaks when callers pass a Headers
instance (spread yields no entries) or a tuple array (numeric keys), and is
case-sensitive. Build a Headers instance and set the default User-Agent only
when no case-insensitive User-Agent entry exists, via a shared helper in both
fetch paths.

Adds unit tests covering record, Headers, tuple-array, and casing variants.
2026-08-16 15:47:55 +02:00
ajnart c08ad3a69a refactor(version): centralize Homarr version in @homarr/version package
Replace fragile deep-relative 'package.json' imports across the monorepo
with a single source of truth: the new @homarr/version leaf package reads
the version once from the root package.json and exports it as VERSION.

Consumers now import by package name incl. the new User-Agent header in
fetchWithTrustedCertificatesAsync (packages/core):
  - core, api (info router), analytics, request-handler (update checker)
This also removes all manually-maintained relative version imports.
2026-08-16 15:24:07 +02:00
ajnart 25b7657a79 feat(http): set custom User-Agent on outbound integration requests
Set a default 'User-Agent: Homarr/<version> (+https://homarr.dev)' header
on all outbound HTTP requests made via fetchWithTrustedCertificatesAsync,
so Homarr's integration traffic is distinguishable in reverse-proxy,
WAF, and security-monitoring logs instead of appearing as generic undici.

Closes #6604
2026-08-16 13:04:13 +02:00
Thomas df438b4cd2 Merge pull request #6571 from homarr-labs/feat/fix-home-assistant-actions 2026-08-16 00:23:14 +02:00
Thomas a1f91eb385 Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space 2026-08-15 20:20:35 +02:00
Thomas Camlong 56e6b23af6 test(e2e): remove flaky credentials onboarding test 2026-08-15 11:05:06 +02:00
Thomas Camlong 5488bf4d18 fix(truenas): use lowercase in operator for dataset query 2026-08-15 01:33:20 +02:00
Thomas Camlong 7652eb8c1c preserve smart home toggle response 2026-08-13 17:04:00 +02:00
Thomas Camlong e761c10f32 fix Home Assistant widget actions 2026-08-13 16:21:48 +02:00
ajnart f6137b2dcc fix(truenas): guard empty dataset percentage 2026-08-12 18:24:33 +02:00
ajnart 425dd90871 fix(truenas): report usable dataset space 2026-08-12 18:19:07 +02:00
51 changed files with 230 additions and 655 deletions

No files matched your search

+12 -5
View File
@@ -15,17 +15,24 @@ COPY patches ./patches
COPY --parents ./apps/*/package.json ./packages/*/package.json ./tooling/*/package.json ./
# @homarr/definitions generates documentation types during install.
COPY --parents ./packages/definitions/src ./
# Workaround for pnpm/pnpm#5268: pnpm fetch crashes when patchedDependencies
# are configured with nodeLinker: hoisted. The applyPatchToDir function tries
# to chdir into node_modules/<pkg> which doesn't exist during fetch (only the
# content-addressable store is populated). By temporarily switching to the
# isolated linker, patches apply inside the virtual store (node_modules/.pnpm/...)
# which IS created by pnpm fetch. The original hoisted linker is restored
# before the install step so the final node_modules layout stays flat.
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
pnpm config set store-dir /pnpm/store && \
pnpm fetch --ignore-scripts
sed -i 's/nodeLinker: hoisted/nodeLinker: isolated/' pnpm-workspace.yaml && \
pnpm fetch --ignore-scripts && \
sed -i 's/nodeLinker: isolated/nodeLinker: hoisted/' pnpm-workspace.yaml
# Install only from the fetched, committed lockfile so local and Docker builds
# resolve the same dependency graph. Serial lifecycle builds avoid esbuild's
# atomic binary replacement racing across the workspace (pnpm/pnpm#8200).
# --force re-runs native build scripts (better-sqlite3 etc.) that the offline
# store otherwise considers "already built" without the actual artifacts.
# atomic binary replacement racing across the hoisted workspace (pnpm/pnpm#8200).
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store \
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1 --force
npm_config_nodedir=/usr/local pnpm install --recursive --offline --frozen-lockfile --child-concurrency=1
COPY . .
@@ -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 />
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
### Adding the widget
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
<div>
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
</div>
</div>
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
<AddingWidget />
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
-2
View File
@@ -30,7 +30,6 @@
"@mdx-js/react": "^3.1.1",
"@nivo/line": "^0.99.0",
"@scalar/api-reference-react": "catalog:",
"@scalar/types": "catalog:",
"@signalwire/docusaurus-plugin-llms-txt": "^1.2.2",
"@tabler/icons-react": "catalog:",
"@tailwindcss/postcss": "^4.3.2",
@@ -53,7 +52,6 @@
"@docusaurus/types": "^3.10.1",
"@fec/remark-a11y-emoji": "^4.0.2",
"@tsconfig/docusaurus": "^2.0.9",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"cheerio": "^1.2.0",
-5
View File
@@ -50,10 +50,8 @@
"@homarr/validation": "workspace:^0.1.0",
"@homarr/websocket": "workspace:^0.1.0",
"@homarr/widgets": "workspace:^0.1.0",
"@mantine/charts": "catalog:",
"@mantine/colors-generator": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/dropzone": "catalog:",
"@mantine/hooks": "catalog:",
"@mantine/modals": "catalog:",
@@ -90,9 +88,7 @@
"mantine-react-table": "catalog:",
"mcp-handler": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"postcss-preset-mantine": "catalog:",
"postcss-simple-vars": "catalog:",
"posthog-js": "catalog:",
"prismjs": "catalog:",
"react": "catalog:",
@@ -103,7 +99,6 @@
"sql.js": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"use-deep-compare-effect": "catalog:",
"yaml": "catalog:",
"zod": "catalog:"
-2
View File
@@ -26,14 +26,12 @@
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@trpc/server": "catalog:",
"dotenv": "catalog:",
"tsx": "catalog:",
"ws": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/ws": "catalog:",
"esbuild": "catalog:",
"typescript": "catalog:"
-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();
-2
View File
@@ -61,13 +61,11 @@
"@semantic-release/release-notes-generator": "catalog:",
"@testcontainers/redis": "catalog:",
"@turbo/gen": "catalog:",
"@types/node-unifi": "catalog:",
"@vitejs/plugin-react": "catalog:",
"@vitest/coverage-v8": "catalog:",
"@vitest/ui": "catalog:",
"conventional-changelog-conventionalcommits": "catalog:",
"cross-env": "catalog:",
"dotenv-cli": "catalog:",
"jsdom": "catalog:",
"json5": "catalog:",
"oxfmt": "catalog:",
-1
View File
@@ -30,7 +30,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
+1 -9
View File
@@ -47,31 +47,23 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@kubernetes/client-node": "catalog:",
"@paralleldrive/cuid2": "catalog:",
"@tanstack/react-query": "catalog:",
"@tiptap/react": "catalog:",
"@trpc/client": "catalog:",
"@trpc/react-query": "catalog:",
"@trpc/server": "catalog:",
"@trpc/tanstack-react-query": "catalog:",
"drizzle-orm": "catalog:",
"jsonpath-plus": "catalog:",
"lodash.clonedeep": "catalog:",
"next": "catalog:",
"pg": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"superjson": "catalog:",
"trpc-to-mcp": "catalog:",
"trpc-to-openapi": "catalog:",
"zod": "catalog:",
"zod-form-data": "catalog:"
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
+16 -1
View File
@@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to toggle the entity",
});
}
return success;
}),
executeAutomation: protectedProcedure
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to trigger the automation",
});
}
}),
});
-3
View File
@@ -32,7 +32,6 @@
"@homarr/validation": "workspace:^0.1.0",
"bcrypt": "catalog:",
"cookies": "catalog:",
"dayjs": "catalog:",
"ldapts": "catalog:",
"next": "catalog:",
"next-auth": "catalog:",
@@ -44,8 +43,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "catalog:",
"@types/cookies": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
-5
View File
@@ -24,16 +24,11 @@
},
"dependencies": {
"@homarr/api": "workspace:^0.1.0",
"@mantine/hooks": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -33,7 +33,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"esbuild": "catalog:",
"typescript": "catalog:"
}
-10
View File
@@ -26,17 +26,11 @@
},
"dependencies": {
"@homarr/core": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@paralleldrive/cuid2": "catalog:",
"@tanstack/query-core": "catalog:",
"@types/node-fetch": "catalog:",
"axios": "catalog:",
"cookie": "catalog:",
"dayjs": "catalog:",
"next": "catalog:",
"node-fetch": "catalog:",
"octokit": "catalog:",
"ofetch": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"undici": "catalog:",
@@ -45,10 +39,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/node-fetch": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-4
View File
@@ -37,7 +37,6 @@
},
"dependencies": {
"@t3-oss/env-nextjs": "catalog:",
"axios": "catalog:",
"better-sqlite3": "catalog:",
"dns-caching": "catalog:",
"drizzle-orm": "catalog:",
@@ -45,15 +44,12 @@
"mysql2": "catalog:",
"pg": "catalog:",
"superjson": "catalog:",
"undici": "catalog:",
"winston": "catalog:",
"winston-transport": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"typescript": "catalog:"
}
@@ -14,6 +14,10 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
import packageJson from "../../../../../package.json";
export const getDefaultUserAgent = () => `Homarr/${packageJson.version} (+https://homarr.dev)`;
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -79,6 +83,10 @@ export const fetchWithTrustedCertificatesAsync = async (
async (signal) =>
fetch(url, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
signal,
dispatcher: agent,
}),
@@ -89,6 +97,10 @@ export const fetchWithTrustedCertificatesAsync = async (
const { bodyTimeout: _bodyTimeout, dispatcher: _dispatcher, ...fetchOptions } = options ?? {};
return fetch(url, {
...fetchOptions,
headers: {
"User-Agent": getDefaultUserAgent(),
...fetchOptions.headers,
},
dispatcher: agent,
});
};
-1
View File
@@ -26,7 +26,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -30,7 +30,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-5
View File
@@ -38,16 +38,11 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"croner": "catalog:",
"drizzle-orm": "catalog:",
"pg": "catalog:",
"semver-parser": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"typescript": "catalog:"
}
}
-2
View File
@@ -51,7 +51,6 @@
"@homarr/server-settings": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@paralleldrive/cuid2": "catalog:",
"@testcontainers/mysql": "catalog:",
"@testcontainers/postgresql": "catalog:",
@@ -67,7 +66,6 @@
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/better-sqlite3": "catalog:",
"@types/node": "catalog:",
"@types/pg": "catalog:",
"dotenv-cli": "catalog:",
"esbuild": "catalog:",
-1
View File
@@ -30,7 +30,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"tsx": "catalog:",
"typescript": "catalog:"
}
+1 -4
View File
@@ -25,14 +25,11 @@
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"dockerode": "catalog:",
"zod": "catalog:"
"dockerode": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-3
View File
@@ -26,13 +26,10 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/form": "catalog:",
"react": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
-8
View File
@@ -30,19 +30,11 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@trpc/server": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -28,7 +28,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -29,7 +29,6 @@
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/bcrypt": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-8
View File
@@ -38,16 +38,10 @@
"@homarr/validation": "workspace:^0.1.0",
"@immich/sdk": "catalog:",
"@jellyfin/sdk": "catalog:",
"@mantine/core": "catalog:",
"axios": "catalog:",
"dayjs": "catalog:",
"http-cookie-agent": "catalog:",
"ical.js": "catalog:",
"maria2": "catalog:",
"node-fetch": "catalog:",
"octokit": "catalog:",
"proxmox-api": "catalog:",
"superjson": "catalog:",
"timezones-ical-library": "catalog:",
"tsdav": "catalog:",
"undici": "catalog:",
@@ -57,8 +51,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/node-fetch": "catalog:",
"@types/node-unifi": "catalog:",
"@types/ws": "catalog:",
"@types/xml2js": "catalog:",
@@ -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",
});
@@ -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(),
@@ -0,0 +1,48 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { HomeAssistantIntegration } from "../src";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
const integration = new HomeAssistantIntegration({
id: "home-assistant",
name: "Home Assistant",
url: "http://home-assistant.local:8123",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "secret" }],
});
describe("HomeAssistantIntegration actions", () => {
beforeEach(() => {
mockFetch.mockReset();
mockFetch.mockResolvedValue({ ok: true } as never);
});
test.each([
["toggle", () => integration.triggerToggleAsync("light.desk"), "/api/services/homeassistant/toggle", "light.desk"],
[
"automation",
() => integration.triggerAutomationAsync("automation.turn_on_lights"),
"/api/services/automation/trigger",
"automation.turn_on_lights",
],
])("sends the %s service call as JSON", async (_action, trigger, path, entityId) => {
await expect(trigger()).resolves.toBe(true);
expect(mockFetch).toHaveBeenCalledWith(new URL(`http://home-assistant.local:8123${path}`), {
headers: {
Authorization: "Bearer secret",
"Content-Type": "application/json",
},
body: JSON.stringify({ entity_id: entityId }),
method: "POST",
});
});
});
-6
View File
@@ -33,11 +33,8 @@
"@homarr/ui": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"dayjs": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
@@ -45,9 +42,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-2
View File
@@ -29,8 +29,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -28,7 +28,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-3
View File
@@ -49,9 +49,6 @@
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/adm-zip": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -26,7 +26,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
+1 -3
View File
@@ -22,12 +22,10 @@
},
"dependencies": {
"@homarr/common": "workspace:^0.1.0",
"@homarr/core": "workspace:^0.1.0",
"undici": "catalog:"
"@homarr/core": "workspace:^0.1.0"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-1
View File
@@ -30,7 +30,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-5
View File
@@ -29,19 +29,14 @@
"@homarr/docker": "workspace:^0.1.0",
"@homarr/integrations": "workspace:^0.1.0",
"@homarr/redis": "workspace:^0.1.0",
"@octokit/plugin-rest-endpoint-methods": "catalog:",
"dayjs": "catalog:",
"dockerode": "catalog:",
"octokit": "catalog:",
"semver-parser": "catalog:",
"superjson": "catalog:",
"undici": "catalog:",
"zod": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/dockerode": "catalog:",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
@@ -18,4 +18,6 @@ export const smartHomeEntityStateRequestHandler = createIntegrationRequestHandle
return result.data.state;
},
// The widget already polls every 30 seconds and refetches after toggles.
cacheTtlMs: 0,
});
-1
View File
@@ -26,7 +26,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-3
View File
@@ -31,9 +31,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-6
View File
@@ -36,12 +36,9 @@
"@homarr/translation": "workspace:^0.1.0",
"@homarr/ui": "workspace:^0.1.0",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@mantine/spotlight": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"dayjs": "catalog:",
"jotai": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -50,9 +47,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-3
View File
@@ -39,9 +39,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-8
View File
@@ -24,21 +24,16 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@gfazioli/mantine-onboarding-tour": "catalog:",
"@homarr/common": "workspace:^0.1.0",
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/settings": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/hooks": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"clsx": "catalog:",
"crypto-js": "catalog:",
"csstype": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"react": "catalog:",
@@ -49,9 +44,6 @@
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/crypto-js": "catalog:",
"@types/css-modules": "catalog:",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"typescript": "catalog:"
}
}
-2
View File
@@ -23,13 +23,11 @@
"dependencies": {
"@homarr/definitions": "workspace:^0.1.0",
"@homarr/translation": "workspace:^0.1.0",
"@mantine/dates": "catalog:",
"zod": "catalog:",
"zod-form-data": "catalog:"
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"typescript": "catalog:"
}
}
-10
View File
@@ -59,12 +59,9 @@
"@homarr/validation": "workspace:^0.1.0",
"@mantine/charts": "catalog:",
"@mantine/core": "catalog:",
"@mantine/dates": "catalog:",
"@mantine/form": "catalog:",
"@mantine/hooks": "catalog:",
"@mantine/tiptap": "catalog:",
"@tabler/icons-react": "catalog:",
"@tanstack/react-query": "catalog:",
"@tiptap/extension-color": "catalog:",
"@tiptap/extension-highlight": "catalog:",
"@tiptap/extension-image": "catalog:",
@@ -81,8 +78,6 @@
"@tiptap/extension-underline": "catalog:",
"@tiptap/react": "catalog:",
"@tiptap/starter-kit": "catalog:",
"@trpc/client": "catalog:",
"@trpc/server": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"jsonpath-plus": "catalog:",
@@ -90,8 +85,6 @@
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
"next": "catalog:",
"next-intl": "catalog:",
"prosemirror-model": "catalog:",
"react": "catalog:",
"react-dom": "catalog:",
"react-jsx-parser": "catalog:",
@@ -103,9 +96,6 @@
},
"devDependencies": {
"@homarr/tsconfig": "workspace:^0.1.0",
"@types/node": "catalog:",
"@types/react": "catalog:",
"@types/react-dom": "catalog:",
"@types/video.js": "catalog:",
"typescript": "catalog:"
}
+48 -458
View File
File diff suppressed because it is too large. Load diff
+1 -12
View File
@@ -35,7 +35,6 @@ catalog:
"@mantine/spotlight": ^9.4.1
"@mantine/tiptap": ^9.4.1
"@octokit/auth-app": ^8.2.0
"@octokit/plugin-rest-endpoint-methods": ^17.0.0
"@paralleldrive/cuid2": ^3.3.0
"@playwright/test": ^1.61.1
"@semantic-release/changelog": ^7.0.0
@@ -46,7 +45,6 @@ catalog:
"@semantic-release/release-notes-generator": ^14.1.1
"@t3-oss/env-nextjs": ^0.13.11
"@tabler/icons-react": ^3.44.0
"@tanstack/query-core": ^5.101.2
"@tanstack/react-devtools": ^0.10.8
"@tanstack/react-query": ^5.101.2
"@tanstack/react-query-devtools": ^5.101.2
@@ -85,22 +83,15 @@ catalog:
"@types/css-modules": ^1.0.5
"@types/dockerode": ^4.0.1
"@types/node": ^24.13.3
"@types/node-fetch": ^2.6.13
"@types/node-unifi": ^2.5.1
"@types/pg": ^8.20.0
"@types/prismjs": ^1.26.6
"@types/react": 19.2.17
"@types/react-dom": 19.2.3
"@scalar/api-reference-react": ^0.9.54
"@scalar/types": ^0.16.2
"@types/video.js": ^7.3.58
"@types/ws": ^8.18.1
"@types/xml2js": ^0.4.14
axios: ^1.15.0
csstype: ^3.2.3
http-cookie-agent: ^6.0.8
node-fetch: ^2.7.0
ofetch: ^1.5.1
posthog-js: ^1.399.1
posthog-node: ^5.40.0
"@vitejs/plugin-react": ^6.0.3
@@ -111,7 +102,6 @@ catalog:
"@xterm/xterm": ^6.0.0
adm-zip: 0.5.18
jszip: ^3.10.1
prosemirror-model: ^1.25.10
sql.js: ^1.14.1
babel-plugin-react-compiler: ^1.0.0
bcrypt: ^6.0.0
@@ -164,7 +154,6 @@ catalog:
oxlint: ^1.73.0
pg: ^8.22.0
postcss-preset-mantine: ^1.18.0
postcss-simple-vars: ^7.0.1
prismjs: ^1.30.0
proxmox-api: 1.1.1
react: 19.2.7
@@ -195,7 +184,6 @@ catalog:
vite-tsconfig-paths: ^6.1.1
vitest: ^4.1.10
winston: 3.19.0
winston-transport: ^4.9.0
ws: ^8.21.0
xml2js: ^0.6.2
yaml: 2.9.0
@@ -204,6 +192,7 @@ catalog:
zod-validation-error: ^5.0.0
catalogMode: strict
nodeLinker: hoisted
strictPeerDependencies: false
engineStrict: true
verifyDepsBeforeRun: warn