From c18548f7c2e973dddf8755dddc6dc588f07d7b58 Mon Sep 17 00:00:00 2001 From: ajnart Date: Thu, 10 Sep 2026 19:58:53 +0200 Subject: [PATCH] fix: preserve dev fixes in v2 and add grid fuzzing --- .../running-as-different-user/index.mdx | 3 +- .../integrations/home-assistant/index.mdx | 2 + .../integrations/open-media-vault/index.mdx | 2 + apps/docs/docs/integrations/truenas/index.mdx | 3 + apps/docs/docs/integrations/unraid/index.mdx | 2 + apps/docs/docs/widgets/bookmarks/index.mdx | 2 + .../board/items/widget-context-menu.tsx | 8 +- nginx.conf | 4 +- packages/api/src/shared.ts | 2 +- .../homeassistant-integration.ts | 5 +- .../truenas/test/truenas-integration.spec.ts | 8 +- .../src/truenas/truenas-integration.ts | 117 ++++++- .../src/unraid/unraid-integration.ts | 8 +- .../widgets/src/filter-storage-volumes.ts | 6 + scripts/entrypoint.sh | 33 +- scripts/regressions/README.md | 40 +++ scripts/regressions/grid-fuzz.mts | 301 ++++++++++++++++++ scripts/run.sh | 11 +- 18 files changed, 526 insertions(+), 31 deletions(-) create mode 100644 scripts/regressions/README.md create mode 100644 scripts/regressions/grid-fuzz.mts 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=$!