fix: preserve dev fixes in v2 and add grid fuzzing
This commit is contained in:
1 parent
7c00c18a42
commit
c18548f7c2
18 files changed
+526
-31
No files matched your search
@@ -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.
|
Important to note is, that you'll also need to change the permissions for the mounted directories accordingly.
|
||||||
|
|
||||||
:::info
|
:::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
|
## Without mounted Docker socket
|
||||||
|
|||||||
@@ -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
|
### Adding the integration
|
||||||
|
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|||||||
@@ -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
|
### Adding the integration
|
||||||
|
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import Alert from "@theme/Admonition";
|
|||||||
|
|
||||||
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
||||||
|
|
||||||
|
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
|
### Adding the integration
|
||||||
|
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ import imgSelectApikeyPreset from "./imgs/unraid-select-api-key-preset.png";
|
|||||||
|
|
||||||
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
<IntegrationCapabilites items={[{ widget: healthMonitoringWidget }, { widget: systemResourcesWidget }]} />
|
||||||
|
|
||||||
|
CPU usage is averaged across logical processors, including SMT threads.
|
||||||
|
|
||||||
### Adding the integration
|
### Adding the integration
|
||||||
|
|
||||||
<AddingIntegration />
|
<AddingIntegration />
|
||||||
|
|||||||
@@ -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
|
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.
|
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
|
### Screenshots
|
||||||
|
|
||||||
import gridIconsOnly from "./img/grid-icons-only.png";
|
import gridIconsOnly from "./img/grid-icons-only.png";
|
||||||
|
|||||||
@@ -244,7 +244,13 @@ export const WidgetContextMenu = ({
|
|||||||
setMenuOpened(false);
|
setMenuOpened(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
if (!session || !settings.enableRightClickOnWidgets || isEditMode || item.kind === "app") {
|
if (
|
||||||
|
!session ||
|
||||||
|
!settings.enableRightClickOnWidgets ||
|
||||||
|
isEditMode ||
|
||||||
|
item.kind === "app" ||
|
||||||
|
item.kind === "bookmarks"
|
||||||
|
) {
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -9,7 +9,7 @@ http {
|
|||||||
|
|
||||||
# Route websockets traffic to port 3001
|
# Route websockets traffic to port 3001
|
||||||
location /websockets {
|
location /websockets {
|
||||||
proxy_pass http://${HOSTNAME}:3001;
|
proxy_pass http://127.0.0.1:3001;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
proxy_set_header Upgrade $http_upgrade;
|
||||||
proxy_set_header Connection "Upgrade";
|
proxy_set_header Connection "Upgrade";
|
||||||
@@ -18,7 +18,7 @@ http {
|
|||||||
|
|
||||||
# Route all other traffic to port 3000
|
# Route all other traffic to port 3000
|
||||||
location / {
|
location / {
|
||||||
proxy_pass http://${HOSTNAME}:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_set_header Host $http_host;
|
proxy_set_header Host $http_host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
|
|||||||
|
|
||||||
function getBaseUrl() {
|
function getBaseUrl() {
|
||||||
if (typeof window !== "undefined") return window.location.origin;
|
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";
|
export const trpcPath = "/api/trpc";
|
||||||
|
|||||||
@@ -134,7 +134,10 @@ export class HomeAssistantIntegration extends Integration implements ISmartHomeI
|
|||||||
*/
|
*/
|
||||||
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
private async postAsync(path: `/api/${string}`, body: Record<string, string>) {
|
||||||
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
return await fetchWithTrustedCertificatesAsync(this.url(path), {
|
||||||
headers: this.getAuthHeaders(),
|
headers: {
|
||||||
|
...this.getAuthHeaders(),
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -158,6 +158,8 @@ const happyResponder = (method: string) => {
|
|||||||
];
|
];
|
||||||
case "pool.query":
|
case "pool.query":
|
||||||
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
return [{ name: "tank", status: "ONLINE", healthy: true, free: 500, size: 1000, allocated: 500 }];
|
||||||
|
case "pool.dataset.query":
|
||||||
|
return [{ id: "tank", used: { parsed: 500 }, available: { parsed: 500 } }];
|
||||||
case "interface.query":
|
case "interface.query":
|
||||||
return [{ id: "eth0", name: "eth0" }];
|
return [{ id: "eth0", name: "eth0" }];
|
||||||
case "reporting.netdata_get_data":
|
case "reporting.netdata_get_data":
|
||||||
@@ -214,7 +216,7 @@ describe("TrueNasIntegration", () => {
|
|||||||
loadAverage: null,
|
loadAverage: null,
|
||||||
gpu: [],
|
gpu: [],
|
||||||
network: { up: 20_000, down: 10_000 },
|
network: { up: 20_000, down: 10_000 },
|
||||||
// `available` is the free space left on the pool (free = 500), not its total size.
|
// `available` is the usable free space of the root dataset, not the total pool size.
|
||||||
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
fileSystem: [{ deviceName: "tank", available: "500", used: "500", percentage: 50 }],
|
||||||
smart: [{ deviceName: "tank", healthy: true, overallStatus: "ONLINE", temperature: null }],
|
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.keys()]).toEqual(["system.info", "reporting.get_data", "pool.query", "interface.query"]);
|
||||||
});
|
});
|
||||||
expect(gates.has("reporting.netdata_get_data")).toBe(false);
|
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"));
|
gates.get("interface.query")?.resolve(happyResponder("interface.query"));
|
||||||
await vi.waitFor(() => expect(gates.has("reporting.netdata_get_data")).toBe(true));
|
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));
|
for (const [method, gate] of gates) gate.resolve(happyResponder(method));
|
||||||
|
|
||||||
await expect(resultPromise).resolves.toMatchObject({
|
await expect(resultPromise).resolves.toMatchObject({
|
||||||
|
|||||||
@@ -15,6 +15,22 @@ const logger = createLogger({ module: "trueNasIntegration" });
|
|||||||
|
|
||||||
const NETWORK_MULTIPLIER = 100;
|
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 {
|
export class TrueNasIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||||
private client?: TrueNasClient;
|
private client?: TrueNasClient;
|
||||||
|
|
||||||
@@ -48,12 +64,17 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
cpuTemp: Math.max(...cpuTempData.filter((_item, index) => index > 0)),
|
||||||
memAvailableInBytes,
|
memAvailableInBytes,
|
||||||
memUsedInBytes,
|
memUsedInBytes,
|
||||||
fileSystem: datasets.map((dataset) => ({
|
fileSystem: datasets.map((dataset) => {
|
||||||
deviceName: dataset.name,
|
const total = dataset.used + dataset.available;
|
||||||
available: `${dataset.free}`, // free space left on the pool
|
let percentage = 0;
|
||||||
used: `${dataset.allocated}`,
|
if (total > 0) percentage = (dataset.used / total) * 100;
|
||||||
percentage: (dataset.allocated / dataset.size) * 100,
|
return {
|
||||||
})),
|
deviceName: dataset.name,
|
||||||
|
available: `${dataset.available}`,
|
||||||
|
used: `${dataset.used}`,
|
||||||
|
percentage,
|
||||||
|
};
|
||||||
|
}),
|
||||||
availablePkgUpdates: 0,
|
availablePkgUpdates: 0,
|
||||||
network: {
|
network: {
|
||||||
up: upload * NETWORK_MULTIPLIER,
|
up: upload * NETWORK_MULTIPLIER,
|
||||||
@@ -108,9 +129,10 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
.map((pool) => {
|
.map((pool) => {
|
||||||
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
if (pool.allocated !== null && pool.size !== null && pool.free !== null) {
|
||||||
return {
|
return {
|
||||||
...pool,
|
name: pool.name,
|
||||||
|
status: pool.status,
|
||||||
|
healthy: pool.healthy,
|
||||||
allocated: pool.allocated,
|
allocated: pool.allocated,
|
||||||
size: pool.size,
|
|
||||||
free: pool.free,
|
free: pool.free,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -118,12 +140,76 @@ export class TrueNasIntegration extends Integration implements ISystemHealthMoni
|
|||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
.filter((pool) => pool !== null);
|
.filter((pool) => pool !== null);
|
||||||
|
|
||||||
logger.debug("Retrieved pools", {
|
logger.debug("Retrieved pools", {
|
||||||
url: this.integration.url,
|
url: this.integration.url,
|
||||||
totalCount: result.length,
|
totalCount: result.length,
|
||||||
activeCount: activePools.length,
|
activeCount: 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<string, { used: number; available: number }>();
|
||||||
|
|
||||||
|
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(
|
const reportingNetDataSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
|
|||||||
@@ -37,7 +37,11 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
|||||||
const systemInfo = await this.getSystemInformationAsync();
|
const systemInfo = await this.getSystemInformationAsync();
|
||||||
|
|
||||||
const cpuUtilization = systemInfo.metrics.cpu.cpus.reduce((acc, val) => acc + val.percentTotal, 0);
|
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 totalMemory = systemInfo.metrics.memory.total;
|
||||||
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
|
const usedMemory = Math.max(totalMemory - systemInfo.metrics.memory.available, 0);
|
||||||
@@ -46,7 +50,7 @@ export class UnraidIntegration extends Integration implements ISystemHealthMonit
|
|||||||
return {
|
return {
|
||||||
version: systemInfo.info.os.release,
|
version: systemInfo.info.os.release,
|
||||||
cpuModelName: systemInfo.info.cpu.brand,
|
cpuModelName: systemInfo.info.cpu.brand,
|
||||||
cpuUtilization: cpuUtilization / cpuCount,
|
cpuUtilization: cpuUtilizationNormalized,
|
||||||
memUsedInBytes: usedMemory,
|
memUsedInBytes: usedMemory,
|
||||||
memAvailableInBytes: totalMemory - usedMemory,
|
memAvailableInBytes: totalMemory - usedMemory,
|
||||||
uptime: dayjs().diff(uptime, "seconds"),
|
uptime: dayjs().diff(uptime, "seconds"),
|
||||||
|
|||||||
@@ -5,10 +5,16 @@ interface StorageVolumeEntry {
|
|||||||
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
|
const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
|
||||||
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
|
// SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
|
||||||
{ pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
|
{ 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
|
// NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
|
||||||
{ pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
{ 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
|
// eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
|
||||||
{ pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
|
{ 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 => {
|
export const normalizeStorageDeviceName = (deviceName: string): string => {
|
||||||
|
|||||||
+25
-8
@@ -7,14 +7,31 @@ export PGID=${PGID:-0}
|
|||||||
echo "Starting with UID='$PUID', GID='$PGID'"
|
echo "Starting with UID='$PUID', GID='$PGID'"
|
||||||
|
|
||||||
if [ "${PUID}" != "0" ] || [ "${PGID}" != "0" ]; then
|
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
|
# Only chown the paths the non-root user must actually write to at runtime.
|
||||||
echo "Changing owner to $PUID:$PGID, this will take about 10 seconds..."
|
# Recursively chowning the whole /app tree is extremely slow on network- or
|
||||||
find . -name 'node_modules' -prune -o -mindepth 1 -maxdepth 1 -exec chown -R $PUID:$PGID {} +
|
# HDD-backed storage (e.g. TrueNAS Apps), because the Next.js standalone
|
||||||
chown -R $PUID:$PGID /var/cache/nginx
|
# bundle and its node_modules contain tens of thousands of tiny files. This
|
||||||
chown -R $PUID:$PGID /var/log/nginx
|
# turned a ~10s step into 15+ minutes for some users (#2190). Application
|
||||||
chown -R $PUID:$PGID /var/lib/nginx
|
# code, migrations and native modules are only read/executed, so they stay
|
||||||
chown -R $PUID:$PGID /run/nginx/nginx.pid
|
# owned by root and do not need chowning.
|
||||||
chown -R $PUID:$PGID /etc/nginx
|
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."
|
echo "Changing owner to $PUID:$PGID, done."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -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 <path>`
|
||||||
|
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.
|
||||||
@@ -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<string, string>();
|
||||||
|
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 = <T,>(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<Placement>;
|
||||||
|
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<Placement>, 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<string, number> };
|
||||||
|
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;
|
||||||
|
}
|
||||||
+6
-5
@@ -21,20 +21,18 @@ fi
|
|||||||
export AUTH_SECRET=$(openssl rand -base64 32)
|
export AUTH_SECRET=$(openssl rand -base64 32)
|
||||||
|
|
||||||
# Start nginx proxy
|
# Start nginx proxy
|
||||||
# 1. Replace the HOSTNAME in the nginx template file
|
# 1. Create the nginx configuration file from the template
|
||||||
# 2. Create the nginx configuration file from the template
|
# 2. Start the nginx server
|
||||||
# 3. Start the nginx server
|
|
||||||
# Only listen on IPv6 when the host actually has IPv6 configured (#4596).
|
# Only listen on IPv6 when the host actually has IPv6 configured (#4596).
|
||||||
# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family
|
# Otherwise nginx aborts with "socket() [::]:7575 failed (97: Address family
|
||||||
# not supported by protocol)". Check HOMARR_DISABLE_IPV6 first so users can
|
# 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
|
# force IPv4-only even when the host supports IPv6, then probe for IPv6
|
||||||
# addresses via grep (not -s: proc files always report size 0).
|
# addresses via grep (not -s: proc files always report size 0).
|
||||||
export HOSTNAME
|
|
||||||
export NGINX_LISTEN_IPV6=''
|
export NGINX_LISTEN_IPV6=''
|
||||||
if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then
|
if [ "${HOMARR_DISABLE_IPV6:-false}" != "true" ] && grep -q . /proc/net/if_inet6 2>/dev/null; then
|
||||||
NGINX_LISTEN_IPV6='listen [::]:7575;'
|
NGINX_LISTEN_IPV6='listen [::]:7575;'
|
||||||
fi
|
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
|
# Start services in the background and store their PIDs
|
||||||
nginx -g 'daemon off;' &
|
nginx -g 'daemon off;' &
|
||||||
NGINX_PID=$!
|
NGINX_PID=$!
|
||||||
@@ -67,6 +65,9 @@ terminate() {
|
|||||||
|
|
||||||
trap terminate TERM INT
|
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 &
|
node apps/nextjs/server.js &
|
||||||
NEXTJS_PID=$!
|
NEXTJS_PID=$!
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user