diff --git a/apps/docs/docs/advanced/running-as-different-user/index.mdx b/apps/docs/docs/advanced/running-as-different-user/index.mdx
index 91c02fb9d..22f1c8b3a 100644
--- a/apps/docs/docs/advanced/running-as-different-user/index.mdx
+++ b/apps/docs/docs/advanced/running-as-different-user/index.mdx
@@ -13,7 +13,8 @@ You can change the user and group by using the `PUID` and `PGID` environment var
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
:::info
-When starting up an updated version of the container it will have about **10 seconds longer to start** as it has to change the permissions of the app directories
+At startup, Homarr updates ownership of its data directories and runtime caches for the selected user and group.
+Startup time depends on the amount of persistent data; application code and dependencies do not need ownership changes.
:::
## Without mounted Docker socket
diff --git a/apps/docs/docs/integrations/home-assistant/index.mdx b/apps/docs/docs/integrations/home-assistant/index.mdx
index 65d09d166..4e4560c84 100644
--- a/apps/docs/docs/integrations/home-assistant/index.mdx
+++ b/apps/docs/docs/integrations/home-assistant/index.mdx
@@ -28,6 +28,8 @@ import { smartHomeExecuteAutomationWidget } from "@site/docs/widgets/smart-home-
]}
/>
+Entity toggles and automation triggers send JSON requests to Home Assistant. A rejected action is shown as a failure.
+
### Adding the integration
diff --git a/apps/docs/docs/integrations/open-media-vault/index.mdx b/apps/docs/docs/integrations/open-media-vault/index.mdx
index b934bf797..57f005240 100644
--- a/apps/docs/docs/integrations/open-media-vault/index.mdx
+++ b/apps/docs/docs/integrations/open-media-vault/index.mdx
@@ -30,6 +30,8 @@ import { systemResourcesWidget } from "@site/docs/widgets/system-resources";
]}
/>
+Disk temperatures are matched to filesystem partitions, including device names without the `/dev/` prefix.
+
### Adding the integration
diff --git a/apps/docs/docs/integrations/truenas/index.mdx b/apps/docs/docs/integrations/truenas/index.mdx
index 3ec09bbcf..448318deb 100644
--- a/apps/docs/docs/integrations/truenas/index.mdx
+++ b/apps/docs/docs/integrations/truenas/index.mdx
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
+Disk space uses each pool's root dataset to account for RAIDZ parity and dataset limits. If dataset space is unavailable,
+Homarr falls back to physical pool space and keeps reporting pool health.
+
### Adding the integration
diff --git a/apps/docs/docs/integrations/unraid/index.mdx b/apps/docs/docs/integrations/unraid/index.mdx
index a629fe844..61675557e 100644
--- a/apps/docs/docs/integrations/unraid/index.mdx
+++ b/apps/docs/docs/integrations/unraid/index.mdx
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
+CPU usage is averaged across logical processors, including SMT threads.
+
### Adding the integration
diff --git a/apps/docs/docs/widgets/bookmarks/index.mdx b/apps/docs/docs/widgets/bookmarks/index.mdx
index fbab1f89b..3b88ecdb8 100644
--- a/apps/docs/docs/widgets/bookmarks/index.mdx
+++ b/apps/docs/docs/widgets/bookmarks/index.mdx
@@ -22,6 +22,8 @@ one-row widgets. Horizontal, vertical, compact-grid, and icon-only layouts keep
preserves its configured columns when the dashboard is scaled. Bookmark cards, icons, text, and gaps keep the same
visual density when a low-column dashboard is enlarged to fill the screen.
+Right-click a bookmark link in view mode to use the browser menu, including opening it in a new tab or copying its address.
+
### Screenshots
import gridIconsOnly from "./img/grid-icons-only.png";
diff --git a/apps/nextjs/src/components/board/items/widget-context-menu.tsx b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
index 54bce1b92..9cd076d29 100644
--- a/apps/nextjs/src/components/board/items/widget-context-menu.tsx
+++ b/apps/nextjs/src/components/board/items/widget-context-menu.tsx
@@ -244,7 +244,13 @@ export const WidgetContextMenu = ({
setMenuOpened(false);
}, []);
- if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
+ if (
+ !session ||
+ !settings.enableRightClickOnWidgets ||
+ isEditMode ||
+ item.kind === "app" ||
+ item.kind === "bookmarks"
+ ) {
return <>{children}>;
}
diff --git a/nginx.conf b/nginx.conf
index 7754b4087..a433abfd9 100644
--- a/nginx.conf
+++ b/nginx.conf
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
- proxy_pass http://${HOSTNAME}:3001;
+ proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000
location / {
- proxy_pass http://${HOSTNAME}:3000;
+ proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
diff --git a/packages/api/src/shared.ts b/packages/api/src/shared.ts
index e05700f22..9237a37a9 100644
--- a/packages/api/src/shared.ts
+++ b/packages/api/src/shared.ts
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
- return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
+ return "http://127.0.0.1:3000";
}
export const trpcPath = "/api/trpc";
diff --git a/packages/integrations/src/homeassistant/homeassistant-integration.ts b/packages/integrations/src/homeassistant/homeassistant-integration.ts
index 6da82df46..15c4ece7f 100644
--- a/packages/integrations/src/homeassistant/homeassistant-integration.ts
+++ b/packages/integrations/src/homeassistant/homeassistant-integration.ts
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
*/
private async postAsync(path: `/api/${string}`, body: Record) {
return await fetchWithTrustedCertificatesAsync(this.url(path), {
- headers: this.getAuthHeaders(),
+ headers: {
+ ...this.getAuthHeaders(),
+ "Content-Type": "application/json",
+ },
body: JSON.stringify(body),
method: "POST",
});
diff --git a/packages/integrations/src/truenas/test/truenas-integration.spec.ts b/packages/integrations/src/truenas/test/truenas-integration.spec.ts
index 61060cea9..f431de9e4 100644
--- a/packages/integrations/src/truenas/test/truenas-integration.spec.ts
+++ b/packages/integrations/src/truenas/test/truenas-integration.spec.ts
@@ -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({
diff --git a/packages/integrations/src/truenas/truenas-integration.ts b/packages/integrations/src/truenas/truenas-integration.ts
index 0d6338cc1..7fb966aa0 100644
--- a/packages/integrations/src/truenas/truenas-integration.ts
+++ b/packages/integrations/src/truenas/truenas-integration.ts
@@ -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();
+
+ 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(),
diff --git a/packages/integrations/src/unraid/unraid-integration.ts b/packages/integrations/src/unraid/unraid-integration.ts
index d2f430be9..0c26b8134 100644
--- a/packages/integrations/src/unraid/unraid-integration.ts
+++ b/packages/integrations/src/unraid/unraid-integration.ts
@@ -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"),
diff --git a/packages/widgets/src/filter-storage-volumes.ts b/packages/widgets/src/filter-storage-volumes.ts
index 9c406c65e..86721587a 100644
--- a/packages/widgets/src/filter-storage-volumes.ts
+++ b/packages/widgets/src/filter-storage-volumes.ts
@@ -5,10 +5,16 @@ interface StorageVolumeEntry {
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
+ // SCSI/SATA/VirtIO without device node prefix: sda1 -> sda (OMV reports unprefixed names)
+ { pattern: /^((?:sd|vd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
+ // NVMe without device node prefix: nvme0n1p2 -> nvme0n1
+ { pattern: /^(nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
+ // eMMC without device node prefix: mmcblk0p1 -> mmcblk0
+ { pattern: /^(mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
];
export const normalizeStorageDeviceName = (deviceName: string): string => {
diff --git a/scripts/entrypoint.sh b/scripts/entrypoint.sh
index f4d7c9fa4..fbfe8756d 100644
--- a/scripts/entrypoint.sh
+++ b/scripts/entrypoint.sh
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
echo "Starting with UID='$PUID', GID='$PGID'"
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
- # The below command will change the owner of all files in the /app directory (except node_modules) to the new UID and GID
- echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
- find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
- chown -R $PUID:$PGID /var/cache/nginx
- chown -R $PUID:$PGID /var/log/nginx
- chown -R $PUID:$PGID /var/lib/nginx
- chown -R $PUID:$PGID /run/nginx/nginx.pid
- chown -R $PUID:$PGID /etc/nginx
+ # Only chown the paths the non-root user must actually write to at runtime.
+ # Recursively chowning the whole /app tree is extremely slow on network- or
+ # HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
+ # bundle and its node_modules contain tens of thousands of tiny files. This
+ # turned a ~10s step into 15+ minutes for some users (#2190). Application
+ # code, migrations and native modules are only read/executed, so they stay
+ # owned by root and do not need chowning.
+ echo "Changing owner to $PUID:$PGID..."
+ # Application data volume (db, redis dumps, trusted certificates).
+ # Ensure the known subdirectories exist and migrate ownership of any
+ # existing content, so a persistent volume with stale or root-owned
+ # entries stays writable after a PUID/PGID change. These trees are small
+ # (a single sqlite db, a redis dump, a few certs), unlike the /app tree.
+ mkdir -p /appdata/db /appdata/redis /appdata/trusted-certificates
+ chown "${PUID}:${PGID}" /appdata
+ chown -R "${PUID}:${PGID}" /appdata/db /appdata/redis /appdata/trusted-certificates
+ # Next.js runtime cache (image optimization, fetch cache, ...)
+ mkdir -p /app/apps/nextjs/.next/cache
+ chown -R "${PUID}:${PGID}" /app/apps/nextjs/.next/cache
+ # nginx runtime directories and the rendered configuration
+ chown -R "${PUID}:${PGID}" /var/cache/nginx
+ chown -R "${PUID}:${PGID}" /var/log/nginx
+ chown -R "${PUID}:${PGID}" /var/lib/nginx
+ chown -R "${PUID}:${PGID}" /run/nginx/nginx.pid
+ chown -R "${PUID}:${PGID}" /etc/nginx
echo "Changing owner to $PUID:$PGID, done."
fi
diff --git a/scripts/regressions/README.md b/scripts/regressions/README.md
new file mode 100644
index 000000000..246480931
--- /dev/null
+++ b/scripts/regressions/README.md
@@ -0,0 +1,40 @@
+# Release regression exploration
+
+Run the opt-in grid fuzzer from the repository root after installing dependencies:
+
+```sh
+pnpm exec tsx --tsconfig apps/nextjs/tsconfig.json scripts/regressions/grid-fuzz.mts --seed 6545 --steps 1000
+```
+
+Change the seed to explore another repeatable sequence. `--seed` accepts unsigned
+32-bit integers, including zero; `--steps` accepts 1–100,000 transactions. Each
+transaction has one to four move or resize previews followed by commit or cancel.
+The committed state becomes the next transaction's input. The fixture includes
+two root grids and two nested containers with varying widths and row limits.
+
+The runner calls the real v2 grid engine and checks placement identity and
+metadata, integer geometry, bounds, overlap, container ownership, cycles,
+immutable snapshots, repeat-preview stability, rejection retention, and exact
+cancel/commit results. It does not start Homarr, Docker, or a browser. It uses
+the Next.js app's TypeScript config to resolve the engine's existing imports.
+
+A failure exits nonzero and writes the seed, source revision and hashes, initial
+state, and operation transcript to `/tmp/homarr-grid-fuzz`. Use `--output `
+to keep artifacts elsewhere. Re-run the printed command on the same source to
+reproduce it; changing engine or runner code can change a seed's outcome. Reduce
+`--steps` to find the first failing transaction. Artifacts contain synthetic data.
+
+Passing this runner covers v2 engine invariants only. It does **not** establish
+parity with `dev`, browser rendering, save/reload persistence, or database upgrades.
+The next useful differential checks are:
+
+- Clone a seeded `dev` database, upgrade only its copy to v2, and compare widget
+ identities/options/integrations through the category/dynamic-to-container
+ migration (`packages/db/migrations/custom/0004_unify_sections_and_gutters.ts`).
+- Drive the same saved board on both versions around layout breakpoints and
+ compare visible widget counts, errors, containment, and save/reload outcomes.
+ Use separate authenticated browser sessions and version-specific selectors;
+ the existing `scripts/benchmarks/dev-board.mts` markers are v2-specific.
+- Reuse mock integration fixtures from `e2e/lazy-widgets.spec.ts` and geometry
+ checks from `e2e/media-server-layout.spec.ts`, keeping intentional v2 redesigns
+ separate from missing behavior. Pixel equality is not a release criterion.
diff --git a/scripts/regressions/grid-fuzz.mts b/scripts/regressions/grid-fuzz.mts
new file mode 100644
index 000000000..4a115907a
--- /dev/null
+++ b/scripts/regressions/grid-fuzz.mts
@@ -0,0 +1,301 @@
+import assert from "node:assert/strict";
+import { execFileSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdir, readFile, writeFile } from "node:fs/promises";
+import path from "node:path";
+
+import {
+ beginGridTransaction,
+ cancelGridTransaction,
+ commitGridTransaction,
+ previewGridMove,
+ previewGridResize,
+} from "../../apps/nextjs/src/components/board/sections/grid/dnd/engine.ts";
+import type {
+ GridMoveInput,
+ GridResizeDirection,
+ GridResizeInput,
+ GridTransaction,
+ TransactionalGridState,
+} from "../../apps/nextjs/src/components/board/sections/grid/dnd/types.ts";
+
+const usage =
+ "pnpm exec tsx --tsconfig apps/nextjs/tsconfig.json scripts/regressions/grid-fuzz.mts [--seed 6545] [--steps 1000] [--output /tmp/homarr-grid-fuzz]";
+const values = new Map();
+for (let index = 2; index < process.argv.length; index += 2) {
+ const flag = process.argv[index];
+ if (flag === "--help") {
+ console.log(usage);
+ process.exit(0);
+ }
+ const value = process.argv[index + 1];
+ if (!flag || !["--seed", "--steps", "--output"].includes(flag) || !value || values.has(flag)) {
+ throw new Error(`Invalid or duplicate argument: ${String(flag)}\n${usage}`);
+ }
+ values.set(flag, value);
+}
+
+const integerOption = (flag: string, fallback: number, minimum: number, maximum: number) => {
+ const value = values.get(flag) ?? String(fallback);
+ const number = Number(value);
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(number) || number < minimum || number > maximum) {
+ throw new Error(`${flag} must be an integer between ${minimum} and ${maximum}`);
+ }
+ return number;
+};
+const seed = integerOption("--seed", 6545, 0, 0xffff_ffff);
+const steps = integerOption("--steps", 1000, 1, 100_000);
+const outputDirectory = path.resolve(values.get("--output") ?? "/tmp/homarr-grid-fuzz");
+const root = path.resolve(import.meta.dirname, "../..");
+
+// Mulberry32 makes the complete operation stream repeatable, including seed zero.
+let randomState = seed;
+const randomInt = (minimum: number, maximum: number) => {
+ randomState = (randomState + 0x6d2b79f5) >>> 0;
+ let value = randomState;
+ value = Math.imul(value ^ (value >>> 15), value | 1);
+ value ^= value + Math.imul(value ^ (value >>> 7), value | 61);
+ const fraction = ((value ^ (value >>> 14)) >>> 0) / 0x1_0000_0000;
+ return minimum + Math.floor(fraction * (maximum - minimum + 1));
+};
+const pick = (choices: readonly T[]): T => {
+ const value = choices[randomInt(0, choices.length - 1)];
+ assert.notEqual(value, undefined, "Cannot pick from an empty collection");
+ return value as T;
+};
+
+interface Placement {
+ id: string;
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ type: "section" | "item";
+ marker: string;
+}
+type State = TransactionalGridState;
+const placement = (id: string, y: number, type: Placement["type"] = "item"): Placement => ({
+ id,
+ x: 0,
+ y,
+ w: randomInt(1, 3),
+ h: randomInt(1, 2),
+ type,
+ marker: `metadata:${id}`,
+});
+const initialState: State = {
+ grids: [
+ {
+ id: "root",
+ columnCount: randomInt(6, 12),
+ maxRowCount: 24,
+ placements: [
+ placement("container", 0, "section"),
+ ...Array.from({ length: 5 }, (_, i) => placement(`root-${i}`, 3 + i * 3)),
+ ],
+ },
+ {
+ id: "other",
+ columnCount: randomInt(4, 10),
+ maxRowCount: 24,
+ placements: Array.from({ length: 4 }, (_, i) => placement(`other-${i}`, i * 3)),
+ },
+ {
+ id: "child",
+ columnCount: randomInt(4, 8),
+ maxRowCount: 24,
+ parentGridId: "root",
+ ownerPlacementId: "container",
+ placements: [placement("nested-container", 0, "section"), placement("child-item", 3)],
+ },
+ {
+ id: "nested",
+ columnCount: randomInt(3, 6),
+ maxRowCount: 24,
+ parentGridId: "child",
+ ownerPlacementId: "nested-container",
+ placements: [placement("nested-item", 0)],
+ },
+ ],
+};
+const allPlacements = (state: State) => state.grids.flatMap((grid) => grid.placements);
+const expectedMetadata = allPlacements(initialState)
+ .map(({ id, type, marker }) => ({ id, type, marker }))
+ .toSorted((a, b) => a.id.localeCompare(b.id));
+const freezeState = (state: State) => {
+ for (const grid of state.grids) {
+ for (const item of grid.placements) Object.freeze(item);
+ Object.freeze(grid.placements);
+ Object.freeze(grid);
+ }
+ Object.freeze(state.grids);
+ return Object.freeze(state);
+};
+const assertState = (state: State) => {
+ assert.deepEqual(
+ state.grids.map(({ id }) => id).toSorted(),
+ initialState.grids.map(({ id }) => id).toSorted(),
+ "Grid IDs changed",
+ );
+ assert.deepEqual(
+ allPlacements(state)
+ .map(({ id, type, marker }) => ({ id, type, marker }))
+ .toSorted((a, b) => a.id.localeCompare(b.id)),
+ expectedMetadata,
+ "Placement IDs or metadata changed",
+ );
+ for (const grid of state.grids) {
+ for (const [index, item] of grid.placements.entries()) {
+ assert.ok([item.x, item.y, item.w, item.h].every(Number.isInteger), `Non-integer geometry: ${item.id}`);
+ assert.ok(item.x >= 0 && item.y >= 0 && item.w >= 1 && item.h >= 1, `Invalid geometry: ${item.id}`);
+ assert.ok(
+ item.x + item.w <= grid.columnCount && item.y + item.h <= (grid.maxRowCount ?? Infinity),
+ `Out of bounds: ${item.id}`,
+ );
+ for (const other of grid.placements.slice(index + 1)) {
+ const overlaps =
+ item.x < other.x + other.w &&
+ item.x + item.w > other.x &&
+ item.y < other.y + other.h &&
+ item.y + item.h > other.y;
+ assert.ok(!overlaps, `Overlap: ${item.id} and ${other.id} in ${grid.id}`);
+ }
+ }
+ if (grid.ownerPlacementId) {
+ const parent = state.grids.find(({ id }) => id === grid.parentGridId);
+ assert.ok(
+ parent?.placements.some(({ id }) => id === grid.ownerPlacementId),
+ `Missing owner: ${grid.id}`,
+ );
+ }
+ const visited = new Set([grid.id]);
+ let parentId = grid.parentGridId;
+ while (parentId) {
+ assert.ok(!visited.has(parentId), `Ownership cycle: ${grid.id}`);
+ visited.add(parentId);
+ const parent = state.grids.find(({ id }) => id === parentId);
+ assert.ok(parent, `Missing parent: ${parentId}`);
+ parentId = parent.parentGridId;
+ }
+ }
+};
+
+type Operation = { kind: "move"; input: GridMoveInput } | { kind: "resize"; input: GridResizeInput };
+const applyOperation = (transaction: GridTransaction, operation: Operation) => {
+ if (operation.kind === "move") return previewGridMove(transaction, operation.input);
+ return previewGridResize(transaction, operation.input);
+};
+interface TranscriptEntry {
+ step: number;
+ activeId: string;
+ previews: Array<{ operation: Operation; accepted?: boolean; rejection?: string }>;
+ completion: "commit" | "cancel";
+}
+const directions: GridResizeDirection[] = ["n", "ne", "e", "se", "s", "sw", "w", "nw"];
+const transcript: TranscriptEntry[] = [];
+const counts = { accepted: 0, rejected: 0, commits: 0, cancels: 0, rejections: {} as Record };
+let state = freezeState(initialState);
+
+try {
+ assertState(state);
+ for (let step = 0; step < steps; step += 1) {
+ const activeId = pick(allPlacements(state)).id;
+ const entry: TranscriptEntry = { step, activeId, previews: [], completion: "commit" };
+ if (randomInt(0, 3) === 0) entry.completion = "cancel";
+ transcript.push(entry);
+ const started = beginGridTransaction(state, { activeId });
+ const snapshot = structuredClone(started.snapshot);
+ let transaction = started;
+ const move = randomInt(0, 1) === 0;
+ const previewCount = randomInt(1, 4);
+ for (let preview = 0; preview < previewCount; preview += 1) {
+ let operation: Operation;
+ if (move) {
+ let targetGridId = pick(state.grids).id;
+ if (randomInt(0, 19) === 0) targetGridId = "missing-grid";
+ operation = { kind: "move", input: { targetGridId, x: randomInt(-4, 16) / 2, y: randomInt(-4, 56) / 2 } };
+ } else {
+ operation = {
+ kind: "resize",
+ input: {
+ direction: pick(directions),
+ deltaColumns: randomInt(-12, 12),
+ deltaRows: randomInt(-12, 12),
+ minWidth: 1,
+ minHeight: 1,
+ },
+ };
+ }
+ const event: TranscriptEntry["previews"][number] = { operation };
+ entry.previews.push(event);
+ const previous = structuredClone(transaction);
+ const result = applyOperation(transaction, operation);
+ assert.deepEqual(transaction, previous, "Preview mutated its input transaction");
+ event.accepted = result.accepted;
+ if (result.accepted) {
+ counts.accepted += 1;
+ // Repeated pointer events must not accumulate displacement.
+ const repeated = applyOperation(result.transaction, operation);
+ assert.deepEqual(repeated, result, "Identical preview accumulated drift");
+ transaction = result.transaction;
+ } else {
+ counts.rejected += 1;
+ event.rejection = result.rejection.code;
+ counts.rejections[result.rejection.code] = (counts.rejections[result.rejection.code] ?? 0) + 1;
+ assert.equal(result.transaction, transaction, "Rejection discarded the last accepted preview");
+ }
+ assert.deepEqual(transaction.snapshot, snapshot, "Transaction snapshot changed");
+ assertState(transaction.preview);
+ }
+ if (entry.completion === "cancel") {
+ state = cancelGridTransaction(transaction);
+ assert.deepEqual(state, snapshot, "Cancel failed to restore the starting state");
+ counts.cancels += 1;
+ } else {
+ state = commitGridTransaction(transaction);
+ assert.deepEqual(state, transaction.preview, "Commit differs from accepted preview");
+ counts.commits += 1;
+ }
+ assert.notEqual(state, transaction.preview, "Completion retained transaction state reference");
+ assertState(state);
+ freezeState(state);
+ }
+ console.log(JSON.stringify({ status: "passed", scope: "v2-grid-invariants", seed, steps, ...counts }, null, 2));
+} catch (error) {
+ const sources = [
+ "scripts/regressions/grid-fuzz.mts",
+ ...["sections/grid/dnd/engine.ts", "layout/geometry.ts", "layout/reflow.ts", "layout/constants.ts"].map(
+ (file) => `apps/nextjs/src/components/board/${file}`,
+ ),
+ ];
+ const hashes = Object.fromEntries(
+ await Promise.all(
+ sources.map(async (file) => [
+ file,
+ createHash("sha256")
+ .update(await readFile(path.join(root, file)))
+ .digest("hex"),
+ ]),
+ ),
+ );
+ let revision = "unknown";
+ try {
+ revision = execFileSync("git", ["rev-parse", "HEAD"], {
+ cwd: root,
+ encoding: "utf8",
+ stdio: ["ignore", "pipe", "ignore"],
+ }).trim();
+ } catch {
+ /* A source archive may not contain Git metadata. */
+ }
+ await mkdir(outputDirectory, { recursive: true });
+ const artifact = path.join(outputDirectory, `grid-fuzz-${seed}-${Date.now()}-${process.pid}.json`);
+ await writeFile(
+ artifact,
+ `${JSON.stringify({ schemaVersion: 1, seed, steps, revision, hashes, initialState, state, transcript, error: String(error), stack: error instanceof Error ? error.stack : undefined }, null, 2)}\n`,
+ );
+ console.error(
+ `Grid fuzz failed. Reproduce with the same source revision:\n${usage.split(" [")[0]} --seed ${seed} --steps ${steps}\nArtifact: ${artifact}`,
+ );
+ throw error;
+}
diff --git a/scripts/run.sh b/scripts/run.sh
index 6357c723c..efcc50f9f 100644
--- a/scripts/run.sh
+++ b/scripts/run.sh
@@ -21,20 +21,18 @@ fi
export AUTH_SECRET=$(openssl rand -base64 32)
# Start nginx proxy
-# 1. Replace the HOSTNAME in the nginx template file
-# 2. Create the nginx configuration file from the template
-# 3. Start the nginx server
+# 1. Create the nginx configuration file from the template
+# 2. Start the nginx server
# Only listen on IPv6 when the host actually has IPv6 configured (#4596).
# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family
# not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can
# force IPv4-only even when the host supports IPv6, then probe for IPv6
# addresses via grep (not -s: proc files always report size 0).
-export HOSTNAME
export NGINX_LISTEN_IPV6=''
if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then
NGINX_LISTEN_IPV6='listen [::]:7575;'
fi
-envsubst '${HOSTNAME} ${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
+envsubst '${NGINX_LISTEN_IPV6}' < /etc/nginx/templates/nginx.conf > /etc/nginx/nginx.conf
# Start services in the background and store their PIDs
nginx -g 'daemon off;' &
NGINX_PID=$!
@@ -67,6 +65,9 @@ terminate() {
trap terminate TERM INT
+# Next.js standalone uses HOSTNAME as its bind address. Docker's generated
+# hostname can be unresolvable, so bind explicitly while nginx uses loopback.
+export HOSTNAME=0.0.0.0
node apps/nextjs/server.js &
NEXTJS_PID=$!