fix: address review findings in the v2 stack fix PR

Board scaling: no sideways scrolling
- Revert the pannable narrow canvas. The canvas fits the viewport again and
  `.viewport` goes back to `overflow: clip`, so a board can never widen the
  document or scroll horizontally.
- Fix the unreadable-narrow-board regression at the real cause instead: cap the
  inverse UI compensation at 2x (50% canvas scale). Down to 50% text and
  controls keep their physical size; below it the board zooms out as a whole, so
  a 200x200 logical tile hosts at most 28px logical text rather than 96px and
  content stops overlapping its neighbours.

Touch editing
- Add `touch-action: none` to `.board-grid-entry`, the dnd-kit draggable and its
  own drag handle. The 200ms touch activation delay is useless if the browser
  has already handed the gesture to page panning and emitted `pointercancel`.
  Only edit mode renders this element.

Custom widgets
- Memoize the merged `data`/`status`/`options` passed to `CustomJsxRenderer`.
  They were rebuilt every render, so the compiled-template memo never hit and
  the new reference-identity boundary key changed on every render, resetting the
  error boundary and forcing an extra reconciliation of the widget subtree.
- Keep the cache key when the shared Redis cache is unavailable so the
  process-local cache still absorbs polling instead of sending every widget
  refresh upstream.
- Reclaim the shared cache's LRU/size/byte bookkeeping for TTL-expired entries
  and touch the LRU in the same script, so expired ghosts stop consuming the
  64 MiB budget and a read costs one round trip instead of two.

Forwarded IP
- Restore `ipAddressFromHeaders` to the full forwarding chain and add
  `trustedIpAddressFromHeaders` for the last hop. Repointing the shared helper
  had collapsed MCP failed-auth rate limiting into a single bucket for every
  client behind a reverse proxy and replaced the client IP with the proxy IP in
  API-key auth logs.

Runtime and restore
- Make the new /appdata ownership migration best-effort. Under `set -e` a mount
  that rejects chown (CIFS/SMB, fixed-uid NFS) turned into a container that
  refuses to start, and /appdata was never chowned before.
- Report malformed metadata.json as such instead of blaming the archive.
This commit is contained in:
ajnart committed 2026-08-21 01:40:46 +02:00
1 parent 496fa60b5f
commit 1b544f599c
10 files changed
+153 -69

No files matched your search

@@ -25,6 +25,32 @@ end
return #KEYS
`;
// Reading has to touch the LRU on a hit and reclaim the byte/LRU/size
// bookkeeping on a miss: entry keys carry their own PX expiry, so a TTL-expired
// response would otherwise keep occupying a slot and its bytes forever and
// shrink the usable cache to nothing over a long uptime.
const READ_SCRIPT = `
local value = redis.call('GET', KEYS[1])
if value then
redis.call('ZADD', KEYS[2], ARGV[1], KEYS[1])
return value
end
local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0')
if size > 0 then redis.call('DECRBY', KEYS[4], size) end
redis.call('ZREM', KEYS[2], KEYS[1])
redis.call('HDEL', KEYS[3], KEYS[1])
return false
`;
const FORGET_SCRIPT = `
local size = tonumber(redis.call('HGET', KEYS[3], KEYS[1]) or '0')
if size > 0 then redis.call('DECRBY', KEYS[4], size) end
redis.call('DEL', KEYS[1])
redis.call('ZREM', KEYS[2], KEYS[1])
redis.call('HDEL', KEYS[3], KEYS[1])
return 1
`;
const STORE_SCRIPT = `
local previousSize = tonumber(redis.call('HGET', KEYS[2], ARGV[1]) or '0')
if previousSize > 0 then redis.call('DECRBY', KEYS[3], previousSize) end
@@ -80,13 +106,14 @@ const getEntryKey = (generation: string, cacheKey: string) => {
};
const readCachedResponse = async (client: RedisClient, entryKey: string) => {
const serialized = await client.get(entryKey);
const serialized = (await client.eval(READ_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY, Date.now())) as
| string
| null;
if (serialized === null) return undefined;
await client.zadd(LRU_KEY, Date.now(), entryKey);
try {
return JSON.parse(serialized) as CustomWidgetHttpResponse;
} catch {
await client.del(entryKey);
await client.eval(FORGET_SCRIPT, 4, entryKey, LRU_KEY, SIZE_KEY, TOTAL_BYTES_KEY);
return undefined;
}
};
@@ -136,7 +163,10 @@ export async function executeWithCustomWidgetResponseCache(
logger.error("Shared custom widget response cache is unavailable", {
errorName: error instanceof Error ? error.name : "UnknownError",
});
return execute({ ...input, cacheKey: undefined });
// Keep the cache key so the caller's process-local cache still absorbs
// polling while Redis is down. Dropping it would send every refresh of
// every placed widget straight to the upstream API.
return execute(input);
}
const pendingKey = `${generation}:${cacheKey}`;
+3 -2
View File
@@ -13,7 +13,7 @@ import { ZodError } from "zod/v4";
import type { Session } from "@homarr/auth";
import { extractBaseUrlFromHeaders, FlattenError } from "@homarr/common";
import { ipAddressFromHeaders, userAgent } from "@homarr/common/server";
import { trustedIpAddressFromHeaders, userAgent } from "@homarr/common/server";
import type { DeviceType } from "@homarr/common/server";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { db } from "@homarr/db";
@@ -43,6 +43,7 @@ interface ApiContext {
deviceType: DeviceType;
baseUrl?: `${string}://${string}`;
onboardingClaimToken?: string;
/** Trusted transport peer, safe to key rate limits and quotas on. */
ipAddress?: string;
db: typeof db;
}
@@ -61,7 +62,7 @@ export const createTRPCContext = (opts: { headers: Headers; session: Session | n
session,
deviceType: userAgent(opts.headers).device.type,
baseUrl: extractBaseUrlFromHeaders(opts.headers),
ipAddress: ipAddressFromHeaders(opts.headers) ?? undefined,
ipAddress: trustedIpAddressFromHeaders(opts.headers) ?? undefined,
onboardingClaimToken: getOnboardingClaimTokenFromCookieHeader(opts.headers.get("cookie")),
db,
};