Compare commits

...
Author SHA1 Message Date
Thomas Camlong 2ebfe659b2 revert(perf): drop the dockerode and docker-modem pnpm patches
Both patches came out of this work and both are removed at the maintainer's
request. The finding was real - ssh2's WASM heap costs +14.0 MiB RSS on require,
and @grpc/grpc-js another 14.6 MiB across 61 files, neither of which a
socket-based Docker install ever uses - but patching a dependency means every
future dockerode or docker-modem bump fails until someone regenerates the patch,
and that recurring cost is not worth roughly 25 MiB.

The finding stays on record in SERVER-MEMORY.md because the fix belongs upstream:
both packages require a module at file scope while using it in exactly one
already-guarded branch, so a PR to each would fix it for every consumer with no
patch to maintain here.

The two remaining patches, @types/node-unifi and trpc-to-openapi, predate this
work and are untouched. allowUnusedPatches was already set on dev and is left
alone. Lockfile regenerated: patchedDependencies now lists only those two.

Also sharpens the Cache Components entry. The SqliteError was simply an unmigrated
build database, which is fixable - but feeding the build a database would be the
wrong fix, because serverSetting is per-installation data on the user's volume and
a shell prerendered against the build machine's database would bake one install's
settings into every other install's first paint. The error was Next.js correctly
reporting a request-time read sitting in the shell path.
2026-08-14 11:16:55 +02:00
Thomas Camlong ca0bb36664 docs(perf): Cache Components tested and rejected, with the numbers
Enabled cacheComponents: true on Next 16.3.0-preview.9 and built. The flag works
and the usual migration friction is absent - zero files export dynamic,
revalidate or fetchCache.

Two blockers. The [locale] dynamic root segment needs generateStaticParams,
which is cheap: the guide is explicit that one value suffices, because
dynamicParams: true now serves the App Shell for unlisted params and upgrades in
the background. Not 53 routes x 38 locales.

Past that, 111 routes fail with the same error - SqliteError: no such table:
serverSetting. Prerendering runs at build time and the root layout
unconditionally awaits auth, two DB reads and a cookie read. For a self-hosted
app whose database is created at runtime on the user's volume there is nothing to
query during a build. Deferring those reads means moving the providers that wrap
children inside Suspense, which leaves a static shell of little more than
<html><body> - the exact failure the migration guide warns about.

And the prize is ~16-21 ms: measured TTFB is ~3 ms for a route that skips the
root layout against ~19-24 ms for one that goes through it. Meanwhile the board -
the main screen - is already dynamic(..., { ssr: false }) with a skeleton, so it
already paints an instant shell and Cache Components has no server render to
unblock there.

Recorded with both caveats: the ~20 ms is warm local SQLite and would be larger
on a network database, and the useful idea inside this - not blocking the first
byte on auth plus two DB queries - can be had by streaming those reads in the
existing layout, without the migration.

Experiment fully reverted; no code changes in this commit.
2026-08-14 11:11:50 +02:00
Thomas Camlong 839b4721fb perf(iframe): lazy-load the iframe widget
Also from PR #6582. An iframe is an entire browsing context, so one scrolled off
a tall board is the single most expensive thing a widget can load eagerly - and
this one had no loading attribute at all.

Records what else was mined from that PR and declined, notably its dynamic()
for BoardItemMenu: that component already early-returns null outside edit mode,
so there is no per-item overlay cost, and dynamic() fetches its chunk when the
component renders rather than when it returns something - so the chunk would
still load on every board render. It only pays off with the isEditMode guard
hoisted to the call site, which is a board-rendering change worth measuring
first rather than adopting on faith.
2026-08-14 11:04:08 +02:00
Thomas Camlong 40374803d4 perf(widgets): stop building collapsed Accordion panels
From reading PR #6582, which adds keepMounted={false} to the two coolify
accordions. The widget sweep had checked Tabs and never checked Accordion, so it
missed this on all five sites - an honest gap that PR closed for two of them.

Accordion behaves exactly like Tabs, verified in the installed version rather
than assumed: with three marker children per panel, the default renders 3 of 3
children of the *collapsed* panel and keepMounted={false} renders 0.

It matters more here than in Tabs, because collapsed is an accordion section's
normal state rather than the exception:

  coolify instance-card          three remote-data lists: servers, applications,
                                 services - all built while collapsed
  coolify single-instance-layout same three lists
  health-monitoring cluster      per-node resource tables   (beyond #6582)
  firewall                       interface rows             (beyond #6582)

Left mounted: the multiReleasesRepositories input, which holds selection state
and is edit-modal only rather than on the board render path.

In every fixed case the open/closed state lives in the parent, so unmounting a
collapsed panel discards nothing.

The spec is renamed tabs-keep-mounted -> keep-mounted since it now pins both
components, and three verdicts in the audit table are corrected: coolify was
recorded "clean (bounded)" and firewall triaged on other grounds, while both were
carrying this cost.
2026-08-14 11:02:13 +02:00
Thomas Camlong 2de3c86b31 fix(docs): exclude generated output from the docs typecheck
CI failed @homarr/docs:typecheck with a wall of TS1473 "An import declaration can
only be used at the top level of a module" pointing at
build/__server/assets/js/*.js - Docusaurus's own emitted bundles, not source.

The docs tsconfig includes **/*.js with allowJs enabled and excludes only
node_modules, so anything the build writes into the package is typechecked as
source. The turbo `typecheck` task dependsOn ["^topo"] and not on its own
package's `build`, so `docusaurus build` and `tsc --noEmit` run concurrently -
and tsc can observe the transient build/__server/ directory before the build
removes it again.

That makes it a latent race rather than a deterministic failure, which is why it
has not bitten before: it needs @homarr/docs to be in the affected set, and
merging dev brought apps/docs changes in. Locally it passes either way, because a
completed build leaves no __server directory behind.

Verified against a control rather than assumed: with build/__server/assets/js
containing a nested import declaration, the current tsconfig reports 0 errors and
the same tsconfig without the exclude reports the 2 TS1473 errors, matching CI.
2026-08-13 19:08:08 +02:00
ajnart be0043ab81 style: auto-fix lint and format 2026-08-13 16:41:32 +00:00
Thomas Camlong 89f081538c Merge origin/dev into feat/phase1-perf-impl
The branch was 12 commits behind dev and GitHub reported it CONFLICTING, which is
why the real CI workflow had not run since 2026-08-10 - a conflicting PR has no
mergeable ref, so pull_request-triggered workflows are skipped. Only the
lightweight checks (semantic title, CodeQL, CodeRabbit) were still reporting,
which made the PR look green while nothing was actually being tested.

Three conflicts, all of them this branch's barrel-diet work meeting dev's new
What's Up Docker integration. In every case the branch's structure is kept and
WUD is absorbed into it rather than the reverse:

  integrations/base/creator.ts   keep the lazy loader map; add wud as
                                () => import(...) rather than dev's static import,
                                which would have undone the lazy creator
  integrations/src/index.ts      keep the slim barrel; re-export WudStats and
                                WudContainerUpdate as `export type`, which is
                                erased at build and so adds no runtime module
  widgets/src/index.tsx          stays deleted - this was the barrel that put
                                every widget in the initial client graph

Registering WUD in the slimmed structures then needed three more entries that the
merge could not infer: base/kind-map.ts (type-only kind -> class map),
widgets/src/import.ts (type-only WidgetImports map, both the import and the
member). Each was found by typecheck rather than guessed at.

Verified: 37/37 packages typecheck, 1866/1881 unit tests pass. The 3 failures are
home-assistant and pi-hole, both testcontainers-based; both pass 11/11 in
isolation and fail only under 150-file parallel load with the container's payload
half-initialised.
2026-08-13 18:40:08 +02:00
Thomas Camlong fd351e4adf test(widgets): pin the Tabs behaviour that keepMounted={false} depends on
health-monitoring and media-missing pass keepMounted={false} because Mantine
otherwise builds the inactive panel's whole subtree. That came from reading
Mantine's docs, which is a weak basis for a prop that trades a remount cost for a
mount saving: if Mantine ever stopped rendering inactive panels, the prop would be
pure loss and nobody would notice.

Verified in the installed version instead. With three marker children per panel,
the default renders 3 of 3 children of the *inactive* panel into the DOM, and
keepMounted={false} renders 0. If that changes, this fails and the prop should
come back out.

Also records two things checked while re-reading the audit:

_inputs/ is excluded from the widget sweep on purpose, not by oversight - its
2,748 lines are option editors reached only from the edit modal and the
custom-widget form, and the one widget importing from it uses `import type`,
which is erased at build.

The "statically analyzable dynamic imports" rule has exactly one exception,
translation/src/mapping.ts building import(`./lang/${language}.json`) over 36
languages. It stands: the rule targets accidental over-inclusion, and every
language is genuinely reachable since the user can switch at runtime. Server-side
message loading is already memoised per locale, so it is not per-request either.
2026-08-13 18:28:29 +02:00
Thomas Camlong 43cc40eb66 docs(perf): optimizePackageImports is inert here - Turbopack makes it a no-op
Seven Mantine entry points were missing from optimizePackageImports while
core/hooks/icons-react were listed. Added all seven, rebuilt, and the client
bundle came out byte-for-byte identical: 482 files, 18050.7 KiB, same largest
chunk, same chunk hashes.

Identical output down to the hashes normally means a cached build, and
turbopackFileSystemCacheForBuild is enabled here, so it got checked rather than
written up as a null result. The Next.js docs are explicit: "This is not needed
when using Turbopack. Turbopack automatically analyzes and optimizes imports
without requiring this configuration." The build banner confirms Turbopack.

So the change is reverted and the existing three-entry list documented as dead
config, kept only as a webpack fallback. This also retires the sub-question of
whether @homarr/ui - the largest barrel at 111 importers - could be optimised
this way: it cannot, and it does not need to be.

Two notes for anyone revisiting: Next 16 with Turbopack prints no size columns in
its route table and emits no app-build-manifest.json, so per-route first-load JS
is unavailable from build output; these numbers are total bytes under
.next/static/chunks.
2026-08-13 15:36:12 +02:00
Thomas Camlong 320c51ac89 fix(http): stop destroying superseded agents, which could abort live requests
Follow-up to the agent caching change. When the trust material changes the caches
are cleared, and that path called agent.destroy() on the node https agents.

Node's documentation for agent.destroy() is "Destroy any sockets that are
currently in use by the agent" - so an admin uploading a certificate could have
aborted an HTTPS request that was already in flight, for instance a nextcloud
calendar sync. The agents run without keepAlive (verified: new https.Agent({})
reports keepAlive false on node 24), so destroying bought nothing anyway; each
socket closes on its own once its response completes.

undici's close() is kept, because it waits for enqueued requests to finish and
therefore cannot interrupt one in flight.

Also verified the correctness premise of sharing a single agent across every
integration, which the previous commit asserted but did not test: three origins
served by one agent each got their own pooled connection, four requests over one
connection apiece, with no response misrouted between them.
2026-08-13 15:36:12 +02:00
Thomas Camlong 09df2910c5 docs(perf): check the widgets against Vercel's React/Next performance rules
Sourced with the skills CLI - npx skills find "react performance", then
npx skills use affaan-m/ecc@react-performance, an adaptation of vercel-labs
react-best-practices: 70+ rules across 8 categories. Every mechanically
checkable rule was run against the widgets.

Seven already hold, and that is worth recording as much as a finding: no widget
registers a scroll listener, no numeric && can render a literal 0, the nested
array scans that exist are over single-digit collections, React.cache() is
applied, TanStack Query does the deduplication, content-visibility is on the six
lists that need it, and the two components-declared-in-render were already found
and fixed.

One rule this codebase deliberately contradicts: the skill prefers React 19
<Activity> over unmounting for tabs, and this pass did the opposite with
keepMounted={false}. The rule optimises for switching; a dashboard optimises for
mounting. Every board load mounts every widget while a user switches a widget's
inner tabs rarely, so building a hidden monitoring view on every page load buys
a faster switch nobody performs. Where state loss would show - custom-api's user
JSX, the edit modal's forms - the panels stay mounted.

Two candidates declined rather than guessed at, both recorded with reasoning:
the 20+ `data = []` defaults only destabilise while data is undefined, since
react-query returns a stable reference once loaded; and the 7 unlisted Mantine
entry points need two production builds and a first-load-JS diff before anyone
claims they help. @homarr/ui is not eligible at all despite 111 importers,
because Next's barrel optimiser only rewrites pure re-export files.

Also records the process error behind the earlier "no skills CLI" claim: the
entries in ~/.claude/skills are symlinks, which find -type f skips, so the
directory looked empty right after cat had succeeded on a file inside it.
2026-08-11 16:40:27 +02:00
Thomas Camlong 083a926719 docs(perf): give every one of the 50 widgets a verdict
Completes the sweep: 11 fixed, 8 triaged, 10 read and found clean, 21 verified
bounded. Each verdict records the evidence behind it, because "I read this" and
"its list cannot get long" are different strengths of claim and should not look
alike in a table.

The four widgets the ranking flagged as most likely to hide something all came
back clean, and three were already bounded before I looked - traefik slices to
what its width shows, umami to its own limit option, beszel-alerts to
options.maxHistoryItems. system-resources looked worst of all with 14 unmemoised
maps, until it turned out none of the six chart components it feeds is memoised
either, so a stable array identity would preserve nothing.
2026-08-11 16:33:33 +02:00
Thomas Camlong ec79a15023 perf(widgets): stop building hidden tab panels, and drop a third-party image fallback
Mantine's Tabs keeps inactive panels mounted by default. On Mantine 9 with
React 19 that uses <Activity>, which preserves state and unmounts effects but
still builds the entire subtree - so a two-tab widget pays for both while
showing one.

  health-monitoring  both the system and the cluster monitoring view were built
  media-missing      both tabs built their full card list, each card with a
                     poster and a blurred backdrop

Two other Tabs users are deliberately left mounted: custom-api panels can hold
user component state, and the widget edit modal would reset half-filled forms.

media-missing also rendered every card into a scrolling tile with no lazy
loading, the same shape as media-releases. Its card height is fixed per density,
so contain-intrinsic-size is exact rather than a guess.

calendar fell back to https://placehold.co/400x400 when a poster failed to
load, so a self-hosted dashboard made a third-party request - once per failing
event - to draw a grey square, and showed nothing at all on a host without
internet access. Same grey square, inline.

The rss feed handler logged the whole serialised feed entry through a raw
console.debug, which no log level could suppress, and reported a match count
that was really the regex capture-group count.

Adds scripts/benchmarks/widget-inventory.mjs and docs/perf/WIDGET-AUDIT.md, a
verdict for all 50 widgets.
2026-08-11 16:30:22 +02:00
Thomas Camlong cf240addea perf(image-proxy): stream proxied images instead of buffering them
forwardImageAsync awaited response.arrayBuffer(), so the whole image sat in
server memory before anything was sent to the client. A media board asks for
dozens of posters and backdrops at once, so that is dozens of full images held
simultaneously - and a client that gives up midway still cost a complete
server-side download.

Forwarding response.body instead lets backpressure reach upstream. Measured
with 30 concurrent 2 MiB images and deliberately slow clients, one fresh
process per arm because the allocator does not return freed spans:

  buffered   +116.0 MiB, +117.8 MiB peak RSS
  streamed    +74.2 MiB,  +78.8 MiB peak RSS

-36% peak RSS on that load, and it scales with concurrency times image size.
Content-Length is now forwarded too, so clients still get a progress bar.
2026-08-11 16:09:37 +02:00
Thomas Camlong 3ba2800db9 perf(http): reuse the certificate agent instead of building one per request
fetchWithTrustedCertificatesAsync has 125 call sites and none of them pass a
dispatcher, so every HTTP request Homarr makes - every integration poll, every
proxied image - built a brand new EnvHttpProxyAgent. An agent owns its
connection pool and its TLS session cache, so a fresh one per request means a
full TLS handshake every time and no keep-alive reuse at all.

Measured against a local TLS server, 60 sequential requests:

  fresh agent per request   5.11 ms/req   60 TLS handshakes
  one shared agent          1.69 ms/req    2 TLS handshakes

3.0x faster and 30x fewer handshakes on loopback; over a real network the gap
is larger, since that is where handshake round-trips actually cost something.

Agent construction itself is cheap (0.004 ms, 1.7 KiB) because undici builds
the TLS store lazily at connect time - so the cost was never construction, it
was the handshake that a fresh pool forces.

The trust material is still read on every call and the cached agents are keyed
on a hash of it, so adding or removing a certificate - including by dropping a
file into the mounted folder rather than going through the UI - takes effect on
the very next request, exactly as before. Superseded agents are closed so their
idle sockets are released.
2026-08-11 16:09:37 +02:00
Thomas Camlong 9b54edd2db docs(perf): correct the "16 MiB tRPC subtree" lever - it was already removed
This branch has repeatedly named a 16.10 MiB subtree of 460 files, reached by every API
route, as the one remaining server-side lever with real headroom, attributing it to the tRPC
router importing every integration eagerly. Both halves are wrong.

The integration creator was already fully lazy: every kind in creator.ts is
`() => import(...)`, so no integration class loads until one is constructed. The earlier
barrel diet had done this properly - the claim was never checked against the code.

The subtree was an artefact of route preloading, measured before preloadEntriesOnStart:
false shipped, when Next loaded all 74 routes' module graphs at startup. Re-measured on the
current build, boot is 706 files and 10.56 MiB:

     3.73 MiB    28 files  .next/server/chunks (the app's own boot graph)
     2.16 MiB    14 files  better-sqlite3 (2.1 MiB of it the native binary)
     1.75 MiB   359 files  next/dist
     1.58 MiB    25 files  next/dist/compiled
     0.45 MiB    77 files  mysql2        <- known, structurally blocked
     0.07 MiB    13 files  pg            <- same

There is no single large server lever left. What remains is diffuse: a full multi-tab session
loads 15.44 MiB of app chunks across 241 files, and both the ~41 MiB of retained source and
the ~38 MiB of compiler zones scale with that. Reducing it means shipping less application
JavaScript per route, which is a broad refactor with no single target.
2026-08-11 10:22:31 +02:00
Thomas Camlong 37dead1690 docs(perf): verify the off-screen changes, and record two instruments that lied
The off-screen work was committed unmeasured; this closes that debt. Measured on two
containers restored from identical data:

  image requests   138, 135  ->  22, 22   (-84%)
  image bytes        12.27 MiB -> 0.03 MiB
  layout objects  11611, 7836  ->  3694, 3705  (-53%)

A 99.8% drop in image bytes is not believable, so it was checked instead of reported. Two
checks were bad instruments before one worked:

  - Counting decoded images via getBoundingClientRect() reported 80/80 loaded in BOTH arms.
    Reading geometry forces layout, which forces content-visibility subtrees to render, which
    triggers the very image loads being tested for. The instrument caused the result.
  - Screenshots came back byte-identical in both arms, because this environment shows an
    onboarding overlay and media-requests renders blank with unreachable integrations. Good
    evidence of no visual difference; useless as evidence that images render.
  - Counting img.complete && naturalWidth > 0 reads no geometry and gave the answer: 80/80
    decoded in both arms, 138 responses before against 25 after.

So nothing is visually broken. The 80 imgs are integration icons sharing URLs, deduplicated to
~25 unique fetches in both arms; the ~113 avoided requests are other widgets' off-screen
artwork, chiefly media-requests' full-size per-card backdrops, which is also where the 12 MiB
came from.

Stated limit: media-requests renders blank in this environment, so its visible-card behaviour
after the change is unverified. Media-releases is verified at 80/80 decoded.
2026-08-11 10:20:33 +02:00
Thomas Camlong d2e86895f4 docs(perf): rewrite the client doc with the measured overlay results
Replaces the earlier version with what was actually measured, and closes out all 54
findings from the widget audit rather than leaving categories open:

  overlay-per-row (12)      5 fixed; 7 are functional tooltips with real labels
  index-key (11)            all safe - static text splits, or stateless rows from
                            user-configured JSONPath data with no stable id
  nested-component (2)      both fixed
  inline-style-per-row (29) left alone; a fresh object per row only defeats memoisation
                            if the child is memoised, and these are not

Measured, deterministic per-widget counts:

  mediaReleases      340 comp / 8387 fib  ->  170 / 8057
  calendar           171 comp / 1195 fib  ->   75 /  907
  beszelSystemTable   36 comp / 1286 fib  ->   18 / 1230

Records two metrics that turned out unusable - Chrome's board-level JSEventListeners read
7905 to 17114 across six runs of identical code, and its Nodes counter includes detached
nodes - and one claim withdrawn: the media-releases tooltip fix measured zero, the win for
that widget came entirely from the shared OverflowBadge fix.
2026-08-11 10:11:08 +02:00
Thomas Camlong afa68687eb perf(widget-inputs): stabilise the drag handle component type
Last of the two nested-component findings from the widget audit. `Handle` was declared in the
render body and passed to a child as `handle={Handle}` - a component *type*. A type that
changes identity makes React unmount and remount the subtree instead of updating it, and this
component re-renders on every frame of a drag as dnd-kit updates `transform`, so the handle was
being torn down and rebuilt continuously while being dragged.

Wrapped in useMemo keyed on the two things it closes over.

Measured result of the whole overlay/remount sweep, per widget, deterministic component and
fiber counts on identical restored data:

  widget                    before              after
  mediaReleases      340 comp / 8387 fib   170 / 8057   (-50% components)
  calendar           171 comp / 1195 fib    75 /  907   (-56% components, -24% fibers)
  beszelSystemTable   36 comp / 1286 fib    18 / 1230   (-50% components)
  downloads / mediaRequests / app        unchanged - their overlays are functional

Two corrections to earlier claims in this branch:

  - The media-releases win came from the shared OverflowBadge fix, NOT from the tooltip change.
    Measured on the build with only the tooltip fix, mediaReleases was still at 340; it dropped
    to 170 once OverflowBadge stopped mounting a Popover when nothing overflows. The tooltip fix
    remains zero on this board because every release has a description.
  - The board-level "event listeners" metric is unusable. Across six runs of identical code it
    read 7905, 8000, 8166, 8179, 10360 and 17114. Chrome's Nodes counter has the same problem
    (23072 on one run against ~8850 on the rest) because it counts detached nodes awaiting
    collection, while "elements under body" stayed at 6691-6726 throughout. Only the per-widget
    component and fiber counts are stable enough to compare, so those are what is reported.
2026-08-11 10:09:59 +02:00
Thomas Camlong 2534103627 perf(ui): stop mounting Popover machinery for overlays that cannot open
Two more instances of the same Mantine behaviour, found by searching for overlay components
that take a `disabled` prop rather than by measuring another board.

OverflowBadge (packages/ui) mounted a Popover and a Popover.Dropdown unconditionally - both
when `disablePopover` was set and, more importantly, when nothing overflowed at all, which is
the common case. It is shared UI, used by media-releases, releases, the docker table and the
widget inputs, so the cost repeated per row wherever a list renders it. The rendered markup is
unchanged: a Popover.Target only forwards a ref, so returning the badges directly is
visually identical.

beszel disk-usage mounted a HoverCard per table row with `disabled={filesystems.length === 0}`,
and already fell back to a plain progress bar inside its own target for that case - so for a
system reporting no extra filesystems the entire overlay existed to render nothing. Hoisted
that branch out; the now-unreachable ternary and the dead `disabled` prop went with it.

Measured result of this class of fix, on the calendar which had the worst ratio:

  interactive components  171 -> 75   (-56%)
  fibers                 1195 -> 907  (-24%)

Honest note on the media-releases tooltip fix committed earlier: it measured **zero** on the
test board. Every release there has a description and the option is on, so all 80 tooltips are
legitimately enabled and none are skipped. It only helps boards where descriptions are absent
or the option is off. The mechanism is right; this board simply is not the case it helps.

Also confirmed NOT worth changing: downloads mounts 30 Tooltips but all three per row are
functional (integration name, hover detail, progress), so there is nothing disabled to skip.
Replacing them with native title attributes would change the UX, not just the cost.
2026-08-11 10:04:12 +02:00
Thomas Camlong 365b2ed13e perf(calendar): stop mounting a HoverCard per day cell that can never open
Third instance of the same pattern, and the worst ratio yet. Mantine mounts a HoverCard's
Popover machinery - Popover, PopoverTarget and PopoverDropdown - even when it is disabled.
A month grid is around 42 cells and `disabled` is `isEditMode || eventsForDate.length === 0`,
so on a typical month most days mounted three components for a card that can never open, and
in edit mode every single day did.

Measured on a real board: 42 Popovers, 42 PopoverDropdowns and 42 PopoverTargets - 126 of
this widget's 171 listener-attaching components - in a widget with only 251 DOM nodes. That
is a far worse components-per-node ratio than media-releases, which had 340 across 2,951
nodes.

Returning the day cell directly when disabled is behaviour-identical: a disabled HoverCard
never opens, so nothing observable is lost.

This is now the third place this exact Mantine behaviour has cost real work - after the
shared board context menu and the media-release tooltips. The general rule: Mantine overlay
components mount their full machinery regardless of `disabled`, so `disabled` is a
visual-suppression prop, not a cost-avoidance one. Deciding before rendering is what avoids
the cost.
2026-08-11 09:58:13 +02:00
Thomas Camlong 7e961d3da4 perf(beszel-system-table): hoist PercentCell out of the render body
A static audit of every widget (scripts/benchmarks/audit-widget-render.mjs, added here)
found this: PercentCell was declared inside the widget body, which makes it a new component
type on every render. React cannot match a new type to the previous one, so it unmounted and
remounted every cell's subtree - Group, Text and Progress - instead of updating it. The
widget re-renders about once a second from its live Beszel subscription, and the cell is used
by three columns, so that was three subtrees per system torn down and rebuilt every second.

Hoisted to module scope and wrapped in memo. `size` is now passed as a prop; it was the only
thing the nested version closed over.

The audit covers all widget .tsx files and ranks findings by whether they sit inside a
.map(), since those multiply by row count:

    29  inline-style-per-row
    12  overlay-per-row
    11  index-key
     2  nested-component

Deliberately not fixing the other 52 yet. The 29 inline styles are the least likely to
matter - a fresh object per row only defeats memoisation if the child is memoised, and mostly
these are not - so changing 29 call sites would be churn without a measurement to justify it.
The 11 index keys are only harmful where a list reorders, which needs checking per widget. The
remaining overlay-per-row sites are the same pattern already fixed twice (context menus, then
media-release tooltips) and are the most likely to be worth it: calendar mounts 42 Popovers
and downloads 30 Tooltips, both confirmed at runtime.

Not measured: the mechanism is certain but the size depends on how often the widget renders
and how many systems are in the table.
2026-08-11 09:52:37 +02:00
Thomas Camlong f5b7818c53 perf(media-releases): stop mounting a Popover per card for tooltips that never show
Attributing listener-attaching components per widget found the same anti-pattern the
shared board context menu already fixed once, still present in list widgets:

  interact   nodes  fibers  kind / which components
       340    2951    8387  mediaReleases
                            UnstyledButton x90  TooltipFloating x80  Popover x80
                            PopoverDropdown x80  PopoverTarget x10
       171     251    1195  calendar
                            UnstyledButton x45  Popover x42  PopoverDropdown x42
                            PopoverTarget x42
        46     264     898  downloads
                            Tooltip x30

Mantine mounts a TooltipFloating's Popover machinery even when the tooltip is disabled, so
wrapping every card unconditionally produced 80 TooltipFloating, 80 Popover and 80
PopoverDropdown components - 240 of this widget's 340 listener-attaching components - for
tooltips that only show on hover, on cards that are 91% off-screen.

Deciding before rendering means a card with no description, or with the option off, mounts
none of it. Behaviour is unchanged for cards that do have a description.

Also extends measure-widget-cost.mts to count these components per widget, which is what
found it. The board's 8,165 event listeners were never attributable before; now they are.

Not yet measured: the listener reduction depends on how many releases carry a description,
which is integration data. The mechanism is certain (the components are simply not created)
but the size of the win on a given board is not, and calendar's 42 Popovers and downloads'
30 Tooltips are untouched. Tracked in docs/perf/NEXT-STEPS.md.
2026-08-11 09:42:59 +02:00
Thomas Camlong fee2e6a6af docs(perf): track which off-screen applications are measured and which are not
Four commits now apply the off-screen row treatment; only the first was A/B'd. Records
which is which, and warns how to read a result for the three widgets that do not overdraw
on the test backup - a measurement there will correctly show no change, because the benefit
only exists once an integration returns more rows than the tile can show.

Also records two things that look like gaps and are not:

  - content-visibility was applied to five of the 26 ScrollArea widgets on purpose. It
    costs containment work and can be wrong on self-measuring content such as charts and
    the notebook, so the rule was: apply where list length is decided by remote data.
  - React cache() is server-only. It already wraps getBoardByNameAsync, getHomeBoardAsync,
    getRscServerSettingsAsync and getQueryClient; it does nothing for client widget
    rendering, where react-query does the caching and its gcTime was already verified at
    5 minutes rather than the 24 hours the original fix catalogue assumed.
2026-08-11 09:38:59 +02:00
Thomas Camlong 040f105946 perf(widgets): share the off-screen row primitive, and apply it to the data-driven lists
Extracts the media-releases fix into packages/widgets/src/common/offscreen-rows.ts and
applies it to the remaining widgets whose list length is decided by remote data rather
than by the tile: rssFeed, notifications and the calendar event list. Images in those
lists also get loading="lazy" - the rss cards carry two each, a backdrop and a poster.

The measured basis, from media-releases where the pattern was found:

  image requests   135 -> 55    (-59%)
  layout objects  8346 -> 5117  (-41%)

Scope was chosen deliberately rather than applied everywhere. 26 widgets use ScrollArea,
but on the measured board only two rendered more than their tile could show, and
content-visibility is not free: it costs a little containment work, and on content that
measures itself - charts, editors - skipping layout can be actively wrong. The widgets
changed here are the ones where overdraw is a function of how much data an integration
returns, so a user with a busier instance hits it even though this board does not. Chart
and fixed-size widgets are deliberately left alone.

Honest scope note: unlike the media-releases change, these three are NOT measured. On the
test board rssFeed renders 7 nodes and the calendar's 7 rows fit their tile, so there is
nothing to improve here to observe - the benefit only appears with more data than this
backup has. They are typechecked and lint-clean. docs/perf/NEXT-STEPS.md tracks them
alongside the media-requests change as pending verification.

A duplicate `style` prop on the notifications Card was caught by typecheck and merged
rather than left to override the existing colour and text-decoration styles.
2026-08-11 09:37:48 +02:00
Thomas Camlong 8f94ea47e3 docs(perf): add a handoff with the unverified item, the remaining lever, and the clean-PR set
Records where this stopped so it can be picked up cleanly:

  1. c526dd2b7 (media-requests content-visibility) is typechecked but NOT measured - the
     only performance claim on this branch not A/B'd before commit. Verify with
     measure-board-paint.mts, three repeats; revert if image requests do not fall.
  2. The one lever with headroom left is the tRPC router's eager integration imports - a
     16.10 MiB subtree of 460 files reached by every API route, which pays twice because
     both the ~41 MiB of retained source and the ~38 MiB of V8 compiler zones scale with
     how much JavaScript the server compiles. Every allocator and V8-flag avenue is closed.
  3. Deferred with prizes attached: ASCII-escaping bundles (9.45 MiB, needs its own PR),
     lazy mysql2/pg (21.4 MiB, structurally blocked), beszelSystemStats chart SVG.
  4. The proposed set for the clean PR, six changes producing -26% peak anon and -52% at
     idle for +18% CPU - with the gRPC patch flagged as hygiene rather than a measured win,
     and the +30% board-load figure flagged as not established.
2026-08-10 19:12:28 +02:00
Thomas Camlong c526dd2b77 perf(media-requests): apply the off-screen fix to the request list — NOT YET MEASURED
Second instance of the pattern fixed in mediaReleases. The overdraw tool, corrected to
find row containers by child count rather than by uniform height, found only one other
widget genuinely rendering more than its tile can show:

  nodes  rows  vis  offscr  imgs   tile  content  kind
   2951    80    7    2693    80    539     7206  mediaReleases            (already fixed)
    393    20    6     275    60    405     1512  mediaRequests-requestList

1,512 px of cards in a 405 px tile, and 20 cards carrying 60 images between them - each
card renders a full-tile backdrop plus a poster. Same treatment: content-visibility with
contain-intrinsic-size on the card, loading="lazy" on both images.

IMPORTANT: this is typechecked but **not measured**. Every other performance claim in this
branch was verified by an A/B before being committed; this one was not, because the session
ended first. It is committed so the work is not lost, and it is the first thing to verify
next - the same measurement used for mediaReleases applies:

  STRESS_BASE_URL=http://127.0.0.1:PORT node --experimental-strip-types \
    scripts/benchmarks/measure-board-paint.mts

Expect image requests to fall; do not expect JS heap to move, since decoded bitmaps live
outside JSHeapUsedSize.

Every other widget on the board fits its tile and needs nothing. The tool's "visible"
column misreads chart widgets (beszelSystemStats reports 28 rows / 0 visible because its
container is an SVG whose groups have no layout height) - content height versus tile height
is the trustworthy signal, and by that measure only these two overdraw.
2026-08-10 19:11:38 +02:00
Thomas Camlong c73b139dbd docs(perf): split the findings into per-subject markdown files
PHASE2-FINDINGS.md had grown past 1,200 lines mixing server memory, client rendering,
methodology and a long list of rejected experiments, which made it unusable as a
reference. Split under docs/perf/:

  README.md              headline results and the change-by-change ratio table
  SERVER-MEMORY.md       what the container's RAM is made of, layer by layer
  CLIENT-PERFORMANCE.md  dashboard cost in Chrome: DOM, fibers, images, paint
  REJECTED.md            ideas implemented, measured and abandoned, with numbers
  METHODOLOGY.md         the tools, and the measurement traps that gave wrong answers

PHASE2-FINDINGS.md is now an index so existing links keep working.

METHODOLOGY.md is the part worth keeping: every wrong number in this investigation came
from an instrument rather than the application, and each trap is written down - snapshots
cannot explain peaks, V8 truncates snapshot strings to ~1 KiB, snapshot objects are not
live handles, measuring without a forced GC measures V8's growth, taking a CDP snapshot
added 134 MiB to the process being measured, per-component React attribution silently
returns nothing on a production build, a fixed port can measure a leftover container, and
a server-side change that moves a client-side metric means the workload changed.
2026-08-10 19:08:20 +02:00
Thomas Camlong 7550766f67 perf(media-releases): stop rendering 73 off-screen cards' images and layout
Per-widget attribution of the dashboard found one widget responsible for 44% of the
board's DOM: mediaReleases at 2,951 nodes and 8,387 fibers, including 1,935 Mantine
Box components, against a board total of 9,293 nodes.

The cause is that it maps over every release the integrations return, with no cap.
Measured on a real board: the tile is 539 px tall and its content is 7,206 px, so of
80 rendered cards only 7 are visible. The other 73 cost DOM, fibers, and - most
expensively - a poster and a CSS backdrop image each, all fetched and decoded for
pixels nobody can see. Decoded bitmaps are far larger in memory than their files.

Fixed with content-visibility: auto plus contain-intrinsic-size on each card, so the
browser skips layout, paint, raster and background-image fetch for off-screen cards,
and loading="lazy" on the poster. Nothing leaves the DOM, so scrolling and in-page
search are unchanged, and the intrinsic size keeps the scrollbar from jumping.

Measured over three repeats per side on identical restored data:

  image requests    135, 135, 135  ->  55, 55, 56   (-59%, deterministic)
  image bytes            12.27 MiB  ->  8.41 MiB    (-31%)
  layout objects  8346, 7815, 7815  ->  5117, 4696, 4636  (-41%)

Two things this does NOT show, stated because a single run suggested otherwise:

  - JS heap did not clearly improve. Across four runs it was 46-88 MiB before against
    74-86 MiB after; the before spread is 2x, so no claim either way. This is expected:
    decoded image memory lives outside JSHeapUsedSize, so the actual saving from 81
    fewer image fetches is invisible to that metric.
  - Task duration and time-to-settled overlap between arms and support no conclusion.

Adds the tools this needed: measure-widget-cost.mts (per-widget DOM nodes, fibers and
heaviest components), measure-board-paint.mts (image requests, bytes, style/layout/task
time, layout objects), measure-widget-overdraw.mjs (rendered versus visible rows).
2026-08-10 19:05:17 +02:00
Thomas Camlong 5fb8e9223a docs(findings): the 38 MiB of compiler zones is V8's optimiser, and not reachable by flags
Closes the largest unexplained item in the peak breakdown, then closes it off as a lever.

Attribution: peak_malloced_memory goes 4.5 -> 42.1 MiB the moment a board's modules
compile, while current malloced_memory stays at 1.0 - memory V8 takes to compile, frees
back to malloc, and musl never returns to the kernel. Confirmed by re-reading the
--jitless runs, where the spike never occurs at all (4.5 MiB at every stage).

--jitless is unusable because it under-renders the page, but V8 can cap the optimisation
tier instead of removing codegen. --max-opt is rejected by NODE_OPTIONS and accepted on
the command line, so it was wired behind HOMARR_MAX_OPT and A/B'd on one image:

  peak anon      276.0 -> 282.8 MiB  (+2%)
  boot idle       64.0 ->  72.3 MiB  (+13%)
  board loaded   186.1 -> 179.2 MiB  (-4%)
  4 tabs         270.8 -> 238.5 MiB  (-12%)
  after soak     199.0 -> 214.3 MiB  (+8%)
  CPU             28.1 ->  27.8 s    (unchanged)

Null result. Render integrity was verified explicitly this time - listeners 7,784 in all
four runs, DOM nodes within 40 - so unlike jitless the page rendered fully and the
comparison is valid.

The mechanism explains it: measured under --max-opt=2, peak_malloced_memory at board load
is 42.1 MiB, identical to Turbofan. Maglev's zones are just as large. Capping the tier does
not reduce zone memory; only not compiling does, and that breaks rendering.

So this 38 MiB is not reachable through V8 configuration. It scales with how much
JavaScript V8 must compile, which returns it to the single remaining server-side lever:
ship less server code. Reverted.
2026-08-10 18:43:31 +02:00
Thomas Camlong dc46057544 docs(findings): definitive server-RAM A/B, and jemalloc rejected with evidence
Two things this settles.

First, the end-to-end number. Every Phase 2 result so far was an incremental A/B against
the build immediately before it, which cannot be summed. Rebuilt the merge-base
(06724cfeb) from a clean worktree and ran 2+2 alternated against HEAD, reporting cgroup
anonymous memory - the figure that counts against a container limit and cannot be
reclaimed:

  stage              before     after
  peak anon        376.6 ->   279.3 MiB  (-26%)
  boot idle        130.5 ->    62.4 MiB  (-52%)
  after sign-in    247.4 ->   142.9 MiB  (-42%)
  dashboard        293.4 ->   170.4 MiB  (-42%)
  4 tabs           366.5 ->   258.2 MiB  (-30%)
  after soak       323.7 ->   200.8 MiB  (-38%)
  peak node RSS    430.7 ->   336.2 MiB  (-22%)

No before/after range overlaps at any stage. Costs: CPU 23.5 -> 27.7s (+18%, solid,
non-overlapping). Median board load reads +30% but is NOT established - individual loads
were [506,383,1233] and [848,431,574] before against [780,808,515] and [621,579,1004]
after, heavily overlapping with no cold-then-warm pattern, so six samples per side cannot
separate a real regression from the variance.

compare-stress-runs.mjs now leads with anonMiB, which the harness recorded all along but
the report was ignoring in favour of memory.current - a figure that includes page cache
and swings 60+ MiB between runs of a byte-identical image.

Second, jemalloc. The largest attributed-but-unfixed item is ~38 MiB of V8 zone memory
that musl frees to its own pools and never returns to the kernel, so an allocator that
decays spans back to the OS looked like the answer. Tested on the byte-identical image
with only HOMARR_MALLOC differing, asserting the "Using jemalloc" log line:

  boot idle    62.4 ->  80.3 MiB  (+29%)
  after soak  215.5 -> 279.6 MiB  (+30%)
  4 tabs      259.9 -> 319.6 MiB  (+23%)
  CPU          28.0 ->  24.5 s    (-13%)

The hypothesis was backwards. jemalloc keeps more freed memory on purpose - thread caches
plus time-based dirty-page decay - and spends it on speed. 13% cheaper CPU, 18-30% more
resident memory. Reverted, and it closes off "swap the allocator" as a RAM direction:
musl is already the frugal choice here.
2026-08-10 18:25:00 +02:00
Thomas Camlong f5a9933dd8 docs(findings): the Beszel coalescing fix measured zero, and was reverted
Second correction in the same area. Having found that three Beszel widgets account for
82% of idle DOM churn, the obvious fix was to batch their subscription updates instead
of calling setState per record. Implemented, built, measured:

  DOM mutations / 45s   733 -> 748
  React commits / 45s    55 ->  55

Identical. Beszel emits roughly one record per second and the flush interval was also
one second, so each flush batched exactly one record. Coalescing at the arrival rate
does nothing - arithmetic that should have come before the implementation.

That also exposes a unit error in the previous commit's numbers: DOM mutations are not
re-renders. The real rates are 1.22 React commits/sec for the whole board and 13.3 DOM
mutations per commit, because a chart redraw touches many nodes. The stats widget's
"10.4 mutations/sec" is ~0.8 renders/sec - three live-monitoring widgets each refreshing
about once a second, which is what they exist to do.

So 73 commits/min is not pathological, it is the feature working, and there is no defect
to fix here. Reverted rather than shipped: keeping speculative complexity that measured
zero - an extra timer plus unmount and system-change teardown - is the trade this work
rejects everywhere else.

What survives is narrower: idle churn is widget-driven rather than app-shell driven, and
legitimate. The 650 MB browser heap therefore comes from interaction and multi-tab use,
not idle churn, and remains unexplained.
2026-08-10 17:52:23 +02:00
Thomas Camlong 9f1a15b9bf docs(findings): the App Router churn claim was wrong; idle churn is Beszel
Corrects the largest wrong claim in this work. Earlier rounds reported that ~80% of
idle React commits entered at Next's internal HeadManagerContext and that "the App
Router root re-renders about once a second", and named it the highest-value next step.

Both are false. Measuring the same idle churn against boards of different sizes:

  board      items   idle commits/min
  hey            0                0.0
  footbol        3                0.0
  default       28               76.0

A 3-item board commits zero times per minute. If the router re-rendered on a timer,
every board would churn regardless of its contents.

The original attribution was a tooling artefact. measure-rerenders.mts decides a fiber
re-rendered from fiber.actualDuration, which only a React *profiling* build populates.
On a production build every fiber fails that test and both attribution tables come back
empty - and an empty table reads as "nothing re-rendered" rather than "this build cannot
tell you". The harness now says which case it is, and waits for the board container to be
attached rather than visible so a zero-item board can be measured at all - that control
is what disproved the claim.

Adds measure-widget-churn.mts, which attributes churn on a production build via DOM
mutations keyed to the nearest [data-type="item"] ancestor. On the 28-item board, idle
for 45s:

  470  beszelSystemStats      64%
   77  beszelSystemTable      11%
   52  beszelSystemGrid        7%
   22  dockerContainers        3%
   93  (outside any widget)   13%

Three Beszel widgets are 82% of all idle churn, one of them 64% on its own at 10.4
mutations per second.

Also logs what Next.js itself costs, since it was assumed to be the problem: its loaded
source is a flat 3.47 MiB (364 framework files + 32 vendored) and does not grow at all
between boot and a fully loaded multi-tab session, while Homarr's own chunks grow from
3.73 to 15.44 MiB. Next is 16.1 MiB on disk; the largest things it vendors are its
next-server runtime (1.60), @edge-runtime (0.85), edge-runtime (0.51) and compression
(0.30). The framework is not where this application's memory goes.
2026-08-10 17:45:12 +02:00
Thomas Camlong ab5b8dad43 docs(findings): attribute the native bucket at peak, and record two disproven theories
Closes the last unexplained part of the peak footprint. The 118.8 MiB 'other
anonymous' smaps bucket is now mostly accounted for: V8 large-object space 34.5,
external buffers 24.6, and ~38 MiB of V8 parser/compiler zone memory that was freed
back to malloc but never returned to the kernel by musl - peak_malloced_memory goes
4.6 -> 42.2 MiB at board load while current malloced_memory stays at 1.0.

Two theories tested and rejected rather than asserted:

  - 'Fewer loaded files means less compiler zone memory, so shipping less server JS
    pays twice.' Wrong. peak_malloced_memory is identical (42.1-42.5) across the
    preloadEntriesOnStart A/B despite 310 fewer files. The spike is a fixed cost of
    the board route, not a per-file one. Claim withdrawn.
  - Orphaned zlib streams. Next documents this leak class and the fix is absent from
    this build, and a peak snapshot showed 15 Gzip objects still present at idle -
    which read as accumulation. getActiveResourcesInfo() reports Zlib=0 both at
    baseline and after 300 deliberately aborted gzip requests, RSS flat. Snapshot
    objects are not live native handles, and nginx buffers responses so Next never
    sees the mid-response disconnect that orphans a stream.

Adds a /handles endpoint to the probe, which is what settled the second one.
2026-08-10 09:37:10 +02:00
Thomas Camlong e687c1dd22 docs(findings): full peak breakdown, the preloading correction, and jitless rejected
Records the Phase 2 peak work:

  - Corrects the earlier claim that Next preloading all 74 route graphs is
    'framework behaviour, not something this codebase sets'. Next documents
    experimental.preloadEntriesOnStart for exactly this, found by reading its
    memory-usage guide rather than by inference. Includes the deterministic
    per-stage table: 310 files and 6.3 MiB of source stay unloaded even after a
    full multi-tab session, so the cost is not deferred, it is never paid.
  - Rejects --jitless with evidence. Code space really does go 11.6 -> 0.0 MiB and
    peak RSS looked 24% better, but the flag was applied to the server while the
    browser's listener count fell 80% and board load got faster, which is
    impossible. The page under-rendered; the win was an app doing less work. The
    tell generalises: a server-side change moving a client-side metric means the
    workload changed and the comparison is void.
  - Notes that --optimize-for-size, --no-opt and --lite-mode are rejected outright
    by Node in NODE_OPTIONS, and that the rest of Next's memory guide covers
    build-time memory under Webpack, which does not apply to this Turbopack build.
2026-08-10 09:18:55 +02:00
Thomas Camlong bcea4733b9 perf(nextjs): stop preloading every route's module graph at startup
Next loads the module graph for all 74 app routes when the server starts. Measured
here: 1,114 files and 24 MiB of source resident before the first request, including
one shared 16 MiB subtree behind the API routes that reaches every integration
client. A dashboard serves a handful of routes per session.

`experimental.preloadEntriesOnStart: false` is Next's documented switch for this.
An earlier round of this work recorded route preloading as "framework behaviour, not
something this codebase sets" - that was wrong, and the findings doc is corrected.

Deterministic per-stage measurements, identical across both runs on each side:

  stage            source loaded      files        heapUsed
  boot idle       24.0 -> 10.6 MiB   1114 -> 706   70.7 -> 44.8  (-37%)
  post-login      27.9 -> 19.3 MiB   1215 -> 884   123.8 -> 109.8
  board loaded    29.0 -> 21.7 MiB   1323 -> 1006  (run-to-run noise)
  multi-tab       29.2 -> 22.9 MiB   1325 -> 1015  139.7 -> 127.8
  post-soak       29.2 -> 22.9 MiB   1325 -> 1015  142.5 -> 127.4

The important part is the last two rows: 310 files and 6.3 MiB of source stay
unloaded even after a full multi-tab session, so this is not merely deferred cost -
routes the session never visits are never paid for at all. Live heap is lower at
every stage, by 26 MiB at idle and 12-15 MiB under load and at rest.

Peak node RSS does not move (350.3 -> 346.1, spreads overlap). V8 sizes its heap
against available headroom, so a smaller live set does not shrink peak RSS; what
improves is idle footprint and how much room is left before the heap cap. Median
board load is +3% with overlapping spreads, so the first-hit cost this trades for
did not show at this workload.

Full E2E suite passes 8/8 against the built image.
2026-08-10 09:07:16 +02:00
Thomas Camlong 8594cc884f perf(docker): stop loading the gRPC stack dockerode only needs for image builds
dockerode/lib/docker.js required './session' at module scope, which pulls in
@grpc/grpc-js and @grpc/proto-loader - 112 files. Its single call site is inside
Docker.prototype.buildImage, the BuildKit path. Homarr lists, starts and stops
containers and never builds images, so every install with a Docker widget paid for
a gRPC stack it cannot reach.

Measured with a forced GC, in context rather than in a bare process: requiring
dockerode goes from +26.6 MiB RSS / 5.2 MiB heap to +20.6 MiB / 2.7 MiB, with grpc
and protobufjs files going 63 and 49 to zero.

The bare-process figure was +24.1 MiB, and quoting that would have overstated the
win by 4x - most of it overlaps with what dockerode loads anyway. The patch comment
records the in-context number.
2026-08-10 08:49:02 +02:00
Thomas Camlong 1de51a9afd docs(findings): record the DB-driver chop as measured, attempted, and backed out
mysql2 (77 files) and pg (13 files) load on every SQLite install because
db/drivers/index.ts imports all three wrappers statically. Re-measured with a
forced GC between readings, the prize is 21.4 MiB RSS - not the ~30 MiB the naive
sum implied, and drizzle-orm's 124.3 MiB in the require-cost sweep was almost
entirely that same no-GC artefact.

Three attempts, each caught by booting the real image rather than by typecheck:
createRequire(import.meta.url) is undefined in the CommonJS migrate.cjs bundle and
broke startup for every dialect; a dynamic require resolves to a .cjs build Next
never traces; and leaving drizzle's dialect static defeats the purpose because
drizzle-orm/mysql2 itself imports mysql2.

The two remaining routes are worse than the problem - a second copy of drizzle's
core alongside the ESM one, or hardcoding drizzle's internal file layout so an
upgrade breaks startup for MySQL and Postgres. Backed out; the structural blocker
is that createDb must stay synchronous.
2026-08-10 08:43:45 +02:00
Thomas Camlong 4a7da0bdab fix(benchmarks): split the allocation-site key explicitly instead of with replace
CodeQL flagged `key.replace("|", ...)` as js/incomplete-sanitization (high): it
replaces only the first occurrence. Here that was intentional - the key is
`functionName|url:line` and only the first separator matters - but written as a
replace it reads as a broken sanitiser, and it would mangle any URL containing a
pipe. Splitting on indexOf says what it means. Path trimming now uses replaceAll,
which is what was meant there too.
2026-08-09 22:24:46 +02:00
Thomas Camlong 3cc6ff33df perf(docker): stop loading ssh2, which allocates a 16 MiB WASM heap nobody uses
Profiling at the memory high-water mark - not at idle, which is what every earlier
round measured - found the largest single retainer in every snapshot was one V8
Context holding a 16.00 MiB ArrayBuffer. Expanding it through the dominator tree
named it: ssh2's crypto module, whose POLY1305_WASM_MODULE instantiates a
WebAssembly memory for ChaCha20-Poly1305 as soon as it is required.

Homarr has no SSH feature and does not depend on ssh2. The chain is
dockerode -> docker-modem -> ssh2, and docker-modem/lib/modem.js requires './ssh'
at module scope while using it in exactly one place, already guarded by
`protocol === 'ssh'`. A pnpm patch moves the require inside that branch. SSH-based
Docker hosts still work - require() is cached, so they pay it once - and every
socket-based install, which is everyone else, never loads it.

Measured over four alternated runs on a restored real backup, external memory:

                     before        after
  board loaded    25.4, 25.1    8.3, 11.2
  nav-01          28.5, 25.4   10.1,  8.6
  multi-tab       31.1, 35.8   13.0, 10.0
  post-soak       21.4, 21.5    5.5,  7.2

Consistently -15 to -18 MiB, ranges non-overlapping, and 30 fewer files loaded
(1470 -> 1440).

Peak RSS does NOT improve, and that is worth stating plainly: V8 sizes its heap
against available headroom, so freeing native memory lets it grow further before
collecting. What changes is the composition - a fixed 16 MiB of unusable WASM
buffer becomes headroom the app can actually use, which matters under a container
memory limit but does not move the peak number.

Also adds the instruments this needed. A heap snapshot can never explain a peak,
because V8 runs a full GC before serialising one and reports only survivors:

  - memory-probe.cjs gains in-process allocation sampling, on-demand snapshots via
    v8.writeHeapSnapshot, an smaps endpoint, and require-graph recording.
  - analyze-allocations.mjs diffs two stages to isolate what one stage allocated.
  - analyze-smaps-stages.mjs splits each stage's RSS into JS heap, JIT, native and
    file-backed pages.
  - analyze-heap-dominators.mjs can now expand the largest retainer, which is what
    identified ssh2. A plain graph walk escapes through __proto__ and is useless.

One trap recorded in the findings: taking the peak snapshot over CDP delivers it as
JS string chunks and added ~134 MiB of ArrayBuffers to the process being measured,
producing a fake 693 MiB reading. v8.writeHeapSnapshot streams from C++ instead, and
the probe now reports its own observerCostMiB so the cost of looking is visible.
2026-08-09 22:17:25 +02:00
Thomas Camlong 3bc25509cf docs(findings): record what the real Umami instance revealed about the page ceiling
The live site has >100k events for one event name, so the paged path logs that its
counts are a lower bound. The previous code had the identical 100k ceiling - the
46.3 MiB body was that ceiling being hit - but truncated in silence. The ceiling is
unchanged; it is now visible.
2026-08-09 21:42:13 +02:00
Thomas Camlong 4766653979 chore(benchmarks): record the server require graph, and use it to attribute boot-time code
A flat module cache says 24 MiB of source is resident before the first request, but not
what pulled any of it in. Recording parent -> child as the CommonJS loader resolves it
answers that, and the answer redirects the remaining 'ship less server JS' work:

  - Next loads all 74 app route bundles at startup, not lazily.
  - One 16.10 MiB / 460-file subtree is reached by the tRPC/API routes AND by
    instrumentation, so instrumentation's exclusive cost is 0 bytes - deferring it would
    free nothing, because Next preloads /api/[...trpc] anyway.
  - Only 1.10 MiB is common to every route, so this is the API surface pulling in every
    integration, not a universal barrel.
2026-08-09 21:39:28 +02:00
ajnart 3c56eab58f style: auto-fix lint and format 2026-08-09 19:36:22 +00:00
Thomas Camlong cb3a2a360e perf(umami): stop buffering 100k raw events per widget refresh
Profiling the container end to end found one allocation site responsible for
most of the peak: a single 46.3 MiB JSON body, assembled from 3,946 socket
chunks, inside undici's fullyReadBody. That function holds the chunk list and
the joined copy at the same instant, so a body of size N costs about 2N.

The source was two Umami calls asking for pageSize=100000, with
getMultiEventTimeSeriesAsync running them for every tracked event name
concurrently via Promise.all. getEventNamesAsync was downloading every raw
event of the last 30 days purely to collect a handful of distinct name strings.

  - getEventNamesAsync now reads the aggregated /metrics?type=event endpoint.
  - getWebsiteEventTimeSeriesAsync pages 5,000 records at a time, folding each
    page into the bucket map before requesting the next.
  - getMultiEventTimeSeriesAsync runs series sequentially.

Instances that return aggregated {x,y} buckets still answer in one request, an
instance that accepts pageSize but ignores page is detected rather than
double-counted, and hitting the page ceiling logs instead of silently
undercounting.

Alternated runs, 2 per side, on a restored real backup:

  peak container       498.9 -> 398.5 MiB  (-20%)
  peak node RSS        452.4 -> 353.3 MiB  (-22%)
  peak during 4 tabs   498.5 -> 356.3 MiB  (-29%)
  peak arrayBuffers     95.8 ->   6.6 MiB  (-93%)
  largest allocation    46.4 ->   2.4 MiB  (-95%)

Costs +4% CPU from the extra round trips; board load latency unchanged.
Post-soak steady state and boot idle are unchanged within run-to-run spread.

Adds the attribution tooling this needed: attribute-memory.mts reconciles
cgroup -> processes -> address-space mappings -> V8 spaces -> loaded code,
memory-probe.cjs is an injectable diagnostics preload (never shipped) that
tracks buffer and fetch allocation sites, analyze-heap-dominators.mjs computes
real retained sizes, and compare-stress-runs.mjs reports alternated runs with
their spread.

Also corrects an earlier claim in PHASE2-FINDINGS.md: the reported "7.86 MiB of
duplicate chunk sources" was an artefact of V8 truncating string contents to
~1 KiB in heap snapshots. There are zero byte-identical duplicates among all
3,055 server JS files. The real doubling is character width - one codepoint
above U+00FF forces a whole source string to two bytes per character, which
costs 9.45 MiB across 80 loaded files.

Adds 6 tests; this integration had none.
2026-08-09 21:35:00 +02:00
Thomas Camlong f707f64333 perf: externalise @ioredis/commands, and identify what actually holds the memory
Tested the hypothesis that cached base64 images were bloating TanStack Query and
Redis. They are not, measured on a loaded 4-tab session:

  base64 data-URLs, server heap   0.01 MiB (2 strings)
  base64 blobs, browser heap      0.01 MiB (7 strings)
  Redis total                     2.17 MB
  image bytes in Redis            none - image-proxy stores URL + headers only

What the server heap is actually made of (141 MiB used): 41.4 MiB is bundled
JavaScript source retained as strings, ~29% of the heap. The memory is the code, not
cached data — which is why every cache-tuning idea in the fix catalog measured at
zero, and why serverExternalPackages was the only lever that moved idle memory.

7.86 MiB of that is literal duplicate copies: the same module inlined into several
Turbopack chunks and retained once per chunk. @ioredis/commands' command table was in
10 chunks and resident 6 times (2.07 MiB wasted), so it is now external — ioredis is
already external and owns it as a direct dependency, so it stays traced.

Recorded as bundle hygiene, NOT a memory win: idle 89.7 -> 89.4 MiB is inside noise,
and board-loaded/peak deltas were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169-173 for identical code).

got and @octokit/request-error duplicate just as badly but were deliberately left
bundled: they are only transitive dependencies, and a check inside the image showed
got is not traced into node_modules, so externalising it would have turned a bundled
module into a require that resolves to nothing — a possible MODULE_NOT_FOUND for zero
measured gain.

analyze-heap-strings.mjs buckets a snapshot's strings by what they are, which is what
made "37 MiB of strings" actionable instead of just alarming.

Verified: 28/28 widgets render, auth control passes, zero module-not-found in the
container log, typecheck clean. pnpm test 1794/1795 with two different Testcontainers
flakes across runs (pi-hole, sabnzbd); both pass 18/18 in isolation.
2026-08-09 16:07:26 +02:00
Thomas Camlong 933a54ead9 docs(perf): consolidated before/after, plus what is still unverified
All previous numbers were spread across rounds with different instruments and
different workloads. This is one instrument, one workload, alternated, 2 runs per
side, merge-base vs HEAD:

  server idle        141.9 -> 89.7 MiB   (-37%)
  server loaded      257.3 -> 173.0 MiB  (-33%)
  server peak        705.2 -> 568.4 MiB  (-19%)
  after idle soak    332.0 -> 232.5 MiB  (-30%)
  JS event listeners 10,126 -> 7,774     (-23%)
  React fibers       5,224 -> 3,588      (-31%)
  DOM nodes          9,175 -> 8,284      (-10%)
  browser JS heap    55.2 -> 51.1 MiB    (-7%)
  image              410 -> 398 MB       (-3%)
  container CPU      22.8 -> 22.8 s      unchanged

Recorded prominently rather than buried: under *sustained* 4-tab load the two builds
converge, 368 vs 364 MiB. The wins are idle, steady state and peak. That is why
hammering the app with five tabs did not feel 30% lighter — at saturation the
footprint is live widget data and render churn, which this PR does not change.

Also documents the gaps, since "tested" without them would be misleading: no
multi-hour soak (the #5301 reports are about days), single user, SQLite only, the
DevTools Profiler tab confirmed at bundle level rather than in the extension, and
the App Router re-rendering ~1x/second still unfixed.

verify-context-menu.mts now also covers touch long-press, and no longer asserts on a
fiber total — that counter swung 7,454 to 16,149 between runs of the same build, so
it was noise. probe-fiber-tree.mts is the stable counter and is what the -31% above
comes from.
2026-08-09 15:39:41 +02:00
Thomas Camlong 63e65bbc6c perf(common): stop useTimeAgo re-rendering every second, add re-render profiler
measure-rerenders.mts counts React commits on an untouched board and attributes each
to the shallowest component that re-rendered, i.e. where the update entered the tree.

What it found: an idle board commits 73-96 times per minute, re-rendering
@mantine/core/Box over 30,000 times in 60s. That is the churn behind the 650 MB
browser peak — transient allocation rather than retention. About 80% of those commits
enter at the same anonymous depth-2 provider, props [value, children], value {appDir}:
Next.js's internal HeadManagerContext, whose value is a fresh object literal on every
App Router render, so each render re-renders the whole app beneath it. The rest enter
at __next_metadata_boundary__ / <title>, pointing the same way.

The trigger is inside Next's router/metadata handling — application code has no
polling router.refresh() — so nothing here fixes it. It is the next thing to chase and
it dominates everything downstream of it.

useTimeAgo called setState on a 1s interval even when the label was unchanged ("3
minutes ago" holds for a full minute), re-rendering its subtree ~60x/minute per
consumer for an identical string. It now compares before setting. Recorded as a
correctness fix and NOT a measured win: idle commits stayed inside their 73-96 range
afterwards, because the app-root churn dominates.

pnpm test 1794/1795 (pre-existing pi-hole Testcontainers flake); typecheck clean
across 37 packages; no new lint errors.
2026-08-09 15:18:38 +02:00
ajnart 9bebc28102 style: auto-fix lint and format 2026-08-09 11:40:56 +00:00
Thomas Camlong 605431bc3f fix(perf): actually produce a React profiling build
DevTools still reported "Profiling not supported" because the build was never a
profiling one, despite three settings appearing to enable it. Next applies the React
swap through a webpack alias — getReactProfilingInProduction, mapping
'react-dom/client$' to 'react-dom/profiling' — that Turbopack never sees. A Turbopack
resolveAlias does not catch it either: the vendored entry requires
./cjs/react-dom-client.production.js relatively, not through a bare specifier.

The Dockerfile now performs that same swap on the vendored entry when
HOMARR_PROFILING=true, guarded so it fails the build loudly if React's file layout
changes rather than silently producing a normal build again.

Added `pnpm docker:profiling`, which builds and then verifies, because
`docker build . -t homarr:performance` alone yields a non-profiling image and that is
almost certainly what was being run.

verify-react-profiling.mts checks the bundle rather than the runtime: 2 client chunks
carry React's profiling renderer with the flag, 0 without. It replaces a Playwright
probe that could not tell the two apart — React sends a reduced inject payload to a
synthetic devtools hook, byte-identical for both builds, so that probe reported a
false negative for profiling and production alike. The bundle marker discriminates;
confirm the Profiler tab itself with the real extension.
2026-08-09 13:39:43 +02:00
ajnart 1d09172887 style: auto-fix lint and format 2026-08-09 09:06:26 +00:00
Thomas Camlong b4be884dfc perf(board): one shared context menu instead of one per widget
Mantine's Menu mounts Popover + PopoverDropdown + several context providers even
while closed, so a menu per widget was ~13 fibers each for something almost never
opened. WidgetContextMenuProvider now mounts a single menu for the board, anchored
to the cursor; items keep only a contextmenu/long-press listener.

The dropdown takes the section's gridstack ref as a prop rather than reading
SectionContext — the shared menu lives outside any section, and reading it from
context there crashed the board with "useSectionContext must be used within a
SectionContext".

Verified against both builds with scripts/benchmarks/verify-context-menu.mts
(opens on right-click, shows actions, closes on Escape, re-targets to a second
widget):

  idle fibers          18,670 -> 16,278  (-12.8%)
  fibers after use     +4,191 -> +4
  JS event listeners   10,128 -> 7,774   (-23%)
  DOM nodes             9,189 -> 8,287   (-10%)
  browser JS heap       54.1  -> 52.7 MiB
  median board load      520  -> 479 ms
  container CPU         20.4  -> 20.0 s

Two runs per side, four tabs across two boards. Server memory is unchanged, as
expected for a client-only change. The listener reduction is the point: the 650 MB
session had ~88k listeners and this removes a per-widget source of them.

The gate is validated by passing against unmodified code first. An earlier version
used Playwright's click({ button: "right" }), which emits no contextmenu event
here and so failed shipped code identically — it wrongly condemned a previous
attempt at this optimisation. A gate never run against the un-optimised build
proves only that something changed, not that it still works.

pnpm test 1794/1795 (pre-existing pi-hole Testcontainers flake, 9/9 in isolation);
typecheck clean across 37 packages; 0 lint errors.
2026-08-09 11:05:03 +02:00
Thomas Camlong fd7bcb2231 test(perf): fiber-tree probe, and the client-side diagnosis it produced
probe-fiber-tree.mts walks the live React tree and reports which components are
mounted, what DOM they render and their props. Heap snapshots only give minified
names like `y`, and source maps do not help because the snapshot records runtime
names — props shape plus rendered DOM identifies a component reliably. It injects
a DevTools hook stub before load so React registers every root; climbing from one
DOM node's fiber missed most of the tree (2,093 fibers vs 3,918).

What it found, on a 28-widget board:

- The most-mounted component is @mantine/core/Box (513). It is the primitive every
  Mantine component renders through, so it is a symptom of how much is mounted
  rather than a bug. Proportions match the 650 MB session snapshot (13% vs 17%),
  so the minified `y` there is almost certainly Box.
- 71 Menu -> Popover -> PopoverDropdown triplets are mounted for context menus that
  are almost never opened. Isolated with the existing enableRightClickOnWidgets
  flag: 3,918 fibers with them, 3,554 without — 364 fibers, 9%, scaling with item
  count. The dropdown contents are already lazy; the wrappers are not.

A fix that deferred the Menu until first right-click was reverted: it broke
right-click and inflated the tree to 20,107 fibers, because the replayed
contextmenu event re-entered the arming handler. Doing it properly needs one
board-level menu instead of one per item. The Playwright check that caught this is
in the findings — a fiber count alone would have read as a win.

Also records that the demo account had completed_board_tour = 0, so earlier rounds
measured the first-time-user path; completing it moves the tree by 38 fibers, so
the published numbers stand.
2026-08-09 10:37:33 +02:00
Thomas Camlong 8f18b17207 test(perf): profiling build, container introspection, client-side measurement
The container-only harness was measuring the wrong thing. Real usage — several
tabs, several boards, clicking into widgets — reaches ~880 MB server and a
650 MB *browser* heap, and the browser was never measured at all.

Profiling build: `docker build --build-arg HOMARR_PROFILING=true . -t
homarr:performance`. React's production bundle strips the hooks DevTools needs,
hence "Profiling not supported". Note reactProductionProfiling in next.config is
read only by the webpack path; under Turbopack it comes from `next build
--profile`, and turbo strips any env var not declared in the task's `env`, so all
three had to line up before the flag actually took effect — verified by markers
present only in React's profiling bundle, and static chunks going 20 -> 84 MB
with source maps.

inspect-container.mts attaches to an already-running container (SIGUSR1 opens
Node's inspector — no rebuild, no restart), reports cgroup/per-process/V8 memory
and can pull a heap snapshot out over CDP. The inspected context has no require
and no dynamic import, so it reads globals and uses CDP domains instead.

analyze-heapsnapshot / analyze-react-fibers / analyze-heap-retainers summarise a
snapshot too large for DevTools to open.

Harness now opens STRESS_BOARDS across STRESS_TABS_PER_BOARD tabs, clicks into
widgets, and records browser heap, DOM nodes and listener count via CDP.
Performance.getMetrics silently returns nothing unless the domain is enabled,
which is why the first run reported a 0 MiB client heap.
2026-08-09 08:21:57 +02:00
Thomas Camlong a532f9d377 docs(perf): repetition-backed headline, round-3 result, unpursued levers
Headline moves from single-sample -36% to repetition-backed -34% idle /
-35% loaded (2 before, 3 after, groups non-overlapping).

Round 3 (API-docs SPA lazy-loaded, MCP SDK external) is recorded as NOT a
memory win: every figure lands inside the previous build's spread, so it is
credited only with the deterministic -3 MB image.

Also records the measured cost of the levers not pursued: in-image Redis is
8.1 MiB RSS (the catalog guessed 10-40 MB) and nginx 9.2 MiB RSS / 3.1 PSS,
so neither justifies its deployment risk. next-server is 225.8 of ~236 MiB
at board-loaded, so anything further has to come out of that heap.
2026-08-09 03:21:21 +02:00
Thomas Camlong 61d9faffc5 perf: keep the API-docs SPA and MCP SDK out of the server graph
@scalar/api-reference-react is a documentation SPA reachable only from one
admin tab, and it renders nothing usable on the server, yet it was compiled
into the server graph for an SSR pass whose output is discarded. It is a
client component already, so next/dynamic with ssr: false is allowed in
place and needs no restructuring. @modelcontextprotocol/sdk is only reachable
through /api/mcp/[transport], so it moves to serverExternalPackages.

Honest accounting: this is an image-size and architecture change, not a
measured memory win. Idle landed at 89.4 MiB against 90.3/91.1/100.5 for the
previous build — the better end of that spread, but inside it, so on one
sample it cannot be claimed as a reduction. What is deterministic is image
size, 401 -> 398 MB. Kept because a docs SPA should not be server-rendered
regardless of what the memory number does.

pnpm typecheck and lint clean; pnpm test 1794/1795 with the known
Testcontainers pi-hole flake, which passes 9/9 in isolation.
2026-08-09 03:20:28 +02:00
Thomas Camlong 1965c75356 docs(perf): record round-2 findings and the auth proof
Adds the GC-tuning and wider-unbundling numbers, states the CPU and image-size
costs alongside the memory wins, and documents the three assertions that now
prove the measurement runs behind a real session rather than a public page.
2026-08-09 01:44:19 +02:00
Thomas Camlong 79ea987761 perf: bound V8's young generation and unbundle server-only deps
Two further wins, both measured on a restored 28-widget board behind a real
demo session (scripts/benchmarks/stress-restore.mts).

--max-semi-space-size=4 in run.sh. --max-old-space-size only bounds the old
generation; V8 also sizes the young generation generously (16 MB per
semi-space) and that slack is pure resident memory. 4 MB cut idle anonymous
memory 120.0 -> 97.2 MiB and peak 710.6 -> 560.2 MiB. It costs CPU, as any
GC-space reduction does — 15.8 -> 17.1 CPU-seconds over the run, ~8% — with
no board-load latency regression (median 594 -> 570 ms). Overridable via
HOMARR_MAX_SEMI_SPACE_SIZE.

serverExternalPackages for the remaining server-only dependencies. Bundling
a package inlines its transitive graph into a chunk that is compiled at boot
even when the code path never runs; leaving it external makes it a lazy
require. Adding winston, drizzle-orm, ical.js, jszip, ldapts, node-unifi,
@kubernetes/client-node and linkedom took idle to 90.3 MiB and board-loaded
209.6 -> 164.6 MiB, with slightly lower CPU (16.3s) and latency (549 ms).
The trade-off is image size: 388 -> 401 MB, because those packages now ship
in node_modules instead of being tree-shaken into chunks.

Cumulative vs the merge-base: idle 141.7 -> 90.3 MiB (-36%), board loaded
257.8 -> 164.6 MiB (-36%), under load 392.7 -> 302.5 MiB (-23%).

The harness now proves the session rather than inferring it: it asserts an
anonymous request to the private board is denied, requires the Auth.js
session cookie after sign-in, and reports whether it had to modify the
restored data at all (now "none"). It also records cgroup CPU seconds and
board-load latency, so a memory win bought with CPU cannot hide.

pnpm test 144/144 files, 1795 tests; typecheck and lint clean.
2026-08-09 01:43:06 +02:00
Thomas Camlong 9826000722 docs(perf): record post-trim verification
Removing the three zero-effect changes cost nothing: idle 120.0 MiB,
board loaded 229.5, after stress 357.0 — all within noise of the
untrimmed branch, with the lowest peak of any config measured.
2026-08-09 01:03:59 +02:00
Thomas Camlong b3f58cd8aa perf: drop the cache layers that measured at zero
Ablation (PHASE2-FINDINGS.md) attributed the memory win to the
serverExternalPackages line and the integrations barrel diet. These three
measured at exactly zero and were most of the file churn, so they go:

next-cache-handler: orphaned. cacheHandlers and cacheComponents are both
already gone from next.config.ts and nothing imported the package — it
was reachable only through a stale dependency entry.

cache-tags / cache-invalidation: there is no "use cache", cacheTag,
cacheLife or unstable_cache anywhere in app source, so revalidateTag was
firing on tags nothing registers, with no handler to receive them, inside
a catch that swallowed the resulting out-of-scope error. Removing the
calls also removed the SELECTs that existed only to supply a board name
or member list to them, so board.ts, group.ts, user.ts, serverSettings.ts,
section-router.ts and change-search-preferences.ts return to the
merge-base exactly. The live in-process integration data cache is
untouched.

Redis 6 -> 2 client consolidation: no measurable effect at idle (139.3 vs
139.2 MiB) or under load. Reverted, but the pubSub:last:* TTL is kept —
that one is an unbounded-growth fix, not a consolidation.

23 files, -883 lines. Diff vs merge-base: 100 files/3336 insertions ->
82 files/2484. pnpm test 144/144 files, 1795 tests, 0 failures;
typecheck and lint clean; app and Docker image build.
2026-08-09 00:59:41 +02:00
Thomas Camlong 84dcbd8847 docs(perf): attribute the Phase 1 memory win to specific changes
Measured on a populated instance (real backup restored through the
onboarding UI, 28-widget board open) rather than an empty container, and
compared on anonymous memory because memory.current swung 192-261 MiB
for the same image once page cache was counted.

Branch vs merge-base: -24% idle, -11% board loaded, -8% under load.

Cumulative ablation from the baseline image shows where that comes from:
adding mysql2/pg to serverExternalPackages — one line — accounts for the
entire idle win (-21 MiB), because the driver barrel statically imports
all three drivers and bundling inlined mysql2's whole graph into the
boot-loaded server chunks (108 MB of chunks vs 95 MB, and mysql2 absent
from node_modules because it had been inlined). The integrations barrel
diet carries the under-load win but moves idle by 0. Redis 6->2 clients
and the V8 heap cap both move idle by 0 — the cap is a drift ceiling,
not a reduction, since removing it entirely (cap4096) matched the branch
at every stage.

The cache-invalidation layer and next-cache-handler package measure at
exactly zero: no "use cache", cacheTag, cacheLife or unstable_cache
anywhere in app source, and cacheHandlers/cacheComponents are already
gone from next.config.ts, so 66 revalidateTag call sites across ~30
routers are no-ops. Keep/drop decision left open.
2026-08-09 00:44:50 +02:00
Thomas Camlong fb80b3882d test(benchmarks): compare on anon memory, retry pre-hydration login clicks
Two fixes found by running the harness against a real pair of images:

Login submitted before the form hydrated, so the click was a no-op that
looked identical to bad credentials. Load the page at networkidle and
retry the submit, surfacing any page alert if all attempts fail — the
same race already handled for the onboarding restore toggle.

memory.current counts reclaimable page cache, which moved the "same"
baseline between 192 and 261 MiB across runs. Anonymous memory is now
the headline metric (idle was 144 MiB anon vs 77 MiB of page cache), so
an A/B is not decided by unrelated disk I/O.
2026-08-09 00:44:50 +02:00
Thomas Camlong 6363d96a1b test(benchmarks): fix post-login wait, allow V8 flag injection
Sign-in redirects client-side, so page.waitForURL never reached its
implicit "load" state and every run failed after the restore step even
though authentication had succeeded. Poll window.location instead.

STRESS_NODE_OPTIONS injects NODE_OPTIONS directly, which is what makes
the V8 heap cap attributable: the merge-base run.sh does not read
HOMARR_MAX_OLD_SPACE_SIZE, so the cap can only be A/B'd against that
image by passing the flag in.
2026-08-09 00:44:50 +02:00
Thomas Camlong 0d19968131 test(benchmarks): add populated-dashboard memory stress benchmark
Container memory was only ever measured on an empty install, which hides
the cost that actually matters: a real board with real widgets and
integrations loaded.

stress-restore.mts boots a fresh container, restores a supplied backup
through the onboarding restore UI (including the "I understand"
confirmation), signs in, opens the busiest board, waits for every widget
to mount, then hammers navigation and soaks idle while sampling cgroup
v2 memory at each stage.

The backup is passed by path via STRESS_BACKUP_ZIP and never enters the
repo — it carries real integration secrets and an encryption key.
.gitignore now also refuses homarr-backup*.zip and loose *.sqlite.

Baseline reading (merge-base image, 28-item board, all widgets ready):
223 MiB idle -> 448 MiB board loaded -> 688 MiB peak.
2026-08-09 00:44:50 +02:00
ajnart 9187af8882 style: auto-fix lint and format 2026-08-08 11:02:04 +00:00
Thomas Camlong abec79abe8 fix: address CodeRabbit review — typed mocks, robust benchmark script
- Replace `as any` casts in integration-test-connection.spec.ts with
  Pick<Integration, "testConnectionAsync"> + mockResolvedValue
- build-compare.sh: split local declarations (SC2318), clean .next
  between builds with --force, add EXIT trap for safe stash/cleanup
2026-08-08 13:00:42 +02:00
Thomas Camlong b1713a9820 fix: revert Suspense layout wrappers causing E2E hydration errors
Revert layout.tsx, _layout-creator.tsx, manage/layout.tsx,
_theme.tsx, _creator.tsx, mantine.tsx, and login/page.tsx
to origin/dev — the Suspense provider splits caused
"EditMode is required", stream close, and React #419 errors.

Also remove cacheComponents/cacheHandlers from next.config.ts
(incompatible with auth/i18n/dynamic rendering) and fix
Boolean() truthy bug in Redis connection.
2026-08-08 12:40:48 +02:00
Thomas Camlong 3db87d781f fix: address CodeRabbit review issues
- redis-handler: add TLS_CA and USERNAME env support matching core client
- connection.ts: fix Boolean() truthy bug (CI="false" was disabling Redis)
- channel.ts: add in-memory lock for single-instance fallback mode
- channel-subscription-tracker: lazy-init subscriber client
- board.ts: invalidate user cache on board delete for home-board users
- .env.example: document REDIS_CACHE_DB vs DATABASE_INDEX collision risk
- CLI docs: fix docker exec command (remove /bin/bash prefix)
- Docker docs: fix docker rm -f and docker ps -a for stopped containers
2026-08-08 09:17:39 +02:00
Thomas Camlong 51467ded12 docs: remove obsolete X-Frame-Options ALLOW-FROM examples
ALLOW-FROM is not supported by any modern browser. The CSP
frame-ancestors directive is the correct replacement and was
already present in both examples.
2026-08-08 09:09:53 +02:00
Thomas Camlong 38fc9bfd01 chore: remove full-screen loading spinners from boards and manage 2026-08-08 09:09:19 +02:00
Thomas Camlong 9bc1fc397c chore: remove .cursor/goal files from git 2026-08-08 09:08:48 +02:00
Thomas Camlong 9596a83f60 fix: use any cast for mock integration in test-connection spec
The barrel no longer exports PiHoleIntegrationV6 after the dynamic
import refactor. The mock only needs testConnectionAsync, so a type
cast is sufficient.
2026-08-08 09:03:18 +02:00
Thomas Camlong e036cd86d8 perf: move dependencies to build-time static JSON, add build benchmark
- Move getDependenciesAsync filesystem scan to scripts/dump-dependencies.mts
  (runs at build time, outputs static-data/dependencies.json)
- About page now imports dependencies.json like other static data
  (eliminates runtime glob of all package.json files)
- Delete dead getDependenciesAsync + parseDependencyCatalogsAsync from
  package-reader.ts (only getPackageVersion remains)
- Add scripts/benchmarks/build-compare.sh for dev vs branch comparison
2026-08-08 08:58:54 +02:00
Thomas Camlong 6692acbd33 chore: strip formatting noise, delete dead routes, fix CodeRabbit issues
- Remove 179 format-only file changes (docs, migrations, plex, patchmon, CSS)
- Delete .cursor/goal files from git tracking
- Delete 4 dead /api/about routes (unused, data loaded from static JSON)
- Fix resolveAlias to use absolute paths (CodeRabbit)
- Fix Tailwind classes in Mantine app layout fallback (CodeRabbit)
- Return stale data during cache revalidation (CodeRabbit)
2026-08-08 08:45:16 +02:00
Thomas Camlong 95a7d56989 fix: pi-hole test import from barrel to direct path
The barrel no longer exports PiHoleIntegrationV5/V6 after the
dynamic integration import refactor.
2026-08-08 00:41:50 +02:00
Thomas Camlong 7bd5991076 ci: trigger CI run 2026-08-08 00:29:48 +02:00
Thomas Camlong 345a4be4b0 fix: CI build errors + simplify cache handler and data cache
- Remove `export const dynamic` from 4 routes (incompatible with cacheComponents)
- Static import for redis-handler (Turbopack can't resolve relative dynamic imports)
- Simplify redis-handler: Buffer.concat, NonNullable, single connect promise
- Inline single-use helpers in integration-data-cache
- Non-greedy SVG regex to prevent ReDoS
- Inline getDevelopmentServiceAliases in next.config
- Remove restating comments
2026-08-08 00:19:27 +02:00
Thomas Camlong 6f563640ac fix: remove as const from svgSanitizeOptions to fix DOMPurify type compat 2026-08-07 22:12:09 +02:00
Thomas Camlong 4270f2f2f1 fix: address CodeRabbit review — stream tee, cache guard, base64, layout fallback
- BoardLayoutFallback: use HomarrLogoWithTitle (no board context required)
- integration-data-cache: guard cache.set with placeholder identity to
  prevent stale write-back after invalidation + test
- next-cache-handler: memoize the promise (not resolved handler) to
  prevent concurrent Redis connections
- redis-handler: tee ReadableStream before consuming in set(), hand fresh
  branch to each get() caller
- redis-handler: base64-encode body instead of number[] (3-4x smaller)
2026-08-07 22:09:11 +02:00
ajnart 00855d4825 style: auto-fix lint and format 2026-08-07 19:44:19 +00:00
Thomas Camlong 39334569d6 fix: remove notifications from refetch-intervals to match widget definitions
The notifications widget has no refetchInterval in its definition,
so the static polling policy entry was causing a manifest test failure.
2026-08-07 21:42:08 +02:00
Thomas Camlong ffed73ba8f merge: incorporate latest dev changes into phase1-perf branch
Merge origin/dev to pick up service isolation (concurrently dev script,
turbopack aliases, instrumentation simplification), PostHog analytics,
color scheme, and other dev improvements while preserving all Phase 1
performance work (Suspense boundaries, cache components, Redis handler,
integration barrel diet, cache invalidation).
2026-08-07 21:36:48 +02:00
Thomas Camlong 90d9a50227 fix: correct Content-Type for content-detected SVG responses
When looksLikeSvg detects SVG content under a non-SVG MIME type,
set Content-Type to image/svg+xml so browsers render correctly
with X-Content-Type-Options: nosniff.
2026-08-07 21:25:07 +02:00
Thomas Camlong e836d07d4c fix: content-based SVG detection, guard tests, document REDIS_CACHE_DB
- Detect SVG by content (magic bytes) in addition to MIME type to prevent
  sanitization bypass via mislabeled uploads
- Add guard test verifying DOMPurify actually strips dirty payloads
- Add looksLikeSvg detection test
- Document REDIS_CACHE_DB in .env.example
2026-08-07 21:22:25 +02:00
Thomas Camlong 33293ba1cf refactor: simplify Phase 1 infrastructure code
- cache-invalidation: extract isExpectedRevalidateError helper
- integration-data-cache: flatten early-return chain, extract eviction
- redis-handler: extract StoredEntry type, readStreamBody, ensureConnected
- svg-purify: single getOwnPropertyDescriptor lookup per prop
- session-scoped-providers: simplify auth flow
- retryable-loader: use function declarations
- translation/request: replace spread-in-reduce with for loop
2026-08-07 21:04:42 +02:00
Thomas Camlong b3b2d5a022 fix: SVG regex prefers </svg> terminator, add self-closing children test
Use greedy <svg>...</svg> match preferring closing tag over inner />
to prevent truncation at first self-closing child element.
Self-closing root <svg .../> handled as separate alternative.
Added test with <path/> + <circle/> children asserting well-formed output.
2026-08-07 21:00:55 +02:00
Thomas Camlong 67ac2bbc7c fix: Redis expire guard, transferOwnership prev-owner, SVG regex
- Redis get() rejects legacy expire=0 entries and cleans them up
- transferOwnership invalidates previous owner in addition to new
- SVG regex uses non-greedy match and handles self-closing <svg />
- SVG fallback returns empty string instead of HTML wrapper
- Memory channel comment: FIFO eviction, not timestamp-based
2026-08-07 20:57:07 +02:00
Thomas Camlong fbb2eddd87 fix: concurrent stale race, Redis expire leak, missing invalidation, UI Suspense
- Fix integration data cache: set pending synchronously before creating
  fetch to prevent concurrent stale-revalidation from spawning duplicate
  upstream calls
- Redis cache handler: skip persisting entries with expire=0 (dynamic
  entries should not be stored in Redis)
- Add missing invalidation: user.changePassword, group.updateGroup,
  group.transferOwnership
- Move SearchEngineOptimization inside Suspense in root layout
- Add Suspense fallback loader to root layout
- Move searchParams resolution inside Suspense in login page
2026-08-07 20:53:36 +02:00
Thomas Camlong 38ac8f3075 fix: concurrent cache miss, SVG output, narrowed error handling, format
- Fix integration data cache: check pending promise before TTL to
  prevent returning undefined on concurrent cold-cache access
- Add concurrent-miss test (two parallel callers, one fetch)
- SVG sanitizer: extract <svg> from DOMPurify output to strip
  residual HTML wrapper
- Narrow safeRevalidateTag catch: log unexpected errors, silence only
  known "outside request scope" messages
- Run oxfmt across all packages (206 files formatted)
2026-08-07 20:48:17 +02:00
Thomas Camlong b81b90875f fix: revert DB driver to static imports, fix test regressions
- Revert createRequire() for DB drivers — require() can't resolve .ts
  files in Vitest, breaking 49 test suites
- Make cache invalidation fail-safe via try-catch around revalidateTag
  (throws outside Next.js context: tests, tasks service, websocket)
- Fix 6 integration test imports: barrel no longer exports concrete
  classes, point tests at direct source paths
- Fix Redis cache handler lint errors: remove non-null assertions,
  suppress __NEXT_BUILD_ID underscore warning
2026-08-07 20:43:07 +02:00
Thomas Camlong 67fcd69eb8 fix: inverted tag expiration in Redis handler + saveItemOptions board name
- Fix critical bug: tag expiration comparison was inverted (< should be >)
  in Redis cache handler, which would prevent revalidateTag from clearing
  stale entries
- Pass board name to invalidateBoardCache in saveItemOptions
- Add clarifying comment about two-tier invalidation helpers
2026-08-07 19:30:58 +02:00
Thomas Camlong aeabfd3521 fix: requestMedia dual-layer cache invalidation
Mirror answerRequest pattern: invalidate both module-level integration
data cache and request-handler caches for media request list/stats.
2026-08-07 19:27:41 +02:00
Thomas Camlong 37930e44cc perf: clean integration barrel — remove 46 concrete class re-exports
- Remove all concrete integration class re-exports from barrel
- Replace instanceof check with kind check in health-monitoring
- Document cache invalidation as preparatory (cacheTag follow-up)
2026-08-07 19:22:32 +02:00
Thomas Camlong 451dc3ce39 fix: address review #4 — board name invalidation, SVG tests, test gaps
- Pass board.name to all invalidateBoardCache calls (permission mutations)
- Align integration create invalidation with update/delete
- Add initial-fetch-failure test for integration-data-cache
- Expand SVG XSS test corpus (javascript:, xlink:href, @import, onbegin)
- Forbid <style> tags in SVG sanitizer to prevent CSS injection
- Fix duplicate export in packages/common/src/index.ts
2026-08-07 19:18:18 +02:00
Thomas Camlong 4361c8dbe2 fix: address Bugbot review #3 — mutation invalidation gaps
- Dual-layer cache invalidation on interact mutations
  (dns-hole, media-requests, smart-home, indexer-manager, anchor-notes)
- importOldmarrConfig: invalidate created boards
- createBoard: invalidateUserCache when auto-setting homeBoardId
- group.savePositions: invalidate user caches for affected members
- integration.requestMedia: invalidate integration data cache
- user.delete: invalidate user cache
2026-08-07 19:14:02 +02:00
Thomas Camlong 0f8362c3da fix: address Bugbot review #2 findings
- Invalidate integration data cache from widget interact mutations
  (downloads, dns-hole, media-requests, indexer-manager, smart-home, anchor-notes)
- Redis handler getExpiration: fetch missing tags from Redis (multi-instance coherence)
- saveItemOptions: invalidate board cache after saving
- Remove unused cacheTags.app
- Fix integration data cache comment accuracy
2026-08-07 19:08:57 +02:00
Thomas Camlong 6b45aca5d5 fix: address Bugbot review findings
- Skip module-level integration cache for multi-instance (REDIS_IS_EXTERNAL)
- Add invalidateBoardCache to permission mutations (prevents future leak)
- Remove unused cacheTags.integrationData
- Replace KEYS with SCAN in Redis cache handler refreshTags()
- Document REDIS_CACHE_DB env var
2026-08-07 19:04:53 +02:00
Thomas Camlong 018f98e318 perf: implement Phase 1 homepage diet via Next.js Cache Components
P1-0: V8 heap cap (HOMARR_MAX_OLD_SPACE_SIZE, default 512MB)
P1-1: Module graph cuts
  - Dynamic integration imports (55 static → lazy loaders)
  - Conditional DB driver loading (only configured driver loaded)
  - Replace jsdom with linkedom for SVG sanitization
  - Delete eager widget barrel (55 import * as → 0)
  - Extract createRetryableLoader to @homarr/common
P1-2: Enable cacheComponents + restructure root layout
  - Suspense boundaries around session-dependent providers
  - ColorSchemeScript replaces server-side cookie reads
  - Board/manage/auth layouts wrapped in Suspense
P1-3: Cache read paths
  - Cache tag registry + invalidation helpers
  - Cache invalidation wired into all mutation routers
  - Translation deep-merge memoized per locale
P1-4: Prerendered shells + loading.tsx files
P1-5: Shared integration data cache (60s TTL, stale-while-revalidate)
P1-6: Redis-backed Next.js cache handler (@homarr/next-cache-handler)
P1-7: Redis optional (memory channel fallback), TTL on pubSub:last:*,
      Redis clients consolidated from 6 to 2
2026-08-07 18:59:47 +02:00
Thomas Camlong 366ec6bdbc fix custom widget e2e login race 2026-08-03 15:52:08 +02:00
Thomas Camlong 9f4b147f0e fix legacy media server option mapping 2026-08-03 15:29:27 +02:00
Thomas Camlong 08af704c8a Merge origin/dev into feat/build-performance 2026-08-03 15:21:23 +02:00
Thomas Camlong 3f9fb215c6 fix: avoid ambiguous benchmark regex 2026-08-03 14:27:21 +02:00
Thomas Camlong de801f3f00 perf: optimize dashboard loading and builds 2026-08-02 15:58:16 +02:00
ajnart d90e762e4b fix: align Navidrome media sessions 2026-07-31 13:52:39 +02:00
ajnart 81a13d3b52 Merge remote-tracking branch 'origin/dev' into feat/build-performance 2026-07-31 13:38:16 +02:00
ajnart 388a74086e fix: address Copilot cache review 2026-07-31 13:32:48 +02:00
ajnart cd6c83a7b0 test: guard LDAP user lookup 2026-07-30 00:50:02 +02:00
ajnart 9963ba0274 fix: harden runtime isolation and regression tests 2026-07-30 00:38:34 +02:00
ajnart 5d9f05e083 test(e2e): open spotlight with hotkey 2026-07-29 07:53:35 +02:00
ajnart d48f299585 test(e2e): wait for authenticated board 2026-07-29 07:42:57 +02:00
ajnart c80e9878de fix: handle lazy startup failures 2026-07-29 07:23:09 +02:00
ajnart 84f87ce24e fix(ci): bound affected task concurrency 2026-07-29 07:11:26 +02:00
ajnart fbb57c8f40 fix(docker): build runtime workspaces only 2026-07-29 06:57:06 +02:00
ajnart 0b9c4d6375 fix: address performance review feedback 2026-07-29 06:50:13 +02:00
ajnart 7266e15356 style: auto-fix lint and format 2026-07-29 06:47:07 +02:00
ajnart c22c733463 build(docker): simplify manifest copy layers 2026-07-29 06:47:07 +02:00
ajnart 440626ba00 test(performance): add authenticated local benchmarks 2026-07-29 06:47:07 +02:00
ajnart f8d8a09c56 perf(proxy): isolate settings database reads 2026-07-29 06:47:07 +02:00
ajnart add6ee56ba fix(updates): enforce a persisted daily check 2026-07-29 06:47:07 +02:00
ajnart 6b74078766 perf(widgets): lazy load the application graph 2026-07-29 06:47:07 +02:00
ajnart 4ca0b747bb chore(next): upgrade preview and isolate dev services 2026-07-29 06:47:07 +02:00
148 changed files with 9698 additions and 1746 deletions

No files matched your search

+4
View File
@@ -55,6 +55,10 @@ TURBO_TELEMETRY_DISABLED=1
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
# DOCKER_SOCKET_PATHS=
# Redis DB index for the Next.js cache handler (default: 1).
# Must differ from REDIS_DATABASE_INDEX (default: 0) to avoid cache ops affecting app data.
# REDIS_CACHE_DB=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
+8
View File
@@ -79,3 +79,11 @@ apps/docs/.docusaurus
apps/docs/build
.pnpm-store
benchmark-results
.bench/
# Homarr backups used by scripts/benchmarks/stress-restore.mts. These contain
# real integration secrets and an encryption key — never commit one.
homarr-backup*.zip
*.sqlite
*.sqlite-wal
*.sqlite-shm
+19
View File
@@ -40,6 +40,25 @@ ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
# Opt-in React profiling build + browser source maps, for React DevTools' Profiler
# and for readable names in heap snapshots. See next.config.ts.
ARG HOMARR_PROFILING='false'
ENV HOMARR_PROFILING=${HOMARR_PROFILING}
# React ships a separate profiling renderer; the production one omits the hooks
# DevTools needs, which is what makes it report "Profiling not supported". Next only
# swaps it via a webpack alias (create-compiler-aliases.js), so under Turbopack both
# `next build --profile` and `reactProductionProfiling` silently produce a normal
# build. Point the vendored client entry at the profiling bundle instead — the same
# swap webpack's alias performs, done where Turbopack cannot miss it.
RUN if [ "$HOMARR_PROFILING" = "true" ]; then \
set -e; \
entry=node_modules/next/dist/compiled/react-dom/client.js; \
grep -q "react-dom-client.production.js" "$entry" || { echo "profiling patch: unexpected $entry"; exit 1; }; \
sed -i "s#./cjs/react-dom-client.production.js#./cjs/react-dom-profiling.profiling.js#" "$entry"; \
grep -q "react-dom-profiling.profiling.js" "$entry" || { echo "profiling patch failed"; exit 1; }; \
echo "profiling patch applied to $entry"; \
fi
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
+28
View File
@@ -0,0 +1,28 @@
# Performance findings
This file grew past 1,200 lines covering server memory, client rendering, methodology and a long
list of rejected ideas at once. It has been split by subject under [`docs/perf/`](./docs/perf/):
| document | subject |
| --- | --- |
| [docs/perf/README.md](./docs/perf/README.md) | Headline results and the change-by-change ratio table |
| [docs/perf/SERVER-MEMORY.md](./docs/perf/SERVER-MEMORY.md) | What the container's RAM is made of, layer by layer, and what reduced it |
| [docs/perf/CLIENT-PERFORMANCE.md](./docs/perf/CLIENT-PERFORMANCE.md) | Dashboard cost in Chrome: DOM, fibers, images, paint |
| [docs/perf/REJECTED.md](./docs/perf/REJECTED.md) | Ideas implemented, measured, and abandoned — with their numbers |
| [docs/perf/METHODOLOGY.md](./docs/perf/METHODOLOGY.md) | The tools, and the measurement traps that produced wrong answers |
## Result in one table
Server RAM, merge-base `06724cfeb` vs this branch, two alternated runs per side, cgroup **anonymous**
memory — the figure that counts against a container limit:
| stage | before | after | change |
| --- | --- | --- | --- |
| **peak** | 376.6 MiB | **279.3 MiB** | **−26%** |
| boot idle | 130.5 MiB | **62.4 MiB** | **−52%** |
| after sign-in | 247.4 MiB | **142.9 MiB** | **−42%** |
| dashboard loaded | 293.4 MiB | **170.4 MiB** | **−42%** |
| 4 tabs, 2 boards | 366.5 MiB | **258.2 MiB** | **−30%** |
| after 2 min idle soak | 323.7 MiB | **200.8 MiB** | **−38%** |
No before/after range overlaps at any stage. Cost: **+18% CPU**, solid and non-overlapping.
@@ -13,7 +13,7 @@ You should also create a backup of the database, in case you break something.
## Execute CLI with Docker
1. Execute ``docker ps | grep homarr`` on the root system with Docker to find the container ID of your Homarr container.
2. Run ``docker exec -it <container-id> /bin/bash homarr <your-command-goes-here>``. \
2. Run ``docker exec -it <container-id> homarr <your-command-goes-here>``. \
Replace ``<container-id>`` with your container ID from step 1. \
Replace ``<your-command-goes-here>`` with the command you want to execute (see sub-pages).
@@ -84,7 +84,7 @@ Docker will mount the configuration files and icons to your host machine. Please
### Updating
To update Homarr, you must remove your container first. Make sure that you've mounted your data and that you have access to it, so your configuration doesn't get lost.
1. Run ``docker rm homarr`` to remove the container.
1. Run ``docker rm -f homarr`` to stop and remove the container.
2. Pull the latest Homarr image ``docker pull ghcr.io/homarr-labs/homarr:latest``.
3. Re-run the command you used to install Homarr.
@@ -98,8 +98,8 @@ Want to update all your containers automatically? Checkout [Watchtower](https://
### Uninstalling
1. Obtain the container ID of your Homarr container using ``docker ps | grep homarr``.
2. Delete the container using ``docker rm <your-container-id>``
1. Obtain the container ID of your Homarr container using ``docker ps -a | grep homarr``.
2. Delete the container using ``docker rm -f <your-container-id>``
3. Prune unused Docker images using ``docker image prune`` (*Warning: this also removes you other unused images - not just Homarr*)
4. (Optional): Prune any network or volumes if you use any.
5. (Optional): Remove Homarr from your reverse proxy, VPNs or tunnels if you use any.
+1 -3
View File
@@ -45,20 +45,18 @@ Some proxies disallow iframes by default for this reason.
You can use https://iframetest.com to check, if the site is allowing iframes.
If you manage all of your own applications, you can usually change that by configuring
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
the appropriate headers (such as `Content-Security-Policy`) to allow iframes.
For example, to allow your site to be embedded in iframes from specific domains:
**Apache (.htaccess or httpd.conf):**
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
+6 -1
View File
@@ -26,5 +26,10 @@
"**/*.md",
"**/*.mdx",
],
"exclude": ["node_modules"],
// `include` sweeps up **/*.js and allowJs is on, so anything the build emits into this package is
// typechecked as source. The turbo `typecheck` task only dependsOn ["^topo"], not on its own
// package's `build`, so `docusaurus build` and `tsc --noEmit` run concurrently - and tsc can observe
// Docusaurus's transient `build/__server/` bundles and fail with TS1473 before the build removes
// them. Excluding generated output makes typecheck independent of that scheduling.
"exclude": ["node_modules", "build", ".docusaurus"],
}
+78 -15
View File
@@ -15,18 +15,27 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
});
const getDevelopmentServiceAliases = () => {
if (process.env.NODE_ENV !== "development") {
return undefined;
}
return {
"@homarr/tasks": "./src/instrumentation-noop.ts",
"@homarr/websocket": "./src/instrumentation-noop.ts",
};
};
/**
* Profiling build. React's production bundle strips the hooks React DevTools needs,
* which is why the Profiler tab reports "Profiling not supported"; the profiling
* variant keeps them. Source maps are emitted alongside so heap snapshots and flame
* charts show real component names instead of minified ones like `y`.
*
* Build with:
* docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance
*
* `reactProductionProfiling` in this config is only read by the webpack path — under
* Turbopack it comes from `next build --profile`, which apps/nextjs/package.json
* passes when HOMARR_PROFILING=true. Both are set so either bundler works.
*
* Off by default: the profiling variant is slower and source maps are large, so this
* must never be what ships to users.
*/
const isProfilingBuild = process.env.HOMARR_PROFILING === "true";
const nextConfig: NextConfig = {
reactProductionProfiling: isProfilingBuild,
productionBrowserSourceMaps: isProfilingBuild,
// Next previews otherwise create agent instruction files in the application
// directory during development.
agentRules: false,
@@ -41,19 +50,73 @@ const nextConfig: NextConfig = {
typescript: { ignoreBuildErrors: true },
/**
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*
* Everything else here is a server-only dependency that is cheaper left as a
* runtime require than inlined into the server bundle. Bundling pulls a package's
* whole transitive graph into a chunk that is loaded and compiled at boot even
* when the code path never runs — measurably so for the DB drivers: only
* better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of
* resident memory at idle.
*/
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
serverExternalPackages: [
"dockerode",
"better-sqlite3",
"mysql2",
"pg",
"winston",
"drizzle-orm",
"ical.js",
"jszip",
"ldapts",
"node-unifi",
"@kubernetes/client-node",
"linkedom",
// Only reachable through /api/mcp/[transport].
"@modelcontextprotocol/sdk",
// Bundling inlines a copy per chunk: a server heap snapshot found this package's
// command table in 10 chunks and resident 6 times over (2.1 MiB of duplication).
// Safe to externalise because ioredis is already external and owns this as a
// direct dependency, so it is always traced alongside it. Bundle hygiene — it did
// not move measured memory (idle 89.7 -> 89.4 MiB, inside run-to-run noise).
// got and @octokit/request-error duplicate similarly but are only transitive
// deps here, so externalising them risks a runtime require that was never traced,
// for no measured gain.
"@ioredis/commands",
],
experimental: {
/**
* Dead config under Turbopack, which this app builds with: "Turbopack automatically analyzes and
* optimizes imports without requiring this configuration."
*
* Measured rather than assumed - adding the seven Mantine entry points that are missing here
* (charts, dates, dropzone, form, notifications, spotlight, tiptap) produced a client bundle that
* was byte-for-byte identical, 482 files and 18050.7 KiB in both arms, down to the chunk hashes.
* Kept only as a fallback for a webpack build; do not add to it expecting an effect.
*/
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
/**
* Next otherwise loads the module graph for every route at startup. Measured here: 74 route
* bundles and 1,114 files, 24 MiB of source, resident before the first request — and one
* shared 16 MiB subtree behind the API routes that reaches every integration. A dashboard
* serves a handful of routes per session, so preloading the rest is pure cost.
*
* The trade is that the first request to a route pays its module load instead of the boot
* paying for all of them. That suits a long-lived self-hosted server, where boot happens
* once and routes warm up as they are used.
*/
preloadEntriesOnStart: false,
},
turbopack: {
root: path.resolve(import.meta.dirname, "../.."),
// Development runs tasks and WebSocket as separate processes. These aliases
// keep their production-only instrumentation imports out of the dev graph.
resolveAlias: getDevelopmentServiceAliases(),
resolveAlias:
process.env.NODE_ENV === "development"
? {
"@homarr/tasks": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
"@homarr/websocket": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
}
: {},
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
+3 -2
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build $([ \"$HOMARR_PROFILING\" = \"true\" ] && echo --profile)",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "oxfmt .",
@@ -76,13 +76,14 @@
"chroma-js": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"dompurify": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"jszip": "catalog:",
"linkedom": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
@@ -4,6 +4,7 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { WidgetContextMenuProvider } from "~/components/board/items/widget-context-menu-provider";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -19,15 +20,17 @@ export const ClientBoard = () => {
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
<WidgetContextMenuProvider>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</WidgetContextMenuProvider>
</Box>
);
};
@@ -40,8 +40,9 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import { getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import dependencies from "@static-data/dependencies.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
@@ -57,7 +58,6 @@ export async function generateMetadata() {
export default async function AboutPage() {
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
return (
<div>
@@ -159,34 +159,26 @@ export default async function AboutPage() {
</AccordionControl>
<AccordionPanel>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
component="a"
href={`https://www.npmjs.com/package/${name}`}
target="_blank"
>
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
{Object.entries(dependencies).map(([name, version]) => (
<UnstyledButton key={name} component="a" href={`https://www.npmjs.com/package/${name}`} target="_blank">
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
@@ -1,11 +1,18 @@
"use client";
import dynamic from "next/dynamic";
import { useComputedColorScheme } from "@mantine/core";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import "@scalar/api-reference-react/style.css";
import "./scalar-theme.css";
// The API reference is a whole documentation SPA reachable only from this one admin
// tab, and it renders nothing useful on the server. Loading it lazily keeps it out
// of the server graph instead of compiling it for an SSR pass that is discarded.
const ApiReferenceReact = dynamic(() => import("@scalar/api-reference-react").then((mod) => mod.ApiReferenceReact), {
ssr: false,
});
interface ScalarApiReferenceProps {
document: object;
}
@@ -11,6 +11,10 @@ interface Props {
params: Promise<{ kind: string }>;
}
export function generateStaticParams() {
return widgetKinds.map((kind) => ({ kind }));
}
export default async function WidgetPreview(props: Props) {
const { kind } = await props.params;
if (!widgetKinds.includes(kind as WidgetKind)) {
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import crowdinContributors from "@static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
};
export const dynamic = "force-static";
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import githubContributors from "@static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
};
export const dynamic = "force-static";
@@ -1,9 +0,0 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
};
export const dynamic = "force-static";
@@ -1,10 +0,0 @@
import { NextResponse } from "next/server";
import { getDependenciesAsync } from "../../../../versions/package-reader";
export const GET = async () => {
const dependencies = await getDependenciesAsync();
return NextResponse.json(dependencies);
};
export const dynamic = "force-static";
@@ -16,10 +16,13 @@ export const GET = async (_request: Request, props: { params: Promise<{ id: stri
});
}
return new Response(result.image, {
// undici and the DOM lib describe the same web stream with incompatible types, so the value is
// passed straight through.
return new Response(result.image as BodyInit | null, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
...(result.contentLength ? { "Content-Length": result.contentLength } : {}),
},
});
};
@@ -1,11 +1,12 @@
import { notFound } from "next/navigation";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import DOMPurify from "isomorphic-dompurify";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema";
import { looksLikeSvg, sanitizeSvg } from "../svg-purify";
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const image = await db.query.medias.findFirst({
@@ -22,17 +23,16 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri
let content = new Uint8Array(image.content);
// Sanitize SVG content to prevent XSS attacks
if (image.contentType === "image/svg+xml" || image.contentType === "image/svg") {
const isSvgMime = image.contentType === "image/svg+xml" || image.contentType === "image/svg";
const isSvgContent = !isSvgMime && looksLikeSvg(content);
if (isSvgMime || isSvgContent) {
const svgText = new TextDecoder().decode(content);
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
});
const sanitized = sanitizeSvg(svgText);
content = new TextEncoder().encode(sanitized);
}
const headers = new Headers();
headers.set("Content-Type", image.contentType);
headers.set("Content-Type", isSvgContent ? "image/svg+xml" : image.contentType);
headers.set("Content-Length", content.length.toString());
headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
headers.set("X-Content-Type-Options", "nosniff");
@@ -0,0 +1,95 @@
import { describe, expect, it } from "vitest";
import { looksLikeSvg, purify, sanitizeSvg, svgSanitizeOptions } from "./svg-purify";
describe("SVG sanitization", () => {
it("strips script tags from SVG", () => {
const dirty = '<svg><script>alert("xss")</script><circle r="10"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("circle");
});
it("strips onload event handler", () => {
const dirty = '<svg onload="alert(1)"><rect width="100" height="100"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onload");
});
it("strips data URI in use href", () => {
const dirty = '<svg><use href="data:text/html,<script>alert(1)</script>"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("data:");
});
it("strips foreignObject", () => {
const dirty =
'<svg><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body></foreignObject></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
});
it("preserves safe SVG content", () => {
const safe = '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="red"/></svg>';
const clean = sanitizeSvg(safe);
expect(clean).toContain("circle");
expect(clean).toContain('fill="red"');
});
it("uses the same DOMPurify options as production", () => {
const dirty = '<svg><script>alert("xss")</script></svg>';
const wrapped = `<html><body>${dirty}</body></html>`;
expect(sanitizeSvg(dirty)).toBe(purify.sanitize(wrapped, svgSanitizeOptions));
});
it("strips javascript: protocol in href", () => {
const dirty = '<svg><a href="javascript:alert(1)"><text>click</text></a></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips xlink:href with javascript:", () => {
const dirty = '<svg><a xlink:href="javascript:alert(1)"><text>click</text></a></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips style with url() import", () => {
const dirty =
'<svg><style>@import url("https://evil.com/steal.css");</style><rect width="100" height="100"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("@import");
});
it("strips animate with onbegin handler", () => {
const dirty = '<svg><animate onbegin="alert(1)"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onbegin");
});
it("DOMPurify actually strips dirty payload (guard against no-op shim)", () => {
const dirty = '<svg><script>alert("xss")</script><rect width="1" height="1"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toBe(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("<rect");
});
it("looksLikeSvg detects SVG content regardless of MIME", () => {
const svg = new TextEncoder().encode('<svg viewBox="0 0 24 24"><path d="M0 0"/></svg>');
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
expect(looksLikeSvg(svg)).toBe(true);
expect(looksLikeSvg(png)).toBe(false);
});
it("preserves well-formed SVG with self-closing children", () => {
const icon =
'<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20Z" fill="currentColor"/><circle cx="12" cy="8" r="2"/></svg>';
const clean = sanitizeSvg(icon);
expect(clean).toContain("<path");
expect(clean).toContain("<circle");
expect(clean).toContain("</svg>");
expect(clean).not.toContain("<html");
expect(clean).not.toContain("<body");
});
});
@@ -0,0 +1,99 @@
import createDOMPurify from "dompurify";
import { parseHTML } from "linkedom";
const svgSanitizeOptions = {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ["style"],
};
const LINKEDOM_NODE_PROPS = [
"ownerDocument",
"parentNode",
"nextSibling",
"previousSibling",
"nodeName",
"nodeType",
"childNodes",
] as const;
// ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify
// resolves them via prototype getters (lookupGetter). Without this shim, sanitize
// is a no-op and XSS payloads pass through unchanged.
const patchLinkedomNodePrototype = (Node: typeof window.Node) => {
for (const prop of LINKEDOM_NODE_PROPS) {
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, prop);
if (descriptor?.get) continue;
if (descriptor?.value !== undefined) {
delete Node.prototype[prop as keyof Node];
}
Object.defineProperty(Node.prototype, prop, {
configurable: true,
enumerable: true,
get() {
return Object.getOwnPropertyDescriptor(this, prop)?.value ?? null;
},
set(value: unknown) {
Object.defineProperty(this, prop, {
value,
writable: true,
configurable: true,
enumerable: true,
});
},
});
}
};
const createDocument = () => parseHTML("<!doctype html><html><head></head><body></body></html>");
const { window, document, Node } = createDocument();
patchLinkedomNodePrototype(Node);
if (!document.implementation?.createHTMLDocument) {
Object.defineProperty(document, "implementation", {
value: {
createHTMLDocument() {
return createDocument().document;
},
createDocument() {
return createDocument().document;
},
},
});
}
window.NodeFilter ??= {
FILTER_ACCEPT: 1,
FILTER_REJECT: 2,
FILTER_SKIP: 3,
SHOW_ALL: 0xffffffff,
SHOW_ELEMENT: 1,
SHOW_ATTRIBUTE: 2,
SHOW_TEXT: 4,
SHOW_CDATA_SECTION: 8,
SHOW_PROCESSING_INSTRUCTION: 64,
SHOW_COMMENT: 128,
SHOW_DOCUMENT: 0x100,
SHOW_DOCUMENT_TYPE: 0x200,
SHOW_DOCUMENT_FRAGMENT: 0x400,
SHOW_NOTATION: 0x800,
} as typeof window.NodeFilter;
const purify = createDOMPurify(window as unknown as Parameters<typeof createDOMPurify>[0]);
export const sanitizeSvg = (svgText: string) => {
// linkedom's DOMParser omits html/body for fragment-only SVG; DOMPurify needs a body root.
const wrapped = `<html><body>${svgText}</body></html>`;
const sanitized = purify.sanitize(wrapped, svgSanitizeOptions);
const svgMatch = sanitized.match(/<svg[^>]*\/>|<svg[\s\S]*?<\/svg>/i);
return svgMatch ? svgMatch[0] : "";
};
export function looksLikeSvg(content: Uint8Array): boolean {
const head = new TextDecoder().decode(content.subarray(0, 512));
return /<svg[\s>]/i.test(head);
}
export { svgSanitizeOptions, purify };
@@ -0,0 +1,94 @@
"use client";
import type { MutableRefObject, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { Menu } from "@mantine/core";
import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { UseGridstackRefs } from "../sections/gridstack/use-gridstack";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { WidgetContextMenuDropdown } from "./widget-context-menu";
interface ActiveContextMenu {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
/** Carried from the item's section, which the board-level menu is outside of. */
gridstack: UseGridstackRefs["gridstack"];
x: number;
y: number;
}
interface WidgetContextMenuApi {
/** Null when right-click menus are disabled, which lets items skip their handler. */
open: ((menu: ActiveContextMenu) => void) | null;
}
const WidgetContextMenuContext = createContext<WidgetContextMenuApi>({ open: null });
export const useWidgetContextMenu = () => useContext(WidgetContextMenuContext);
/**
* One context menu for the whole board instead of one per widget.
*
* Mantine's `Menu` mounts `Popover` + `PopoverDropdown` + several context providers
* even while closed, so a menu per item cost ~13 fibers each for something almost
* never opened — measured at 364 of 3,918 fibers (9%) on a 28-widget board, growing
* with item count. Items now only carry a `contextmenu` listener and this single menu
* is anchored to the cursor.
*/
export const WidgetContextMenuProvider = ({ children }: PropsWithChildren) => {
const { data: session } = useSession();
const settings = useSettings();
const [active, setActive] = useState<ActiveContextMenu | null>(null);
const isEnabled = Boolean(session) && settings.enableRightClickOnWidgets;
const open = useCallback((menu: ActiveContextMenu) => setActive(menu), []);
const api = useMemo<WidgetContextMenuApi>(() => ({ open: isEnabled ? open : null }), [isEnabled, open]);
return (
<WidgetContextMenuContext value={api}>
{children}
{active && (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened
onChange={(opened) => {
if (!opened) setActive(null);
}}
>
{/* Zero-size anchor at the cursor so Mantine positions the dropdown there. */}
<Menu.Target>
<div
style={{
position: "fixed",
left: active.x,
top: active.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
/>
</Menu.Target>
<Menu.Dropdown>
<WidgetContextMenuDropdown
item={active.item}
definition={active.definition}
widgetStateRef={active.widgetStateRef}
settings={settings}
gridstack={active.gridstack}
/>
</Menu.Dropdown>
</Menu>
)}
</WidgetContextMenuContext>
);
};
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useRef } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
@@ -16,12 +16,12 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useSettings } from "@homarr/settings";
import type { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
@@ -30,7 +30,9 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { UseGridstackRefs } from "../sections/gridstack/use-gridstack";
import { useSectionContext } from "../sections/section-context";
import { useWidgetContextMenu } from "./widget-context-menu-provider";
import { useItemActions } from "./item-actions";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
@@ -42,58 +44,68 @@ interface WidgetContextMenuProps {
children: ReactNode;
}
const LONG_PRESS_DELAY_MS = 500;
/**
* Per-item trigger only. The menu itself lives once at board level — see
* WidgetContextMenuProvider for why.
*/
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const settings = useSettings();
const { open } = useWidgetContextMenu();
const { gridstack } = useSectionContext().refs;
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}, []);
if (!open) return <>{children}</>;
return (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
item,
definition,
widgetStateRef,
children,
settings,
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={isMenuOpen}
onChange={setIsMenuOpen}
// display:contents keeps GridStack's layout maths seeing the original child box.
<div
style={{ display: "contents" }}
onContextMenu={(event) => {
event.preventDefault();
open({ item, definition, widgetStateRef, gridstack, x: event.clientX, y: event.clientY });
}}
onTouchStart={(event) => {
const touch = event.touches[0];
if (!touch) return;
const { clientX, clientY } = touch;
longPressTimer.current = setTimeout(
() => open({ item, definition, widgetStateRef, gridstack, x: clientX, y: clientY }),
LONG_PRESS_DELAY_MS,
);
}}
onTouchEnd={cancelLongPress}
onTouchMove={cancelLongPress}
onTouchCancel={cancelLongPress}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{isMenuOpen && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
settings={settings}
/>
)}
</Menu.Dropdown>
</Menu>
{children}
</div>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
settings: ReturnType<typeof useSettings>;
/**
* Passed in rather than read from SectionContext: the menu is mounted once at board
* level, outside any section, while the item that opened it is inside one.
*/
gridstack: UseGridstackRefs["gridstack"];
};
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
export const WidgetContextMenuDropdown = ({
item,
definition,
widgetStateRef,
settings,
gridstack,
}: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
@@ -110,7 +122,6 @@ const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
@@ -13,10 +13,8 @@
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
.wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
opacity: 1;
transform: translateY(0);
}
@@ -1,63 +1,3 @@
import fsPromises from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { glob } from "glob";
import { parse as parseYaml } from "yaml";
import z from "zod";
import packageJson from "../../../../package.json";
export const getPackageVersion = () => packageJson.version;
export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> => {
const pathNames = await glob("**/package.json", {
ignore: "**/node_modules/**",
cwd: "../../",
absolute: true,
});
const packageContents = await Promise.all(pathNames.map(async (path) => await fsPromises.readFile(path, "utf-8")));
const packageDependencies = packageContents
.map((packageContent) => (JSON.parse(packageContent) as PackageJson).dependencies)
.filter((dependencies) => dependencies !== undefined);
const catalog = await parseDependencyCatalogsAsync();
let dependencies = {};
for (const dependenciesOfPackage of packageDependencies) {
const resolvedDependencies = Object.entries(dependenciesOfPackage).map(([name, version]) => {
const catalogVersion = catalog.get(name);
return [name, catalogVersion ?? version] as const;
});
dependencies = { ...dependencies, ...Object.fromEntries(resolvedDependencies) };
}
return dependencies;
};
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
};
const workspaceConfigCatalogSchema = z.object({
catalog: z.record(z.string(), z.string()).optional(),
});
export type PackageJsonDependencies = Record<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
}
+136
View File
@@ -0,0 +1,136 @@
# Dashboard performance in Chrome
Measured on the busiest board of a restored real backup: 28 items, 16 widget kinds.
## A systemic Mantine cost, found in five places
**Mantine overlay components mount their full machinery regardless of `disabled`.** A `Tooltip`,
`HoverCard` or `Popover` that can never open still creates a `Popover`, a `PopoverTarget` and a
`PopoverDropdown`. `disabled` suppresses *display*, not *cost* — so avoiding it means deciding before
rendering, not passing a prop.
That single fact accounts for five separate findings, three of them measured:
| widget | before | after |
| --- | --- | --- |
| **mediaReleases** | 340 components / 8,387 fibers | **170 / 8,057** (−50% components) |
| **calendar** | 171 components / 1,195 fibers | **75 / 907** (−56% components, −24% fibers) |
| **beszelSystemTable** | 36 components / 1,286 fibers | **18 / 1,230** (−50% components) |
| downloads, mediaRequests, app | unchanged | unchanged — their overlays are functional |
- **calendar** mounted a `HoverCard` per day cell. A month grid is ~42 cells and `disabled` is
`isEditMode || eventsForDate.length === 0`, so most days — and in edit mode *every* day — mounted
three components for a card that can never open. 126 of its 171 components, in a widget with only
251 DOM nodes.
- **OverflowBadge** (shared UI in `packages/ui`) mounted a `Popover` and `Popover.Dropdown` even when
nothing overflowed, which is the common case. Used by media-releases, releases, the docker table and
the widget inputs, so it repeated per row wherever a list renders it. This is where the entire
media-releases win came from.
- **beszel disk-usage** mounted a `HoverCard` per table row for systems reporting no filesystems —
it already fell back to a plain bar inside its own target, so the overlay existed to render nothing.
### Two remount bugs
A component declared inside a render body is a **new type every render**, so React unmounts and
remounts its subtree instead of updating it.
- `beszelSystemTable` declared `PercentCell` inline and used it for three columns, while the widget
re-renders ~1×/second from its live subscription — three cell subtrees per system rebuilt every
second. Hoisted and memoised.
- A drag handle was passed to a child as `handle={Handle}` while its parent re-renders every frame of
a drag, so the handle was rebuilt continuously mid-drag. Stabilised with `useMemo`.
### The audit that found them
`scripts/benchmarks/audit-widget-render.mjs` scans every widget and ranks findings by whether they sit
inside a `.map()`, since those multiply by row count. All 54 findings were triaged:
| count | pattern | outcome |
| --- | --- | --- |
| 12 | overlay-per-row | **5 fixed**; the other 7 are functional tooltips with real labels — nothing is disabled to skip, and swapping them for native `title` would change UX, not just cost |
| 11 | index-key | **all safe.** Either static text splits (`description.split("\n")`) or stateless read-only rows from user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts |
| 2 | nested-component | **both fixed** |
| 29 | inline-style-per-row | **left alone.** A fresh object per row only defeats memoisation if the child is memoised, and these are not. 29 edits without a measurement is churn |
### Off-screen rendering
`mediaReleases` rendered **80 cards into a 539 px tile whose content is 7,206 px** — 7 visible, 73
off-screen, each still fetching and decoding a poster and a full-tile backdrop. Decoded bitmaps are
far larger in memory than their files, which is what made this expensive.
Fixed with `content-visibility: auto` + `contain-intrinsic-size`, plus `loading="lazy"`. Three repeats
per side:
| metric | before | after |
| --- | --- | --- |
| image requests | 135, 135, 135 | **55, 55, 56** (−59%) |
| image bytes | 12.27 MiB | **8.41 MiB** (−31%) |
| layout objects | 8346, 7815, 7815 | **5117, 4696, 4636** (−41%) |
Extracted to `offscreenRowStyle()` and applied to the other data-driven lists (rssFeed, notifications,
calendar events, media-requests). Applied to 5 of 26 `ScrollArea` widgets deliberately:
`content-visibility` costs containment work and can be wrong on self-measuring content like charts.
### Two metrics that cannot be used, and one claim withdrawn
**Chrome's board-level `JSEventListeners` is unusable here.** Across six runs of identical code it read
7905, 8000, 8166, 8179, 10360 and 17114. Its `Nodes` counter has the same problem — 23,072 on one run
against ~8,850 on the others — because it counts detached nodes awaiting collection, while
"elements under body" stayed at 6,691–6,726 throughout. Only per-widget component and fiber counts are
stable enough to compare, so those are what is reported above.
**The media-releases tooltip fix measured zero**, and is reported as such. Every release on the test
board has a description and the option is on, so all 80 tooltips are legitimately enabled and none are
skipped. The mechanism is correct; this board is not the case it helps. The −50% for that widget came
entirely from OverflowBadge.
### Idle churn is legitimate, and an earlier claim here was wrong
This description previously said ~80% of idle React commits entered at Next's `HeadManagerContext` and
that the App Router re-rendered once a second. **Both were false.** A 3-item board commits **0.0**
times per minute against 76.0 for the 28-item board — if the router re-rendered on a timer, every board
would churn. The original attribution was a tooling artefact: `fiber.actualDuration` is only populated
by a React *profiling* build, so on production every fiber fails the "did it re-render" test and the
tables come back empty, which reads as "nothing" rather than "this build cannot tell you".
Attributed properly, three Beszel widgets are 82% of idle churn — but the real rate is **1.22 commits
per second** for the whole board at **13.3 DOM mutations per commit**. That is three live-monitoring
widgets refreshing about once a second, which is what they are for. A coalescing fix measured *exactly
zero* (55 commits both sides) and was reverted.
## Verification of the off-screen changes, and one instrument that lied
The off-screen work was committed before it was measured. Verified afterwards on two containers
restored from identical data, `maxopt` against `client3`:
| metric | before | after |
| --- | --- | --- |
| image requests | 138, 135 | **22, 22** (−84%) |
| image bytes (declared) | 12.27 MiB | **0.03 MiB** |
| layout objects | 11611, 7836 | **3694, 3705** (−53%) |
**A −99.8% drop in image bytes is not believable, so it was checked rather than reported.** Three
checks, and the first two were bad instruments:
1. Counting decoded images with `getBoundingClientRect()` reported **80/80 loaded in both arms** — but
reading geometry forces layout, which forces `content-visibility` subtrees to render, which triggers
exactly the image loads the check was looking for. The instrument caused the result.
2. Screenshotting the widgets produced **byte-identical images in both arms** (3,372 bytes for
media-releases, 1,580 for media-requests), because this environment shows an onboarding overlay and
media-requests renders blank with unreachable integrations. Useful only as evidence of *no visual
difference*, not as evidence images render.
3. Counting `img.complete && img.naturalWidth > 0`, which reads no geometry, gave the answer:
| arm | images decoded | image responses |
| --- | --- | --- |
| before | **80 / 80** | 138 |
| after | **80 / 80** | **25** |
**Everything still displays.** The 80 `<img>` in media-releases are integration icons that share URLs,
so the browser deduplicates them to ~25 unique fetches in both arms; the ~113 requests avoided are
other widgets' off-screen artwork, chiefly media-requests' full-size per-card backdrops. That also
explains the byte figure: those backdrops are where the 12.27 MiB came from.
One limit worth stating: media-requests renders blank here because its integrations are unreachable, so
its *visible-card* behaviour after the change is not verified. Media-releases is, at 80/80 decoded.
+122
View File
@@ -0,0 +1,122 @@
# Methodology, and the traps that produced wrong answers
Every wrong number in this investigation came from an instrument, not from the application. They are
recorded here because each one looked plausible at the time, and several survived long enough to be
published before being caught.
## The harness
`scripts/benchmarks/stress-restore.mts` boots a fresh container, restores a supplied backup
**through the onboarding restore UI** (including typing "I understand"), signs in, opens boards
across several tabs, clicks into widgets, reloads, then soaks idle — sampling memory, CPU, browser
heap, DOM nodes and listeners at each stage.
Each run proves it is actually behind a login rather than reading a public page: it asserts a
cookie-less request to the private board is **denied**, requires the Auth.js session cookie after
sign-in, and reports whether it modified the restored data at all.
The backup is passed by path via `STRESS_BACKUP_ZIP` and never enters the repository — it carries
real integration secrets. `.gitignore` also refuses `homarr-backup*.zip` and loose `*.sqlite`.
## Tools
| tool | answers |
| --- | --- |
| `attribute-memory.mts` | cgroup → processes → address-space mappings → V8 spaces → loaded code, each layer reconciled against the one above |
| `memory-probe.cjs` | injected `--require` preload: V8 spaces, module cache, buffer/fetch allocation sites, require graph, smaps, handles. Diagnostics only, never shipped |
| `analyze-heap-dominators.mjs` | real retained sizes via a dominator tree, and expansion of the largest retainer |
| `analyze-allocations.mjs` | allocation volume by site, diffable between two stages |
| `analyze-smaps-stages.mjs` | RSS split into JS heap, JIT, native and file-backed, per stage |
| `analyze-require-graph.mjs` | what pulled each module in |
| `compare-stress-runs.mjs` | alternated runs, medians **and per-run spread** |
| `measure-widget-cost.mts` | per-widget DOM nodes, fibers, heaviest components |
| `measure-board-paint.mts` | image requests, bytes, style/layout/task time, layout objects |
| `measure-widget-churn.mts` | idle churn attributed to a widget via DOM mutations |
## Metric choice
**cgroup `anon`** is the server-memory figure. It is non-reclaimable and is what a container limit
acts on.
**`memory.current` is not used for comparisons.** It includes page cache and swung **192–261 MiB
for the same image**, and 60+ MiB between runs of a byte-identical build.
**`heapUsed` is not server memory.** It is one component. An early version of this work reported a
`heapUsed` improvement as though it were the whole result.
## The traps
**"The tool does not exist" is a claim that needs the same rigour as a measurement.** Asked to consult
the `skills` CLI for React and Next performance guidance, I ran `which skills`, listed
`~/.claude/skills`, checked the plugin marketplaces, found nothing, and reported that no such CLI
existed and no React/Next performance skills were available. Both halves were false. `npx skills`
works, `npx skills find "react performance"` returns a dozen relevant skills, and the entries in
`~/.claude/skills` were **symlinks** into `~/.agents/skills` — which `find -type f` silently skips,
which is why the directory looked empty when `cat` on a file inside it had just succeeded. Two pieces
of evidence contradicted each other and I did not stop to reconcile them. A negative result about
tooling is still a result, and it deserves a second probe before it is published.
**A heap snapshot cannot explain a peak.** V8 runs a full GC before serialising one, so it reports
survivors only. The 94 MiB of transient ArrayBuffers that drove this container past 500 MB never
appeared in a single snapshot. Peaks need an allocation profiler and wrapped allocators.
**V8 truncates snapshot string contents to ~1 KiB.** Grouping snapshot strings by content therefore
compares only each string's first kilobyte. This produced a published claim of "7.86 MiB of literal
duplicate chunk sources"; hashing the real files found **zero** byte-identical duplicates among all
3,055 server JS files.
**Snapshot objects are not live native handles.** A snapshot showed 15 `Gzip` objects still present
at idle, which read as leaked zlib streams pinned by native handles — a real leak class that Next
documents. `process.getActiveResourcesInfo()` reported **Zlib=0**, before and after 300 deliberately
aborted gzip requests.
**Measuring memory without forcing a GC measures V8's growth, not the module's cost.** A
require-cost sweep reported `drizzle-orm` at 124.3 MiB; with `global.gc()` before each reading the
same measurement was a fraction of that. Two database drivers measured 21.4 MiB, not the ~30 MiB the
naive sum implied.
**The act of measuring can dominate the measurement.** Taking a heap snapshot over CDP delivers it
as JS string chunks: for a ~135 MB snapshot that added **~134 MiB of ArrayBuffers to the process
being measured**, and the next stage read 693.7 MiB. `v8.writeHeapSnapshot` streams from C++
instead, and the probe now reports its own `observerCostMiB`.
**Per-component React attribution requires a profiling build.** `fiber.actualDuration` is only
populated by one, so on a production build every fiber fails the "did it re-render" test and the
attribution tables come back **empty** — which reads as "nothing re-rendered" rather than "this
build cannot tell you". This produced a published claim that the App Router re-rendered once a
second. The tool now says which case it is.
**When a server-side change moves a client-side metric, the workload changed.** `--jitless` looked
like −24% peak RSS, −14% CPU and 30% *faster* board loads. Three of those are impossible for a
server flag. The client had **80% fewer event listeners**: the page had simply under-rendered.
**A fixed port can silently measure the wrong container.** A readiness check passed against a
leftover container on the same port while the container under test had not started. Ephemeral ports
and asserting the container's own identity are the fix.
**A completed build is not a built image.** A `docker builder prune` running concurrently killed a
BuildKit session; the build failed and the next step ran against a stale tag. Verify the tag exists.
**Boards differ, so measure the right one.** Peak memory is at `nav-01`, not the multi-tab stage — a
GC lands during multi-tab and pulls RSS down. Measuring the obvious stage understates peak by
37 MiB.
## Reproducing
```bash
STRESS_IMAGE=<image> \
STRESS_BACKUP_ZIP=~/Downloads/<backup>.zip \
STRESS_BOARDS="default,defaultv2" STRESS_TABS_PER_BOARD=2 \
STRESS_NAV_ITERATIONS=3 STRESS_SOAK_MS=120000 \
node --experimental-strip-types scripts/benchmarks/stress-restore.mts
node scripts/benchmarks/compare-stress-runs.mjs .bench/stress <before-prefix> <after-prefix>
```
For attribution, add the probe:
```bash
STRESS_MOUNT="$PWD/scripts/benchmarks:/probe:ro" \
STRESS_ENV="HOMARR_PROBE_PORT=9333,HOMARR_PROBE_TRACK_BUFFERS=32768" \
STRESS_NODE_OPTIONS="--require /probe/memory-probe.cjs"
```
+138
View File
@@ -0,0 +1,138 @@
# Where this left off, and what to do next
Written as a handoff. The branch is green and everything below is measured except where explicitly
marked.
## 1. Verify the unmeasured changes
Four commits apply the off-screen-row treatment beyond the one widget where it was measured. All are
typechecked and lint-clean, none are A/B'd — they are the only performance claims on this branch not
verified before commit.
| commit | widget | measured on this board? |
| --- | --- | --- |
| `7550766f6` | mediaReleases | **yes** — 135 → 55 image requests, 8346 → 5117 layout objects |
| `c526dd2b7` | mediaRequests-requestList | no — but overdraw confirmed: 1,512 px of cards in a 405 px tile, 60 images |
| `040f10594` | rssFeed, notifications, calendar event list | no — these do **not** overdraw on this board |
The last row needs care when reading a result. On the test backup rssFeed renders 7 nodes and the
calendar's 7 rows fit their tile, so a measurement will correctly show **no change**. That is not a
failure of the change — the benefit only exists for a user whose integrations return more rows than
the tile can show. To observe it, use a backup with a busier feed, or shrink the tile.
For `c526dd2b7`, which does overdraw here, a real effect is expected:
```bash
# restore two containers on identical data, one per image, then:
STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
node --experimental-strip-types scripts/benchmarks/measure-board-paint.mts
```
Expect **image requests to fall**. Do **not** expect JS heap to move: decoded bitmaps live outside
`JSHeapUsedSize`. Three repeats per side — a single run wrongly suggested a −16% heap win on the first
widget. If image requests do not fall, revert it.
## 1b. What was deliberately not changed
26 widgets use `ScrollArea`; the treatment was applied to five. `content-visibility` is not free — it
costs containment work, and on content that measures itself (charts, editors, the notebook) skipping
layout can be actively wrong. The rule used: apply it where list length is decided by remote data,
leave fixed-size and chart widgets alone.
`beszelSystemStats`, `beszelSystemTable` and `beszelSystemGrid` report large row counts in the
overdraw tool, but that is the tool misreading SVG groups as rows — their content fits their tiles.
## React `cache()` and memoisation — already in place
Recorded because it looks like a gap and is not.
React `cache()` is **server-only**: it dedupes within a single RSC render and does nothing for client
widget rendering. It is already applied where it belongs — `getBoardByNameAsync`, `getHomeBoardAsync`,
`getRscServerSettingsAsync` and `getQueryClient` are all wrapped, the last with a comment explaining
the dedup.
Client-side data is cached by tRPC/react-query, whose `gcTime` was already checked and found to be
5 minutes, not the 24 hours the original fix catalogue assumed. `React.memo` is already used in the
clock, calendar and releases widgets.
## 2. The one lever with real headroom left
**The tRPC router imports every integration eagerly.** A single **16.10 MiB subtree of 460 files** is
reached by every API route, and because Next preloads route bundles it is resident before the first
request. This pays twice:
- ~41 MiB of retained bundle source (28% of the live server heap) — V8 keeps a script's source alive
while any function in it may still be lazily compiled
- ~38 MiB of V8 compiler zone memory, which is **proportional to how much JavaScript V8 compiles** and
is not reachable by any V8 flag (`--max-opt` is a null result; only `--jitless` removes it, and that
breaks rendering)
Nothing else has this profile. Every allocator and V8-level avenue is closed — see
[REJECTED.md](./REJECTED.md).
Start from `analyze-require-graph.mjs`, which found the shared subtree, and
`[root-of-the-server]__0g06wx7._.js` as the chunk reaching 460 files.
## 3. Known-good but deliberately deferred
| item | prize | why it was not done |
| --- | --- | --- |
| ASCII-escape server bundles | **9.45 MiB** | Proven mechanism — 2.00 vs 1.00 bytes/char; one char above U+00FF doubles a whole source string. Needs a build-time rewrite of minified **vendor** output, so it deserves its own PR and safety argument |
| Lazy `mysql2` / `pg` | 21.4 MiB | Three implementations, all broken; see REJECTED.md. Structurally blocked because `createDb` must stay synchronous |
| `beszelSystemStats` at 1,156 nodes | unknown | Second-heaviest widget, but its cost is chart SVG (`<g>×317`), so reducing it changes what is drawn |
| `documents` reads 18–32 | unknown | Never investigated; each document carries overhead |
## 4. For the clean PR
The instruction was to close this experimental PR and open one with only the verified fixes, judged on
lines-of-code to performance ratio. On the evidence, that set is:
| change | size | measured effect |
| --- | --- | --- |
| `--max-semi-space-size=4` | **1 line** | −19% idle, −21% peak |
| `preloadEntriesOnStart: false` | **1 line** | −52% anon at idle; 310 fewer files even at full load |
| Umami: stop fetching 100k raw events per refresh | 1 file | −26% peak, −93% transient buffers, largest allocation 46.4 → 2.4 MiB |
| `serverExternalPackages` (mysql2/pg + 8 more) | 1 block | −21 MiB idle |
| `content-visibility` on media-release cards | 4 lines | −59% image requests, −41% layout objects |
| ~~ssh2 WASM heap (docker-modem patch)~~ | ~~1 patch~~ | **dropped** — the gain was real (−15 to −18 MiB external) but patching a dependency breaks on every `dockerode`/`docker-modem` bump. Worth raising upstream instead: both packages require a module at file scope while using it in one already-guarded branch |
| Reuse the certificate agent instead of one per request | 1 file | 5.11 → 1.69 ms/request, 60 → 2 TLS handshakes over 60 requests |
| Stream proxied images instead of buffering | 2 files | −36% peak RSS on 30 concurrent 2 MiB images (116 → 74 MiB) |
Together the server changes produce the headline: **−26% peak anon, −52% at idle**, no overlapping
ranges at any stage, for **+18% CPU**.
Two notes for whoever cuts it:
- **Split the formatting out of `media-missing/component.tsx`.** Its 77-line diff is roughly 8 lines of
semantic change plus **69 lines of pre-existing formatting debt** that `oxfmt` swept up when the file
was touched. Every other file changed in this work was already `oxfmt`-clean, so this is the only one
affected. Landing the reformat as its own commit keeps the perf change reviewable.
- The client changes worth carrying are `OverflowBadge`, the `calendar` day-cell `HoverCard`, the
`beszelSystemTable` inline `PercentCell`, `content-visibility` on the data-driven lists, the two
`keepMounted={false}` widgets, and the `placehold.co` removal. The last of those is a bug fix rather
than an optimisation and should probably ship regardless.
Two things to leave out or flag:
- **The gRPC patch** removes 112 files and 6 MiB from an isolated `require`, but is **not separately
visible in a full run**. Bundle hygiene, not a measured memory win.
- **Median board load reads +30%** but is **not established** — individual loads were
[506, 383, 1233] and [848, 431, 574] before against [780, 808, 515] and [621, 579, 1004] after,
heavily overlapping with no cold-then-warm pattern. If the latency trade matters, measure it properly
with many more samples; the suspicion is that it is real and concentrated in
`preloadEntriesOnStart`.
## 5. Read this before measuring anything
[METHODOLOGY.md](./METHODOLOGY.md). Every wrong number in this investigation came from an instrument,
not the application, and several were published before being caught. The short version:
- a heap snapshot **cannot** explain a peak — V8 GCs first and reports survivors only
- V8 truncates snapshot string contents to ~1 KiB
- snapshot objects are **not** live native handles
- measuring without a forced GC measures V8's growth, not a module's cost
- taking a CDP heap snapshot added **134 MiB to the process being measured**
- React per-component attribution silently returns **nothing** on a production build
- a fixed port can measure a **leftover container**
- **when a server-side change moves a client-side metric, the workload changed** — this is how
`--jitless` was caught faking a 24% win by under-rendering the page
+55
View File
@@ -0,0 +1,55 @@
# Homarr performance investigation
Measured work on Homarr's memory and rendering cost, split by subject. Every figure here comes from
a run of `scripts/benchmarks/stress-restore.mts` against a **restored real backup** — 4 boards, 51
widgets, 11 integrations, driven through an actual login — not from a synthetic or empty container.
| document | subject |
| --- | --- |
| [SERVER-MEMORY.md](./SERVER-MEMORY.md) | What the container's RAM is made of, and the changes that reduced it |
| [CLIENT-PERFORMANCE.md](./CLIENT-PERFORMANCE.md) | Dashboard rendering cost in Chrome: DOM, fibers, images, paint |
| [WIDGET-AUDIT.md](./WIDGET-AUDIT.md) | All 50 widgets, one at a time, with a verdict and the evidence for each |
| [REJECTED.md](./REJECTED.md) | Ideas that were implemented and measured, then abandoned — with the numbers |
| [METHODOLOGY.md](./METHODOLOGY.md) | The tools, and the measurement traps that produced wrong answers |
| [NEXT-STEPS.md](./NEXT-STEPS.md) | Handoff: what is unverified, what to do next, and the set for a clean PR |
## Headline result
Server RAM, merge-base `06724cfeb` vs this branch, two alternated runs per side, reported as cgroup
**anonymous** memory because that is what counts against a container limit and cannot be reclaimed:
| stage | before | after | change |
| --- | --- | --- | --- |
| **peak** | 376.6 MiB | **279.3 MiB** | **−26%** |
| boot idle | 130.5 MiB | **62.4 MiB** | **−52%** |
| after sign-in | 247.4 MiB | **142.9 MiB** | **−42%** |
| dashboard loaded | 293.4 MiB | **170.4 MiB** | **−42%** |
| 4 tabs, 2 boards | 366.5 MiB | **258.2 MiB** | **−30%** |
| after 2 min idle soak | 323.7 MiB | **200.8 MiB** | **−38%** |
No before/after range overlaps at any stage. The cost is **+18% CPU** (solid, non-overlapping).
## The changes that did it, by ratio
| change | size | effect |
| --- | --- | --- |
| `--max-semi-space-size=4` | **1 line** | −19% idle, −21% peak |
| `preloadEntriesOnStart: false` | **1 line** | −52% anon at idle, 310 fewer files even at full load |
| Umami: stop fetching 100k raw events per refresh | 1 file | −26% peak, −93% transient buffers |
| `serverExternalPackages` (mysql2/pg + 8 more) | 1 block | −21 MiB idle |
| Reuse the certificate agent instead of one per request | 1 file | 5.11 → 1.69 ms/request, 60 → 2 TLS handshakes |
| Stream proxied images instead of buffering them | 2 files | −36% peak RSS under 30 concurrent 2 MiB images |
| `content-visibility` on media-release cards | 4 lines | −59% image requests, −41% layout objects |
The ssh2 / `@grpc/grpc-js` finding (−15 to −18 MiB external) is deliberately **not** in this table: it
was implemented as two pnpm patches and then reverted, because patching a dependency breaks on every
upstream bump. The finding is recorded in [SERVER-MEMORY.md](./SERVER-MEMORY.md) and belongs upstream.
## What is left, and why it is hard
One lever has real headroom and it pays twice. Roughly **41 MiB of retained bundle source** and
**~38 MiB of V8 compiler zone memory** both scale with how much JavaScript the server loads and
compiles, and the largest single contributor is a **16.10 MiB subtree reached by every API route** —
the tRPC router importing every integration eagerly. Reducing that reduces both.
Everything else has been measured and closed: see [REJECTED.md](./REJECTED.md).
+223
View File
@@ -0,0 +1,223 @@
# Ideas measured and abandoned
Each of these was implemented or configured, built, and measured. They are recorded with their
numbers so nobody repeats them, and because several looked like wins until the measurement came in.
## Cache Components — the app cannot prerender, and the prize is ~20 ms
Tried properly: `cacheComponents: true` at top level on Next.js 16.3.0-preview.9. The flag is
recognised (`- Cache Components enabled`) and the usual migration friction is absent — **zero** files
export `dynamic`, `revalidate` or `fetchCache`, and `experimental.dynamicIO` was never set.
**First blocker, and it is cheap.** Everything is routed through a `[locale]` dynamic root segment, so
the build stops before prerendering: `A required root parameter (locale) was not provided in
generateStaticParams`. That looks like 53 routes × 38 locales = 2,014 shells, but it is not: the guide
is explicit that `generateStaticParams` need only return **one** value, because with Cache Components
`dynamicParams: true` serves the App Shell for unlisted params and upgrades it in the background. One
entry for `en` clears it.
**Second blocker, and it is structural.** Past that, 111 routes fail with the *same* error:
```
Error occurred prerendering page "/en/manage/settings"
SqliteError: no such table: serverSetting
```
Prerendering runs at build time, and the root layout unconditionally awaits real data:
```ts
const [session, user, serverSettings, colorScheme] = await Promise.all([
sessionPromise, userPromise, getRscServerSettingsAsync(), getCurrentColorSchemeAsync(),
]);
```
The immediate cause is mundane: no migration had been run, so the build had no tables. That is fixable
— run the migration first and the build gets a database.
**But making the error go away that way would be the wrong fix, and the error is the useful signal.**
If a build needs the database in order to prerender a route, the route's content is being decided at
build time rather than per request. For Homarr that is actively wrong: `serverSetting` is
per-installation data on the user's own volume, so a shell prerendered against the build machine's
database would bake one install's settings into every other install's first paint. The error is Next.js
correctly reporting that a request-time read sits in the shell path.
The honest fix is therefore to defer those reads into `<Suspense>`, not to feed the build a database.
And that is where it stops being worth it: all four values feed providers that wrap `children`
(`AuthProvider session=`, `SettingsProvider user=`), so deferring them puts essentially the whole app
inside the boundary and leaves a static shell of little more than `<html><body>`. The build would
report `◐` while shipping an empty shell — precisely the failure the migration guide warns about.
**And the prize is small.** Measured against a running container, warm, five repeats:
| request | TTFB |
| --- | --- |
| `/api/health/live` (skips the root layout) | ~3 ms |
| `/auth/login` (through the root layout) | ~19–24 ms |
So the layout's blocking work — auth, two DB reads, a cookie read — is worth **~16–21 ms**. Not
perceptible, against a root-layout restructure affecting all 53 routes plus a standing constraint on
every route added afterwards.
**The main screen cannot benefit at all.** `DynamicClientBoard` is already
`dynamic(..., { ssr: false })` with a skeleton `loading` shell, so the board already paints an instant
shell and renders on the client. Cache Components has no server render to unblock there; the board's
real cost is client JS and tRPC round-trips, which is what the rest of this work addresses.
**Two honest caveats.** The ~20 ms is a warm local SQLite install; on a network database (MySQL,
Postgres) or slow storage those four awaits could cost far more, and the win would scale with them.
And the useful idea inside this — *do not block the first byte on auth plus two DB queries* — can be
had without the migration, by streaming those reads in the existing layout. That is a much smaller,
independently testable change and is the shape worth trying if first paint ever needs attention.
## `optimizePackageImports` — inert, because this app builds with Turbopack
Seven Mantine entry points were absent from `experimental.optimizePackageImports` while
`@mantine/core`, `@mantine/hooks` and `@tabler/icons-react` were listed: `charts`, `dates`,
`dropzone`, `form`, `notifications`, `spotlight`, `tiptap`. Adding all seven and rebuilding:
| | baseline | all seven added |
| --- | --- | --- |
| client chunk files | 482 | 482 |
| client chunk bytes | 18050.7 KiB | 18050.7 KiB |
| largest chunk | 2341.5 KiB | 2341.5 KiB |
**Byte-for-byte identical, down to the chunk hashes.** That pattern normally means a cached build — and
`turbopackFileSystemCacheForBuild` is enabled here — so it was checked rather than written up as a null
result. The Next.js documentation is explicit: *"This is not needed when using Turbopack. Turbopack
automatically analyzes and optimizes imports without requiring this configuration."* The build banner
reads `Next.js 16.3.0-preview.9 (Turbopack)`.
So the existing three-entry list is also dead config. It is left in place as a fallback should the app
ever build with webpack again, with a comment saying not to expect anything from it.
This retires the entire "barrel imports / bundle optimization" line of enquiry for this repo, including
the sub-question of whether `@homarr/ui` — the biggest barrel at 111 importers — could be optimised.
It cannot be by that mechanism, and does not need to be.
Two measurement notes for anyone revisiting this: Next 16 with Turbopack prints **no size columns** in
its route table and emits no `app-build-manifest.json`, so per-route first-load JS is not available
from the build output. Total bytes under `.next/static/chunks` is what these numbers are.
## `--jitless` — looked like the biggest win of the entire investigation, and was fake
V8's `--jitless` removes the optimising compiler and all generated machine code. Its effect on code
memory is real and unambiguous: **code space 11.6 → 0.0 MiB**. The stress figures looked
spectacular: peak node RSS −24%, peak container −36%, CPU −14%, median board load **30% faster**.
Three of those are impossible. The flag was applied to the **server**, so it cannot make the browser
lighter, and removing an optimising compiler does not make code faster.
| | normal | jitless |
| --- | --- | --- |
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
Boards still reported 28/28 widgets ready, so the coarse check passed — but the page carries **80%
fewer event listeners**. Much of the UI never mounted. The memory "win" is an application doing less
work, and the CPU and latency "improvements" are the same artefact.
## `--max-opt=2` (cap V8 at Maglev) — null result, and it closes the door
Since full jitless is unusable, capping the *tier* while still generating code was the obvious next
try. `--max-opt` is rejected by `NODE_OPTIONS` and accepted on the command line.
| stage | full | Maglev-capped |
| --- | --- | --- |
| peak anon | 276.0 MiB | 282.8 MiB (+2%) |
| boot idle | 64.0 MiB | 72.3 MiB (+13%) |
| dashboard loaded | 186.1 MiB | 179.2 MiB (−4%) |
| 4 tabs | 270.8 MiB | 238.5 MiB (−12%) |
| after soak | 199.0 MiB | 214.3 MiB (+8%) |
| CPU | 28.1 s | 27.8 s |
Render integrity was verified explicitly this time: listeners **7,784 in all four runs**, DOM nodes
within 40. So unlike jitless the page rendered fully and the comparison is valid — there is simply no
consistent win.
The mechanism closes it permanently. Measured under `--max-opt=2`, `peak_malloced_memory` at board
load is **42.1 MiB, identical to Turbofan**. Maglev's zones are just as large. Capping the tier does
not reduce zone memory; only not compiling does, and that breaks the page.
## jemalloc — the hypothesis was backwards
~38 MiB of V8 compiler zone memory is freed back to malloc and never returned to the kernel by musl,
so an allocator that decays unused spans back to the OS looked like the answer. Tested on the
**byte-identical image** with only `HOMARR_MALLOC` differing, asserting the "Using jemalloc" log
line.
| stage | musl | jemalloc |
| --- | --- | --- |
| boot idle | 62.4 MiB | **80.3 MiB (+29%)** |
| after sign-in | 144.9 MiB | **170.6 MiB (+18%)** |
| dashboard loaded | 214.8 MiB | 184.4 MiB (−14%) |
| 4 tabs | 259.9 MiB | **319.6 MiB (+23%)** |
| after soak | 215.5 MiB | **279.6 MiB (+30%)** |
| **CPU** | 28.0 s | **24.5 s (−13%)** |
jemalloc does not release freed spans sooner; it deliberately retains more of them (thread-local
caches, time-based dirty-page decay) and spends that memory on speed. **musl is already the frugal
choice**, so "swap the allocator" is not a RAM direction. If CPU ever becomes the binding
constraint, this is a measured 13% for two lines.
## Lazy-loading mysql2 and pg — 21.4 MiB, three broken attempts
Both load on every SQLite install, because `db/drivers/index.ts` imports all three driver wrappers
statically and picks one at call time. The prize is 21.4 MiB. Three attempts, each caught only by
booting the real image:
1. `createRequire(import.meta.url)` is `undefined` in the CommonJS `migrate.cjs` bundle. It threw at
load and broke startup **for every dialect, including SQLite**.
2. With that fixed, MySQL failed with `MODULE_NOT_FOUND` on `drizzle-orm/mysql2/index.cjs` — a
dynamic require resolves through the package's `require` condition to a `.cjs` build that Next's
tracing never copies into the standalone output.
3. Leaving drizzle's dialect static defeats the purpose: `drizzle-orm/mysql2` imports `mysql2`
itself, so the client loads anyway. Verified — the built image still loaded all 77 mysql2 files.
The remaining routes are worse than the problem: forcing drizzle's `.cjs` into the image loads a
**second copy of drizzle's core** beside the ESM one (straddled class identities), and the
`require(esm)` route works only via a hardcoded absolute path, so a drizzle upgrade would break
*startup* on two dialects. Structural blocker: `createDb` must stay synchronous because
`packages/db/index.ts` does `export const db = createDb(schema)` at module scope and `migrate.cjs` is
CommonJS.
## Coalescing Beszel subscription updates — measured exactly zero
Three Beszel widgets account for 82% of idle DOM churn, so batching their per-record `setState` calls
looked like an easy win. Implemented, built, measured:
| | before | after |
| --- | --- | --- |
| DOM mutations / 45 s | 733 | 748 |
| React commits / 45 s | **55** | **55** |
Identical. Beszel emits roughly one record per second and the flush interval was also one second, so
each flush batched exactly one record — arithmetic that should have come before the implementation.
It also exposed a unit error: **DOM mutations are not re-renders**. The real rates are **1.22 React
commits per second** for the whole board and **13.3 DOM mutations per commit**. So the stats widget's
"10.4 mutations/second" is about 0.8 *renders* per second: three live-monitoring widgets each
refreshing about once a second, which is what they exist to do. **73 commits/min is the feature
working, not a defect.**
## Next's image optimiser — 18.5 MiB shipped, 0 MiB of RAM
sharp plus `@img` is 18.5 MiB in the image and costs **+9.1 MiB RSS** once `/_next/image` is hit. But
`shouldUseNextImage={true}` appears nowhere, so the logo always renders a plain `<img>`, and across
three full 4-tab stress runs sharp **never loads**. `images: { unoptimized: true }` would save no RAM
at all. The 9.1 MiB is conditional on visiting the About or Kubernetes pages; the 18.5 MiB is an
image-size observation, not a memory one.
## Smaller null results
| idea | measured | verdict |
| --- | --- | --- |
| base64 images in cache / TanStack Query / Redis | **0.01 MiB** (2 data-URLs), Redis totals 2.17 MB, `image-proxy` stores URL + headers only | the theory that started this — not a factor |
| orphaned zlib streams | `Zlib=0` handles before and after 300 aborted gzip requests | Next's leak class does not occur here; nginx buffers the response so Next never sees the disconnect |
| V8 native contexts (2 → 10 under load) | **0.25 MiB each**, ~2.0 MiB total | too small to chase; sized before investigating |
| Redis 6 → 2 client consolidation | 139.3 vs 139.2 MiB idle | inside noise, reverted. The `pubSub:last:*` TTL was kept as a real unbounded-growth fix |
| V8 old-space cap | removing it entirely matched the capped build at every stage | a drift ceiling, not a reduction. Kept as one line |
| `--max-semi-space-size=2` (vs 4) | no improvement | 4 is the sweet spot |
| `--optimize-for-size`, `--no-opt`, `--lite-mode` | rejected by Node in `NODE_OPTIONS` | not available as a runtime knob |
| in-image Redis / nginx removal | 8.1 / 9.2 MiB RSS (3.1 PSS for nginx) | real but small, and both are load-bearing |
| ASCII-escaping server bundles | **9.45 MiB** — one char above U+00FF forces a whole source string to 2 bytes/char, proven at 2.00 vs 1.00 bytes/char | genuine, but it means rewriting minified **vendor** output at build time; deserves its own PR |
+753
View File
@@ -0,0 +1,753 @@
# Server memory: what the container's RAM is made of
Measured with `scripts/benchmarks/stress-restore.mts` on a restored real backup. See
[METHODOLOGY.md](./METHODOLOGY.md) for the tools and the measurement traps, and
[REJECTED.md](./REJECTED.md) for everything that was tried and abandoned.
## Metric choice
`memory.current` moved between **192 and 261 MiB for the same baseline image** across runs,
because cgroup v2 counts reclaimable page cache. At idle that split was 144 MiB anon vs
77 MiB page cache. Everything below therefore reports **anonymous memory**, which was
stable to ±2.5 MiB across repeated runs of the same image.
## Attribution — this is the part that matters
Built cumulatively from the baseline image, one change at a time:
| config | boot idle anon | verdict |
| --- | --- | --- |
| baseline | 139.2 | — |
| + integrations barrel diet | 139.5 | **no idle effect** |
| + Redis clients 6 → 2 | 139.3 | **no idle effect** |
| + `serverExternalPackages: mysql2, pg` | **118.6** | **−21 MiB — the entire idle win** |
| branch (everything) | 115.2 / 99.0 | no further gain beyond the above |
| change | diff size | idle | under load |
| --- | --- | --- | --- |
| `serverExternalPackages` + `mysql2`/`pg` | **1 line** | **−21 MiB** | −25 MiB |
| integrations barrel diet | ~10 files, +119 lines | ~0 | −40 MiB (±25 noise) |
| Redis clients 6 → 2 | 4 files | ~0 | ~0 |
| V8 heap cap (`HOMARR_MAX_OLD_SPACE_SIZE=512`) | 1 line | ~0 | ~0 |
| cache invalidation + `next-cache-handler` | ~30 files, 66 call sites, ~290 lines | **0** | **0** |
### Why one config line beats the whole module-graph refactor
`packages/core/src/infrastructure/db/drivers/index.ts` statically imports all three
drivers, so `mysql2` and `pg` are in the graph even when `DB_DRIVER=better-sqlite3`.
Bundled, their full transitive graphs are inlined into the boot-loaded server chunks;
marked external they become a `require()` that the sqlite path never reaches. Verified
inside the images:
| image | server chunks | `mysql2` in `node_modules` |
| --- | --- | --- |
| baseline | 108,336 KB | no — it was inlined into the chunks |
| + external pkgs | 94,936 KB | yes — traced, loaded only on demand |
| branch | 98,084 KB | yes |
This is fix-catalog **F6 ("load only the configured DB driver")** achieved by one config
line, after the dynamic-`import()` attempt was reverted for breaking Vitest.
### The V8 heap cap is a safety ceiling, not a reduction
`branch-cap4096` (cap effectively removed) matched `branch` at every stage — idle 99.0 vs
115.2, board loaded 230.2 vs 228.1, after stress 362.6 vs 358.8. The workload never
approaches a 512 MB old space, so the cap cannot reduce steady-state usage. It is still
worth keeping as one line: baseline's soak hit a 543.8 MiB anon sample, and capped
baseline topped out at 347.5 MiB, which is exactly the unbounded-drift protection
issue #5301 needs. It should not be described as a memory saving.
### The cache layer is provably dead code
On the current branch state there are **0** `"use cache"` directives, **0** `cacheTag()`,
**0** `cacheLife()`, **0** `unstable_cache`, and no tagged `fetch` in application source —
and `cacheHandlers`/`cacheComponents` have both been removed from `next.config.ts`.
`packages/next-cache-handler` is referenced only by a `package.json` dependency line;
nothing imports it. The 66 remaining `invalidate*` call sites call `revalidateTag` on tags
nothing ever registers, with no handler to receive them, inside a `try/catch` that
swallows the resulting out-of-scope error. Zero functional effect, ~30 files of churn.
## What is actually hogging the memory
Tested directly against the hypothesis that cached base64 images were bloating
TanStack Query / Redis. **They are not** — measured on a loaded 4-tab session:
| suspect | measured | verdict |
| --- | --- | --- |
| base64 data-URLs in server heap | **0.01 MiB** (2 strings) | not a factor |
| base64 blobs in browser heap | **0.01 MiB** (7 strings) | not a factor |
| Redis total | **2.17 MB** | not a factor |
| image bytes in Redis | **none** — `image-proxy` stores only URL + headers | not a factor |
| React Query `gcTime` | already 5 min, not 24 h | already addressed |
What the server heap is actually made of (141 MiB used, 259 MiB RSS):
| bucket | size | share of heap |
| --- | --- | --- |
| **bundled JS source retained as strings** | **41.4 MiB** | **~29%** |
| other strings (translations, ids, misc) | 10.3 MiB | 7% |
| URLs | 2.0 MiB | 1% |
| everything else (objects, code, maps) | ~87 MiB | 62% |
**The memory is the code, not cached data.** That is why every cache-tuning idea in the
fix catalog measured at zero, and why `serverExternalPackages` — which removes code from
the boot-loaded bundle — was the only lever that moved idle memory.
> **Correction (later round).** An earlier version of this section claimed "7.86 MiB is
> literal duplicate copies" of identical chunk sources. That figure was wrong. It came from
> grouping heap-snapshot strings by content, but **V8 truncates string contents to ~1 KiB
> when serialising a snapshot**, so the comparison only ever saw each string's first
> kilobyte and reported near-identical chunks as identical. Hashing the real files found
> **zero byte-identical duplicates among all 3,055 `.next/server/**/*.js` files (77.6 MiB)**.
> The chunks that looked like copies are distinct files that happen to begin with the same
> vendored module. The real doubling has a different cause — see "Why the source costs
> twice what it should" below.
`@ioredis/commands` is now external, which removes it from all 10 chunks. Recorded as
**bundle hygiene, not a memory win**: idle went 89.7 → 89.4 MiB, inside noise, and
board-loaded/peak differences were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169–173 for identical code).
`got` and `@octokit/request-error` were deliberately **not** externalised. They are only
transitive dependencies here, and a check inside the image confirmed `got` is **not
traced** into `node_modules` — externalising it would have turned a bundled module into a
runtime `require` that resolves to nothing, trading zero measured gain for a possible
`MODULE_NOT_FOUND` on a code path the benchmarks never hit.
### Where the remaining headroom is
Nothing further is available from caches, Redis, or process trimming. The two real levers
left are both large:
1. **Ship less server JavaScript.** ~29% of the heap is retained bundle source. Every
package moved out of the boot graph pays twice — smaller chunks and less retained
source. This is the same lever that produced the −37% idle result.
2. **Stop the App Router re-rendering every second** (see above). That governs the
browser side, which is where the 650 MB peak lives.
## Full attribution — every megabyte, top to bottom
Everything above measured *totals*. This section measures *composition*: each layer is
reconciled against the one above it, so nothing hides in a rounding gap. Tooling:
`scripts/benchmarks/attribute-memory.mts` (cgroup → processes → mappings → V8 spaces →
loaded code) with `scripts/benchmarks/memory-probe.cjs` injected as a `--require` preload.
The probe is diagnostics-only and is never in a shipped image: the harness mounts
`scripts/benchmarks` at `/probe` and sets `NODE_OPTIONS=--require /probe/memory-probe.cjs`.
### Layer 1 — the container is one process
| process | RSS at steady state |
| --- | --- |
| **next-server** | **188.1 MiB (95%)** |
| redis-server | 4.8 MiB |
| nginx (2 procs) | 3.7 MiB |
| shells | 1.3 MiB |
Redis and nginx are rounding errors. Every question about Homarr's memory is a question
about one Node process.
### Layer 2 — where that process's RSS lives
Measured from `/proc/<pid>/smaps`. Anonymous mappings cannot be told apart by size, so they
are classified by signature: V8 allocates its heap in **256 KiB pages** (`kRegularPageSize`),
and pthread reserves ~8 MiB per thread stack.
At boot idle (139 MiB RSS):
| region | RSS | what it is |
| --- | --- | --- |
| **node binary (code)** | **54.3 MiB** | the Node runtime's own text pages — **file-backed and shared**, so it counts in RSS but *not* in cgroup `anon` |
| V8 heap pages (256 KiB each) | 47.8 MiB | the JS heap |
| native malloc / small V8 spaces | 28.2 MiB | musl arenas, Buffers |
| V8 code range / trusted space | 4.8 MiB | |
| JIT code (anonymous executable) | 1.8 MiB | machine code V8 compiled |
| better_sqlite3.node | 2.0 MiB | the only native addon that matters |
| thread stacks | 0.1 MiB resident | 48 MiB *reserved*, essentially untouched |
**This explains the single most confusing number in the whole exercise:** RSS 139 MiB but
cgroup `anon` only 89 MiB. The 54 MiB gap is the Node binary itself. It is reclaimable page
cache shared with every other Node process on the host, so it is real RSS but not a real
cost — which is exactly why `anon` is the metric this PR reports.
Address space reserved but not committed: **16.8 GiB**. V8 claims its cage up front. It is
not memory.
### Layer 3 — inside the JS heap
| V8 space | boot idle | after real use |
| --- | --- | --- |
| old_space | 42.0 MiB | **97.2 MiB** |
| large_object_space | 22.7 MiB | 27.3 MiB |
| code_space + code_large | 1.9 MiB | 10.9 MiB |
| trusted_space | 3.9 MiB | 8.1 MiB |
| new_space | 0.0 MiB | 0.2 MiB |
| external (Buffers) | 4.1 MiB | 22.0 MiB |
### Layer 4 — what those objects *are*
From a post-GC heap snapshot at steady state (143.8 MiB heapUsed, 1.39M nodes):
| bucket | size | share of heap |
| --- | --- | --- |
| **bundled JS source retained as strings** | **41.0 MiB** | **28%** |
| ArrayBuffer backing stores | 17.6 MiB | 12% |
| object property/element arrays | 16.4 MiB | 11% |
| other strings (translations, ids) | 10.3 MiB | 7% |
| closures | 5.7 MiB | 4% |
| plain objects | 4.5 MiB | 3% |
| shapes/maps/descriptors | 6.6 MiB | 5% |
| compiled code metadata | ~12 MiB | 8% |
Confirmed again, with a dominator-tree analysis this time
(`scripts/benchmarks/analyze-heap-dominators.mjs`, real retained sizes rather than shallow
histograms): the largest single retainers are `code:` nodes named after chunk files, each
holding **0.5–1.4 MiB**, i.e. the Script objects keeping their own source alive. V8 retains
a script's source string for as long as any function in it might still be lazily compiled.
**The memory is the code, not cached data.**
### Why the source costs twice what it should
`.next/server` chunks loaded at boot are 21.7 MiB on disk, but the heap holds 32.4 MiB of
source strings. The gap is not duplication — it is character width.
A V8 string is either one byte per character (Latin-1) or two. **One character above U+00FF
anywhere in a file forces the entire source string to two bytes.**
| loaded JS | files | on disk | heap cost |
| --- | --- | --- | --- |
| pure Latin-1 | 1,025 | 12.25 MiB | 12.25 MiB |
| contains ≥1 char > U+00FF | **80** | 9.45 MiB | **18.91 MiB** |
Predicted total 31.16 MiB against 32.43 MiB measured — within 4%.
Proven directly rather than inferred, by reading one chunk as shipped and again with its
non-Latin-1 characters replaced (identical length):
```
_049nb9n._.js 538,519 chars, 2 of them non-Latin-1
as shipped : 1053 KiB per copy -> 2.00 bytes/char
ascii variant : 526 KiB per copy -> 1.00 bytes/char
```
**Two characters cost 527 KiB.** The offenders are almost all trivia in dependencies:
| chunk | non-ASCII chars | the actual characters |
| --- | --- | --- |
| `_049nb9n._.js` (+2 siblings) | 2 | `process.versions.icu?"✅":"Y "` |
| `[root-of-the-server]__1ipwvlq._.js` | 13,141 | Next's log symbols `○ ⨯ ⚠` |
| `_1ew9xg8._.js` (+ssr twin) | 70,175 | a UTF-8 self-test containing `💩` |
| zod chunks ×4 | 13,137 | `✖ ${i.message}` |
| `app-page-turbo.runtime.prod.js` | 13 | an ellipsis `…` |
| `[root-of-the-server]__1xyzm2f._.js` | 13 | **an em-dash in Homarr's own MCP instructions** |
Total recoverable: **~9.45 MiB of heap** (≈10% of idle `anon`), by emitting server bundles
with `\uXXXX` escapes instead of raw characters. Not implemented here — it means rewriting
minified vendor output at build time, and the safety argument needs to be made properly
before that ships. Recorded as a measured, mechanistically understood opportunity.
## The peak: one integration was allocating 46 MiB per refresh
The steady state above is ~237 MiB RSS. The *peak* during a 4-tab session was 429 MiB RSS /
539 MiB container, and that peak is a completely different phenomenon.
Per-stage V8 accounting (`scripts/benchmarks/analyze-probe-stages.mjs`):
| stage | rss | old_space | external | **arrayBuffers** |
| --- | --- | --- | --- | --- |
| boot idle | 138.6 | 42.0 | 4.1 | 0.3 |
| board loaded | 254.9 | 95.3 | 22.9 | 3.0 |
| nav ×3 | 361.9 | 122.5 | 24.7 | 4.8 |
| **4 tabs, 2 boards** | **429.3** | **152.6** | **114.3** | **94.3** |
| after 90 s idle | 237.0 | 97.2 | 22.0 | **2.0** |
`arrayBuffers` goes **0.3 → 94.3 → 2.0**. It is entirely transient — which is precisely why
the app appears to "oscillate between 835 MB and 400 MB" and never settles at the high mark.
Soak growth was **−102 MiB/hour**: there is no leak.
Tracking large `Buffer` allocations by stack (`HOMARR_PROBE_TRACK_BUFFERS`) put **48.3 MiB
in 16 allocations at one site, with a single allocation of 46.3 MiB**:
```
T (/app/apps/nextjs/.next/server/chunks/_1q3xovl._.js:92:32374)
process.processTicksAndRejections
```
That function is undici's `fullyReadBody` — the machinery behind `Response.json()`:
```js
async function T(A,e,t){ let r=[],s=0; for(;;){ let{done:i,value:o}=await A.read();
if(i) return void e(Buffer.concat(r,s)); r.push(o), s+=o.length } }
```
It holds the chunk list *and* the joined copy at the same moment, so a body of size N costs
~2N at the instant it completes. Capturing the bytes identified the response immediately:
```
46.3 MiB assembled from 3,946 socket chunks
{"data":[{"id":"1251f6db…","websiteId":"f133f10c…","sessionId":"9e02d227…","createdAt":"2026-08-09T…
```
**Umami.** And the cause was in plain source — two call sites requesting
`pageSize: "100000"`, i.e. up to 100,000 raw event records:
1. `getEventNamesAsync` downloaded every raw event of the last 30 days **to collect a
handful of distinct event name strings**.
2. `getWebsiteEventTimeSeriesAsync` downloaded them to bucket them into a time series.
3. `getMultiEventTimeSeriesAsync` ran (2) for **every tracked event name concurrently** via
`Promise.all`, multiplying the resident pages by the number of events.
A board with 3 Umami widgets and several tracked events therefore had several ~46 MiB bodies
in flight at once, each briefly doubled, plus ~100k parsed objects per response landing in
old_space. That is the 500–880 MB.
### The fix
| call site | before | after |
| --- | --- | --- |
| `getEventNamesAsync` | list 100k raw events, collect distinct names | aggregated `/metrics?type=event`, one row per name |
| `getWebsiteEventTimeSeriesAsync` | one page of up to 100k records | pages of 1,000, folded into buckets and released |
| `getMultiEventTimeSeriesAsync` | `Promise.all` over all event names | sequential |
The paging path keeps the existing dual-shape handling: instances that return aggregated
`{x,y}` buckets still answer in one request, and an instance that accepts `pageSize` but
ignores `page` is detected (repeated first record id) and stopped rather than double-counted.
Hitting the page ceiling logs a warning instead of silently undercounting.
Covered by `packages/integrations/src/umami/test/umami-integration.spec.ts` (6 tests, new —
there were none).
### Result
Alternated runs, 2 before and 2 after, same harness and workload, `before` = branch HEAD
`f707f6433`, `after` = HEAD + this change. Per-run values are given because the harness's
own spread is what decides whether a difference means anything.
| metric | before | after | change | before spread / after spread |
| --- | --- | --- | --- | --- |
| **peak container** | 498.9 MiB | **398.5 MiB** | **−20%** | [461.6, 536.3] / [387.0, 410.0] |
| **peak node RSS** | 452.4 MiB | **353.3 MiB** | **−22%** | [432.7, 472.2] / [355.6, 350.9] |
| **peak during 4 tabs** | 498.5 MiB | **356.3 MiB** | **−29%** | [460.8, 536.3] / [341.8, 370.8] |
| **peak arrayBuffers** | 95.8 MiB | **6.6 MiB** | **−93%** | [95.7, 95.8] / [6.4, 6.7] |
| largest single allocation | 46.4 MiB | **2.4 MiB** | −95% | identical in both runs |
| post-soak steady state | 324.2 MiB | 322.5 MiB | −1% | [337.7, 310.7] / [280.7, 364.4] |
| boot idle | 119.4 MiB | 125.8 MiB | +5% | [130.0, 108.8] / [152.8, 98.8] |
| median board load | 526 ms | 523 ms | −1% | [389, 663] / [401, 645] |
| CPU over the run | 19.5 s | 20.4 s | +4% | [19.6, 19.4] / [20.3, 20.4] |
Every peak metric separates cleanly — the two sides' ranges do not overlap. Two rows do
**not** support a claim and are listed so they are not read as one:
- **post-soak steady state is inconclusive.** One "after" run finished at 364.4 MiB, above
a "before" run at 310.7. This change targets a transient spike, so a steady-state effect
was not expected.
- **boot idle is noise.** The spread is ±20 MiB on *identical* code (before: 130.0 vs
108.8), which is wider than the apparent difference. Umami's code is not even loaded at
boot; it arrives when a board containing the widget renders.
The cost is **+4% CPU**, from turning one large request into several smaller ones. Board
load latency did not regress.
A prediction worth recording, because it confirms the mechanism rather than just the
outcome: at `pageSize=1000` the largest allocation measured 0.7 MiB, and at 5,000 it
measured 2.4 MiB — 5× the page size, 3.4× the bytes, exactly as a per-page bound implies.
### What the real instance revealed
Widget readiness is identical before and after — 28/28 on the board carrying the three
Umami widgets, and the same single pre-existing error on the other board — so nothing broke.
But the container log from the "after" runs contains something the "before" runs could not
have produced:
```
warn: Event time series hit the page ceiling, counts are a lower bound
module="umami-integration" eventName="Add app" maxRecords="100000"
```
That site really does have more than 100,000 events for one event name in the window. **The
old code had exactly the same ceiling** — `pageSize=100000` is 100k records — and the 46.3 MiB
body it received was that ceiling being hit. The difference is that it truncated in silence
and reported the undercount as fact. The ceiling is unchanged; it is now visible.
This is also the worst case for the round-trip trade-off: that one event name costs 20
sequential requests. It did not show up in board load latency (523 ms vs 526 ms median),
because the widget fetches after the board renders, and total CPU rose 4%.
## Dissecting the peak, not the idle
Everything above measures a drained process — boot idle, or post-soak. That is the wrong
moment: users complain about the high-water mark. Measuring it needs different instruments,
because the obvious one cannot work.
**A heap snapshot can never explain a peak.** V8 runs a full GC before serialising one, so by
construction it reports what survived. The 94 MiB of ArrayBuffers that drove this container to
500 MB was invisible in every snapshot taken. Three instruments were added instead:
| instrument | what it sees | blind to |
| --- | --- | --- |
| `HeapProfiler.startSampling` (in-process `inspector.Session`) | every V8-heap allocation, survivor or garbage, with a stack | anything outside the V8 heap |
| wrapped `Buffer.alloc`/`concat` | external buffer allocations, with a stack | V8-heap objects |
| `/proc/<pid>/smaps` at every stage | JS heap vs JIT vs native vs file-backed | anything below page granularity |
Together they cover the whole address space. Neither of the first two alone would have found
the Umami bug: the sampling profiler cannot see `Buffer` backing stores, which is precisely
where those 46 MiB lived.
### An observer effect worth recording
The first attempt took the peak snapshot over CDP, which delivers it as JS string chunks. For
a ~135 MB snapshot that added **~134 MiB of ArrayBuffers to the process being measured**, and
the stage right after it read 693.7 MiB. That is an artefact, not an application problem —
and it is exactly the kind of number that gets reported as a finding. Switching to
`v8.writeHeapSnapshot`, which streams from C++, removed it. The probe now reports its own
`observerCostMiB` so the cost of looking is visible rather than assumed to be zero.
### What the peak is actually made of
Peak here is `nav-01`, not the multi-tab stage — a GC lands during multi-tab and pulls RSS
*down*, so "most tabs open" is not the same as "most memory". Node RSS 357.8 MiB:
| component | MiB | notes |
| --- | --- | --- |
| live JS objects (`heapUsed`) | 175.7 | of which ~41 MiB is retained bundle source |
| node binary text | 65.7 | file-backed and shared; not in cgroup `anon` |
| native malloc | ~59 | see below |
| external buffers | 24.6 | |
| V8 committed but free | 12.3 | heapTotal − heapUsed |
| JIT code | 10.1 | grew from 1.9 MiB at boot |
Cross-checked rather than assumed: `heapTotal 188.0 − V8's 256 KiB pages 153.1 = 34.9 MiB`,
which matches `large_object_space` at 34.5 MiB. So the handful of very large anonymous
mappings (31.6, 20.7, 8.4 MiB) are V8's large-object space and external buffers — **not** the
runaway native allocator that their shape first suggested.
**The retained heap at peak is the same as at rest.** A post-GC snapshot at peak has 160.7 MiB
of shallow size against 157.7 MiB after the soak; strings 51.1 vs 50.1 MiB. Nothing
accumulates. The peak is transient churn plus pages V8 and malloc have not returned — which is
why memory "oscillates" and why soak growth is negative.
And the churn is small now: total V8-heap allocation across the entire run is **125 MiB**, 40%
of it node's own module loader reading source files. After the Umami fix the multi-tab burst
allocates **3.3 MiB**. There is no longer a churn problem on the JS side.
## The 16 MiB nobody was using: ssh2
The largest single retainer in every snapshot — at boot, at peak, and after the soak — was one
`system / Context` holding **16.03 MiB**. Expanding it through the dominator tree (a plain
graph walk escapes via `__proto__` and reports the whole heap) named it immediately:
```
=== inside the largest retainer (object:system / Context, 16.03 MiB) ===
16.00 MiB (indirect) -> object:ArrayBuffer
0.00 MiB POLY1305_WASM_MODULE -> object:Object
0.00 MiB ChaChaPolyCipherNative -> closure:ChaChaPolyCipherNative
0.00 MiB AESGCMCipherNative -> closure:AESGCMCipherNative
```
`ssh2`. Its crypto module instantiates a WebAssembly memory for ChaCha20-Poly1305 the moment
it is required. Homarr has no SSH feature and does not depend on ssh2; the chain is
`dockerode → docker-modem → ssh2`, and `docker-modem/lib/modem.js` requires `./ssh` at module
scope while using it in exactly one place, already guarded by `protocol === 'ssh'`.
Measured directly inside the image — requiring ssh2 and nothing else:
```
heapUsed +2.6 MiB external +0.9 MiB rss +14.0 MiB
```
This was fixed with a pnpm patch moving that one require inside the branch that uses it, and the
patch has since been **removed** — see below.
> **Reverted, by decision.** Two pnpm patches came out of this work: `docker-modem@5.0.7` (the ssh2
> require above) and `dockerode@5.0.1` (the same shape, hiding `@grpc/grpc-js` — 14.6 MiB across 61
> files — behind `dialWithSession`). Both were dropped at the maintainer's request: patching a
> dependency means every future `dockerode` or `docker-modem` bump fails until someone regenerates the
> patch, and that recurring cost was judged not worth roughly 25 MiB of RSS.
>
> The finding itself stands and is worth keeping on record, because the fix belongs **upstream**: both
> packages require a module at file scope while using it in exactly one already-guarded branch. A PR to
> `docker-modem` and `dockerode` would fix it for every consumer, with no patch to maintain here.
>
> The two patches that remain — `@types/node-unifi` and `trpc-to-openapi` — predate this work and are
> untouched.
### The same question asked of every dependency
Since one package was hiding 14 MiB behind a require, all 173 were measured the same way: each
required in a fresh process, RSS delta recorded. Packages costing more than 2 MiB, cross-checked
against whether the server actually loads them:
| package | RSS to require | loaded at runtime? |
| --- | --- | --- |
| drizzle-orm | 124.3 MiB | only via subpaths — the root import loads every dialect, so this figure overstates it |
| @kubernetes/client-node | 66.9 MiB | **no** — correctly lazy |
| dockerode | 28.8 MiB | yes (includes docker-modem 16.1 and ssh2 14.3) |
| **mysql2** | **20.9 MiB** | **yes — 77 files, on a SQLite install** |
| sharp | 20.4 MiB | no |
| openid-client / jose | 16.1 / 15.5 MiB | no |
| @grpc/grpc-js | 14.6 MiB | yes, 61 files (via dockerode) |
| **pg** | **9.4 MiB** | **yes — 13 files, on a SQLite install** |
These are not additive — dockerode's figure contains ssh2's — but the ranking is what matters.
> **Correction.** The RSS figures in the table above were measured without forcing a GC
> between readings, so they include V8 growing its heap opportunistically rather than the real
> cost of the module. Re-measured with `global.gc()` before each reading, the two unused
> database drivers cost **21.4 MiB RSS / 4.1 MiB heap**, not the ~30 MiB the naive sum implied
> — and `drizzle-orm`'s 124.3 MiB was almost entirely this artefact. The ssh2 conclusion is
> unaffected: it was confirmed independently by the before/after external-memory drop.
### mysql2 and pg load on every SQLite install — and it was not worth fixing
`db/drivers/index.ts` statically imports all three driver wrappers and picks one at call time,
so importing the module executes all three. Confirmed at runtime: mysql2 (77 files) and pg
(13 files) are resident on a SQLite install. Re-measured properly, the prize is **21.4 MiB**.
Three attempts, each caught by testing the real image rather than by typecheck:
1. `createRequire(import.meta.url)` — this module is bundled into two entry points, and
`db/migrations/<dialect>/migrate.cjs` is **CommonJS**, where `import.meta.url` compiles to
`undefined`. `createRequire` threw `ERR_INVALID_ARG_VALUE` at load and took down migrations,
and therefore startup, **for every dialect including SQLite**. Fixed by basing resolution on
`process.cwd()`.
2. With that fixed, MySQL failed with `MODULE_NOT_FOUND` on
`drizzle-orm/mysql2/index.cjs`. A dynamic `require` resolves through the package's `require`
condition to a `.cjs` build, and Next's tracing only ever copied the ESM `index.js` into the
standalone output.
3. Leaving drizzle's dialect as a static import defeats the purpose entirely:
`drizzle-orm/mysql2` itself does `from "mysql2"`, so the client loads anyway. Verified — the
built image still loaded all 77 mysql2 files.
That leaves two routes, and both are worse than the problem. Forcing drizzle's `.cjs` into the
image via `outputFileTracingIncludes` would load **a second copy of drizzle's core** next to
the ESM one for MySQL users, so class identities and `is()` checks would straddle two module
instances. Requiring the ESM file works under Node 24's `require(esm)` — but only via an
absolute path (`/app/node_modules/drizzle-orm/mysql2/index.js`); both bare specifiers fail,
so it would hardcode drizzle's internal file layout and a drizzle upgrade would break
*startup* for MySQL and Postgres users.
**Backed out.** 21.4 MiB is not worth a startup-failure class of risk on two dialects that
cannot be exercised in normal development. The blocker to record is structural: `createDb`
must stay synchronous because `packages/db/index.ts` does `export const db = createDb(schema)`
at module scope and `migrate.cjs` is CommonJS, so neither `await import()` nor top-level await
is available. Making the database handle async would unblock it.
## Why 24 MiB of code is resident before the first request
The module cache is a flat list with no parentage, so "what pulled this in" needed the
require graph recorded as the loader resolves it (`HOMARR_PROBE_TRACK_REQUIRES=1`, analysed
by `scripts/benchmarks/analyze-require-graph.mjs`). At boot idle, having served only
`/api/health/ready`: **2,004 edges over 1,121 nodes, 23.97 MiB of source**.
| importer | subtree |
| --- | --- |
| `apps/nextjs/server.js` (everything) | 23.83 MiB / 1,087 files |
| `next/dist/server/load-components.js` | 21.75 MiB / 796 files |
| **`[root-of-the-server]__0g06wx7._.js`** | **16.10 MiB / 460 files** |
| next's own framework code, exclusively | 2.16 MiB / 315 files |
Two things follow, and the second is the one that matters:
1. **Next loads all 74 app route bundles at startup**, not on first request. ~~That is
framework behaviour, not something this codebase sets.~~ **That was wrong** — see below.
2. **One subtree of 16.10 MiB / 460 files is reached by the tRPC/API routes *and* by
`instrumentation`** — the cron tasks and websocket server. Exclusive attribution for
instrumentation is therefore **0 bytes**: everything it touches, an API route touches
too. Only 1.10 MiB / 119 files is common to *every* route, so this is not one universal
barrel — it is specifically the API surface pulling in every integration and widget
request handler.
That kills the obvious fix. Deferring `instrumentation` would free nothing, because Next
preloads `/api/[...trpc]` at boot and that route reaches the same graph. Cutting the shared
subtree itself requires the tRPC router to stop referencing every integration eagerly — the same
lever the integrations barrel diet started, with roughly 16 MiB of source still behind it.
### Correction: preloading *is* configurable, and turning it off is the largest idle win here
The claim above that route preloading is "framework behaviour, not something this codebase sets"
was wrong. Next documents `experimental.preloadEntriesOnStart: false` for exactly this, and the
guide describes it as preventing the server from preloading page modules into memory at startup.
Found by reading Next's own memory-usage guide rather than by inference — which is the lesson.
Deterministic per-stage measurements, identical across both runs on each side:
| stage | source loaded | files | heapUsed |
| --- | --- | --- | --- |
| boot idle | 24.0 → **10.6 MiB** | 1114 → **706** | 70.7 → **44.8 MiB** (−37%) |
| post-login | 27.9 → **19.3 MiB** | 1215 → **884** | 123.8 → **109.8 MiB** |
| board loaded | 29.0 → **21.7 MiB** | 1323 → **1006** | run-to-run noise |
| multi-tab | 29.2 → **22.9 MiB** | 1325 → **1015** | 139.7 → **127.8 MiB** |
| post-soak | 29.2 → **22.9 MiB** | 1325 → **1015** | 142.5 → **127.4 MiB** |
The last two rows are the important ones: **310 files and 6.3 MiB of source stay unloaded even
after a full multi-tab session**, so this is not merely deferred cost — routes a session never
visits are never paid for at all. Live heap is lower at every stage.
Peak node RSS does not move (350.3 → 346.1, spreads overlap), for the same reason the ssh2 chop
did not move it: V8 sizes its heap against available headroom, so a smaller live set does not
shrink peak RSS. Median board load is +3% with overlapping spreads, so the first-hit cost this
trades for did not show at this workload. Full E2E suite passes 8/8.
Also from that guide, and **not** applicable here: `webpackMemoryOptimizations`,
`serverSourceMaps`, `enablePrerenderSourceMaps` and disabling the Webpack cache all address
*build-time* memory, and this build uses Turbopack.
### `--jitless`: rejected, and the reason generalises
V8's `--jitless` removes the optimising compiler and all generated machine code. It applies
through `NODE_OPTIONS` and its effect on code memory is real and unambiguous: **code space
11.6 → 0.0 MiB**. The stress figures looked spectacular — peak node RSS −24%, peak container
−36%, CPU −14%, median board load 30% *faster*.
Three of those are impossible. The flag was applied to the **server**, so it cannot make the
browser lighter, and removing an optimising compiler does not make code faster. The client
metrics show what actually happened:
| | normal | jitless |
| --- | --- | --- |
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
| widget controls available | 244 | 216 |
Boards still reported 28/28 widgets ready, so the coarse readiness check passed — but the page
carries **80% fewer event listeners**. Much of the UI never mounted. The memory "win" is an app
doing less work, and the CPU and latency "improvements" are the same artefact.
The generalisable tell: **when a server-side change moves a client-side metric, the workload
changed and the comparison is void.**
Flags that cannot be used this way at all: `--optimize-for-size`, `--no-opt` and `--lite-mode`
are rejected by Node with "not allowed in NODE_OPTIONS".
## Closing the last gap: what the native bucket at peak actually is
At peak the `smaps` bucket "other anonymous" holds **118.8 MiB** and was the least understood
part of the footprint. It is now mostly accounted for:
| component | MiB | how it was established |
| --- | --- | --- |
| V8 `large_object_space` | 34.5 | `heapTotal 188.0 − the 256 KiB pages 153.1 = 34.9`, matching the space report |
| `external` — Buffers/ArrayBuffers | 24.6 | `process.memoryUsage()` |
| freed-but-unreturned V8 zone memory | ~38 | `peak_malloced_memory` 4.6 → **42.2 MiB** at the board-loaded stage, while *current* `malloced_memory` stays at 1.0 MiB |
| unattributed native malloc | ~22 | remainder |
The zone figure is the interesting one. V8 mallocs ~38 MiB of parser/compiler zone memory while
compiling the board's modules, frees it back to malloc, and **musl never returns it to the
kernel** — so it stays resident as anonymous memory with nothing alive in it.
### A prediction that was wrong
The obvious inference — loading fewer files means less compiler zone memory, so "ship less
server JS" pays twice — **does not hold**. `peak_malloced_memory` measured across the
`preloadEntriesOnStart` A/B:
| stage | before ×2 | after ×2 |
| --- | --- | --- |
| boot idle | 4.5, 4.5 | 4.5, 4.5 |
| post-login | 4.5, 4.5 | 4.5, 4.5 |
| board loaded | 42.1, 42.3 | 42.1, 42.1 |
Identical, despite 310 fewer files being loaded. The 38 MiB spike is a **fixed cost incurred at
board load**, not a per-file one — one large parse or compile, not the sum of many. So the
"pays twice" claim is withdrawn; `preloadEntriesOnStart` saves retained source and live heap, and
does nothing for the native bucket.
### The zlib-orphan hypothesis: disproven here
Next's own source documents a real leak class: a zlib stream left open by a client disconnect is
pinned by its native handle, survives GC, and permanently retains ~256 KiB of deflate state.
`releaseCompressionStream`, the fix, is **not present** in this Next build, and a peak heap
snapshot showed 16 `Zlib` and 15 `Gzip` objects — 14 still there at idle, which looked exactly
like accumulation.
It is not. `process.getActiveResourcesInfo()` reports **Zlib=0**, both at baseline and after
**300 deliberately aborted gzip requests**, with RSS flat at 136.5 MiB. Those snapshot entries
are heap objects, not live handles — an important distinction, since a snapshot happily lists
objects with no live native resource behind them. nginx buffers the response from Next, so Next
never observes the mid-response client disconnect that creates an orphan in the first place.
Recorded because the initial reading of the snapshot was wrong in a way that would have shipped a
fix for a leak this deployment does not have.
## The 38 MiB of compiler zones: attributed, then found to be unaddressable
The largest single item in the peak breakdown that was never explained is now explained, and then
closed off.
**It is V8's optimising compiler.** `peak_malloced_memory` jumps from 4.5 MiB to 42.1 MiB the
moment a board's modules are compiled, while *current* `malloced_memory` stays at 1.0 MiB — memory
V8 takes for compilation, frees back to malloc, and that musl never returns to the kernel. Proven
by re-reading the `--jitless` runs, where the spike **never happens at all**:
| stage | normal | `--jitless` |
| --- | --- | --- |
| boot idle | 4.5 MiB | 4.5 MiB |
| **board loaded** | **42.1 MiB** | **4.5 MiB** |
| multi-tab | 42.7 MiB | 4.5 MiB |
`--jitless` itself is unusable (it leaves the page under-rendered — see above), but V8 can cap the
*tier* instead of removing code generation: `--max-opt=3` is the default Turbofan, `2` stops at
Maglev, `1` at Sparkplug. It is rejected by `NODE_OPTIONS` but accepted on the command line, so it
was wired behind `HOMARR_MAX_OPT` and A/B'd on one image.
**`--max-opt=2` is a null result for memory:**
| stage | full | capped at Maglev |
| --- | --- | --- |
| peak anon | 276.0 MiB | 282.8 MiB (+2%) |
| boot idle | 64.0 MiB | 72.3 MiB (+13%) |
| board loaded | 186.1 MiB | 179.2 MiB (−4%) |
| 4 tabs | 270.8 MiB | 238.5 MiB (−12%) |
| after soak | 199.0 MiB | 214.3 MiB (+8%) |
| CPU | 28.1 s | 27.8 s (unchanged) |
Render integrity was checked explicitly this time, because that is how `--jitless` was caught:
listeners were **7,784 in all four runs** and DOM nodes within 40 of each other, so unlike jitless
the page rendered fully and the comparison is valid. There is simply no consistent memory win —
some stages better, some worse, peak unchanged.
The mechanism says why, and it is worth recording as a closed door. Measured directly with the
probe under `--max-opt=2`:
```
01-boot-idle peak_malloced= 4.5 MiB
03-post-login peak_malloced= 4.5 MiB
04-board-loaded peak_malloced= 42.1 MiB <- identical to Turbofan
```
**Maglev's zone allocation is just as large as Turbofan's.** Capping the tier does not reduce zone
memory; only not compiling at all does, and that breaks the page. So this 38 MiB is not reachable
through V8 configuration — it is proportional to how much JavaScript V8 has to compile, which puts
it back under the one remaining server-side lever: ship less server code. Reverted.
## Correction: the "16 MiB tRPC subtree" lever was already removed
Earlier rounds identified a **16.10 MiB subtree of 460 files reached by every API route** and named it
the one remaining lever with real headroom, attributing it to the tRPC router importing every
integration eagerly. Both halves of that are now known to be wrong.
**The integration creator was already fully lazy.** Every kind in
`packages/integrations/src/base/creator.ts` is `() => import(...)`, so no integration class is loaded
until one is constructed. The earlier "integrations barrel diet" had already done this properly; the
claim that the router imports them eagerly was never checked against the code.
**The subtree itself was an artefact of route preloading.** That measurement was taken before
`preloadEntriesOnStart: false` shipped, when Next loaded the module graph of all 74 routes at startup.
Re-measured on the current build, boot is **706 files and 10.56 MiB of source**, and it breaks down as:
| owner | size | files | can it be reduced? |
| --- | --- | --- | --- |
| `.next/server/chunks` (app code) | 3.73 MiB | 28 | this is the app's own boot graph, already lean at 28 chunks |
| better-sqlite3 | 2.16 MiB | 14 | no — 2.1 MiB of it is the native `.node` binary, and it is the default driver |
| next/dist (framework) | 1.75 MiB | 359 | no — framework, and flat regardless of load |
| next/dist/compiled (vendored) | 1.58 MiB | 25 | no |
| **mysql2 + pg** | **0.52 MiB** | 90 | yes in principle, but structurally blocked — see REJECTED.md |
| everything else | < 0.5 MiB | — | noise |
So there is no single large server-side lever left. What remains is diffuse: under a full multi-tab
session the app loads **15.44 MiB of its own chunks** across 241 files, and that is what the ~41 MiB of
retained source and the ~38 MiB of compiler zones both scale with. Reducing it means shipping less
application JavaScript per route — a broad refactor with no single target, not a 16 MiB subtree waiting
to be cut.
+299
View File
@@ -0,0 +1,299 @@
# Widget-by-widget audit
Every widget in `packages/widgets/src`, worked through one at a time. 50 widgets, 22,726 lines.
`_inputs/` is excluded on purpose, not overlooked. Its 2,748 lines are widget *option* editors, reached
only from the edit modal and the custom-widget form; the one widget that imports from it,
`timetable/component.tsx`, uses `import type`, which is erased at build. None of it is on the board
render path. The one real finding there — a drag handle rebuilt every frame of a drag — was found and
fixed by the anti-pattern audit, which does cover the directory.
The point of doing this exhaustively rather than opportunistically: the two largest client wins so far
(`OverflowBadge`, `content-visibility` on media-releases) were both found in widgets nobody suspected,
and both turned out to be *shared* problems that repeated across many widgets. A sweep finds the
pattern; a spot-check finds the instance.
## How the list is built
`scripts/benchmarks/widget-inventory.mjs` reads every widget and records the signals that predict a
render problem — rows multiplied by `.map()`, overlays, image sites, whether a scrolling list renders
lazily, memoisation, live subscriptions, refetch interval — then ranks by a weighted score that
prefers repetition over size. A per-row overlay in a scrolling list outranks a large file that renders
once.
Every column is a **signal, not a defect**. A widget with 6 maps and no memoisation is perfectly fine
if it renders three rows. The ranking decides reading order; the verdict comes from reading the code.
Two companion scripts: `audit-widget-render.mjs` finds specific anti-patterns and ranks them by
whether they sit inside a `.map()`, and `measure-widget-cost.mts` counts components and fibers per
widget on a real board.
## The same cost in `Accordion`, which this sweep missed
Credit where it is due: this came from reading [PR #6582](https://github.com/homarr-labs/homarr/pull/6582),
which adds `keepMounted={false}` to the two coolify accordions. The sweep below checked `Tabs` and never
checked `Accordion`, so it missed the problem entirely on five sites.
**`Accordion` behaves exactly like `Tabs`: collapsed panels render their children.** Verified in the
installed version — with three marker children per panel, the default renders **3 of 3** children of the
*collapsed* panel, and `keepMounted={false}` renders **0**.
It matters more here than in `Tabs`, because collapsed is the *normal* state of an accordion section
rather than the exception:
| widget | collapsed panels were building | verdict |
| --- | --- | --- |
| `coolify` instance-card | three remote-data lists — servers, applications, services | **fixed** |
| `coolify` single-instance-layout | same three lists | **fixed** |
| `health-monitoring` cluster-health | per-node resource tables | **fixed** — beyond what #6582 did |
| `firewall` | interface rows | **fixed** — beyond what #6582 did |
| `_inputs/widget-multiReleasesRepositories-input` | repository picker | left mounted — it holds selection state, and it is edit-modal only, not on the board render path |
In every fixed case the open/closed state lives in the parent component, so unmounting a collapsed
panel discards nothing.
This also corrects three verdicts in the table below: `coolify` was recorded as "clean (bounded)" and
`firewall` and `health-monitoring` as triaged/fixed on other grounds. All three were carrying this cost.
### What else #6582 had, and what was declined
Mining the rest of that PR's perf-shaped hunks:
| their change | taken? |
| --- | --- |
| `keepMounted={false}` on 2 accordions | **taken, and extended to 4 sites** — above |
| `loading="lazy"` on the `iframe` widget | **taken** — an iframe is a whole browsing context, so one scrolled off a tall board is the most expensive thing a widget can load eagerly |
| `loading="lazy"` on media-releases and rssFeed | already done here |
| `dynamic()` for `BoardItemMenu` | **declined, and it does not work as written.** `BoardItemMenu` already early-returns `null` outside edit mode, so there is no overlay-per-item cost to begin with — but it is *rendered* unconditionally, and `dynamic()` fetches its chunk when the component renders, not when it returns something. So the chunk still loads on every board render. It would only pay off with the `isEditMode` guard hoisted to the call site, which is a board-rendering change worth measuring first |
| `loading="lazy"` on the immich carousel | declined — the carousel's image *is* the visible content, so there is nothing off-screen to defer |
| `memo()` on `item-content` / `grid-editor` | not extractable — entangled with that PR's new advanced-focus feature, portals and viewport hooks, not a standalone perf change |
| 5 `<Suspense>` boundaries | mostly in that PR's new components; not applicable here |
## The systemic finding this pass: inactive tab panels render in full
**Mantine's `Tabs` keeps inactive panels mounted by default.** In Mantine 9 on React 19 that is
implemented with `<Activity>`, which preserves state and unmounts effects but *still builds the entire
subtree*. So a widget with two tabs pays for both, forever, while showing one.
Four widgets use `Tabs`. Two were costing real work and are now `keepMounted={false}`; two must keep
their panels mounted and are left alone:
| widget | panels | verdict |
| --- | --- | --- |
| `health-monitoring` | system view + cluster view | **fixed** — two live monitoring views were both built; only one is ever visible |
| `media-missing` | missing + queued media cards | **fixed** — both tabs built their full card list, each card with a poster and a blurred backdrop |
| `custom-api` | user-authored JSX | left mounted — panels can hold user component state, which unmounting would discard |
| `widget-edit-modal` | form sections | left mounted — unmounting would reset half-filled form fields |
`health-monitoring` is the worse of the two: its panels are the system and cluster monitors, so a
board showing one was building the other's whole tree as well.
**The premise is verified, not inferred.** This started as a reading of Mantine's documentation, which
is a weak basis for a prop that trades a remount cost for a mount saving — if Mantine ever stopped
rendering inactive panels, the prop would be pure loss. `common/keep-mounted.spec.ts` pins the
behaviour in the installed version: with three marker children per panel, the default renders **3 of 3**
children of the *inactive* panel into the DOM, and `keepMounted={false}` renders **0**. If that ever
changes, the test fails and the prop should come back out.
What remains unmeasured is the *magnitude* on a live board — `media-missing` and `health-monitoring`
need reachable integrations to render data, so neither could go through the per-widget component-count
harness. The mechanism is confirmed; the size of the saving is not.
## Changes made
| widget | change | why |
| --- | --- | --- |
| `health-monitoring` | `keepMounted={false}` on `Tabs` | the hidden monitoring view was fully built |
| `media-missing` | `keepMounted={false}` on `Tabs` | both tabs built every card |
| `coolify` ×2, `health-monitoring` cluster, `firewall` | `keepMounted={false}` on `Accordion` | collapsed panels built their lists; collapsed is an accordion's normal state |
| `media-missing` | `offscreenRowStyle(CARD_HEIGHT[density])` | renders every card into a scrolling tile; the card height is fixed per density, so the intrinsic size is exact rather than a guess |
| `media-missing` | `loading="lazy"` on the poster | a poster per card, all fetched regardless of scroll position |
| `calendar` | inline SVG instead of `placehold.co` | a failed poster made a **third-party network request** from a self-hosted dashboard, once per failing event, to draw a grey square |
| `rssFeed` handler | logger instead of raw `console.debug` | logged the entire serialised feed entry per entry, unsuppressable by log level |
The `placehold.co` fallback is the one I would call a bug rather than a cost: it fails on any install
without internet access, which is a normal way to run Homarr.
## The checklist
Verdicts, and the evidence behind each — the distinction matters, because "I read it" and "its list
cannot get long" are different strengths of claim:
- **fixed** — changed here or in an earlier pass
- **clean (read)** — the component was read; nothing worth changing
- **clean (bounded)** — list length verified bounded by a widget option, by viewport width, or by the
remote service (active streams, configured instances, physical disks), *and* the row is cheap:
no image, no overlay. Not a line-by-line read
- **triaged** — a finding was examined and deliberately declined, reason recorded
| ✓ | # | widget | LOC | verdict | note |
| --- | --- | --- | --- | --- | --- |
| [x] | 1 | `downloads` | 1525 | clean (read) | top of the ranking, and the flag was wrong: it memoises data, sorting, columns, sizes and stats. Renders via `DataTable`; list is bounded by active downloads |
| [x] | 2 | `media-transcoding` | 521 | triaged | 2 overlays per row, both functional tooltips with real labels |
| [x] | 3 | `dns-hole` | 761 | triaged | icons render through `MaskedOrNormalImage`, whose default branch is a CSS mask, so `loading="lazy"` does not apply |
| [x] | 4 | `patchmon` | 1154 | triaged | per-row tooltip is functional |
| [x] | 5 | `beszel` | 1186 | fixed | disk-usage mounted a `HoverCard` per row for systems reporting no filesystems |
| [x] | 6 | `health-monitoring` | 1250 | **fixed** | hidden tab panel built its whole tree |
| [x] | 7 | `media-requests` | 542 | fixed | `content-visibility` + `loading="lazy"` on both images |
| [x] | 8 | `tracearr` | 445 | clean (read) | poster is a CSS background per stream card, and the list is active streams — a handful |
| [x] | 9 | `custom-api` | 1014 | triaged | 6 index keys, all safe: stateless read-only rows from user JSONPath data with no stable id |
| [x] | 10 | `firewall` | 334 | **fixed** | its `Accordion` built the interface rows while collapsed. The 4 inline-style-per-row findings stay declined: children are not memoised, so nothing is defeated |
| [x] | 11 | `releases` | 817 | fixed | `OverflowBadge` mounted a `Popover` per row even with nothing to overflow |
| [x] | 12 | `coolify` | 752 | **fixed** | its `Accordion` built all three resource lists while collapsed; the icon is a fixed constant URL, not per-row data |
| [x] | 13 | `notebook` | 1189 | clean (read) | rich-text editor. Deliberately **not** given `content-visibility`: it measures itself, and skipping its layout would be wrong |
| [x] | 14 | `umami` | 595 | clean (read) | the top-list is bounded by the widget's own `limit` option and each row is three `<Text>` — containment would cost more than it saves |
| [x] | 15 | `traefik` | 289 | clean (read) | already bounded: `.slice(0, getEntryPointLimit(width))` |
| [x] | 16 | `weather` | 481 | triaged | per-row overlay is a functional tooltip |
| [x] | 17 | `system-resources` | 568 | clean (read) | the 14 maps build chart data series, not DOM rows, and none of the six chart components is memoised — so memoising the arrays would defeat nothing. Its data changes every tick anyway |
| [x] | 18 | `app` | 356 | triaged | icon is a masked image; single icon per widget, not a list |
| [x] | 19 | `beszel-alerts` | 288 | clean (read) | fully memoised — input, name map, alerts and history — and history is sliced to `options.maxHistoryItems` |
| [x] | 20 | `beszel-system-stats` | 220 | clean (bounded) | one row per configured system |
| [x] | 21 | `media-missing` | 261 | **fixed** | tabs, lazy poster, off-screen cards |
| [x] | 22 | `media-server` | 457 | clean (read) | the flat session list is explicitly memoised on `currentStreams`, and is bounded by active streams |
| [x] | 23 | `bookmarks` | 545 | triaged | 5 image sites, all masked images |
| [x] | 24 | `immich` | 301 | clean (bounded) | carousel shows one photo at a time |
| [x] | 25 | `ups` | 271 | clean (bounded) | one row per UPS device |
| [x] | 26 | `docker` | 590 | fixed | uses `OverflowBadge` |
| [x] | 27 | `indexer-manager` | 150 | clean (bounded) | one row per configured indexer |
| [x] | 28 | `vpn` | 227 | clean (bounded) | one row per tunnel |
| [x] | 29 | `calendar` | 507 | **fixed** | `HoverCard` per day cell (126 of 171 components), lazy images, off-screen event rows, and the `placehold.co` fallback |
| [x] | 30 | `speedtest-tracker` | 707 | clean (bounded) | no list map in the component |
| [x] | 31 | `system-disks` | 196 | clean (bounded) | one row per physical disk |
| [x] | 32 | `minecraft` | 91 | clean (bounded) | one server icon |
| [x] | 33 | `paperless-ngx` | 255 | clean (bounded) | statistic rows, fixed set |
| [x] | 34 | `uptime-kuma` | 268 | clean (bounded) | one row per monitor |
| [x] | 35 | `iframe` | 148 | clean (bounded) | maps are over option lists, not remote data |
| [x] | 36 | `stocks` | 146 | clean (bounded) | one row per configured symbol |
| [x] | 37 | `beszel-system-grid` | 496 | clean (bounded) | already slices its input |
| [x] | 38 | `audio-stats` | 305 | clean (bounded) | no list map in the component |
| [x] | 39 | `bazarr` | 141 | clean (bounded) | one row per configured instance |
| [x] | 40 | `beszel-system-table` | 488 | fixed | declared `PercentCell` inside the render body, so three cell subtrees per system remounted every second |
| [x] | 41 | `clock` | 213 | clean (read) | re-renders once a second by design; no list |
| [x] | 42 | `media-releases` | 275 | fixed | 80 cards into a 539 px tile; `OverflowBadge`; lazy posters |
| [x] | 43 | `network-controller` | 231 | clean (bounded) | one row per device |
| [x] | 44 | `timetable` | 110 | clean (bounded) | one map over the configured schedule |
| [x] | 45 | `anchor-note` | 340 | clean (read) | editor content, no list; self-measuring like `notebook` |
| [x] | 46 | `notifications` | 113 | fixed | off-screen rows |
| [x] | 47 | `rssFeed` | 131 | fixed | off-screen rows; payload and handler checked |
| [x] | 48 | `smart-home` | 235 | clean (bounded) | one entity per widget |
| [x] | 49 | `archive-team-warrior` | 105 | clean (bounded) | no list map |
| [x] | 50 | `video` | 144 | clean (bounded) | a single video element |
**All 50 have a verdict.** 11 fixed, 8 triaged, 10 read and found clean, 21 verified bounded.
The four the ranking suggested were most likely to hide something all came back clean, and three of
them were *already* bounded before I looked: `traefik` slices to what its width can show, `umami` to
its own `limit` option, `beszel-alerts` to `options.maxHistoryItems`. `system-resources` was the
interesting one — its 14 unmemoised maps look bad until you notice none of the six chart components it
feeds is memoised, so there is nothing for a stable array identity to preserve.
The 21 "bounded" verdicts are the weakest claim here. They rest on the list length being fixed by
configuration or by physical reality — disks, tunnels, configured instances — plus a cheap row with no
image and no overlay. That is sound reasoning, not a reading, and a widget whose data source changes
shape could invalidate it.
## Checked against Vercel's React/Next performance rules
Sourced with the `skills` CLI — `npx skills find "react performance"`, then
`npx skills use affaan-m/ecc@react-performance`, which is an adaptation of
[Vercel Labs `react-best-practices`](https://github.com/vercel-labs/agent-skills): 70+ rules across
8 priority categories. Every rule that is mechanically checkable was run against the widgets.
| rule | category | result |
| --- | --- | --- |
| `content-visibility: auto` for long lists | rendering | **applied** — 6 widgets |
| Don't define components inside components | re-render | **2 found, both fixed** (`PercentCell`, drag handle) |
| Ternary over `&&` for conditional render | rendering | **passes** — only 2 `&&` renders exist and both operands are already boolean or string, so nothing can render a literal `0` |
| Passive listeners for scroll | client fetching | **N/A** — no widget registers a scroll listener |
| `Set`/`Map` for membership over `Array.includes` | JS micro-perf | **passes** — the nested scans that exist are over configured integrations and column accessors, single digits either way. `use-persisted-table-layout` already uses a `Set` for the hot half |
| Hoist RegExp out of loops | JS micro-perf | **passes** — the only user-supplied pattern, the release version filter, is compiled once by `compileVersionRegex` before the `.filter()` that uses it |
| Statically analyzable dynamic imports | bundle | **one exception, justified** — `translation/src/mapping.ts` builds `import(`./lang/${language}.json`)` for 36 languages, 6.8 MiB of JSON. The rule targets accidental over-inclusion; here every language is genuinely reachable because the user can switch at runtime, so the context module includes exactly what is needed. Rewriting it as 36 explicit entries would emit the same 36 lazy chunks |
| `React.cache()` for per-request dedup | server | **applied** — 4 functions |
| TanStack Query for dedup, not hand-rolled `useEffect` + `fetch` | client fetching | **passes** — used throughout |
| Direct imports, not barrels | bundle | **partial** — see below |
| Hoist default non-primitive props | re-render | **declined** — see below |
| `<Activity>` for show/hide instead of mount/unmount | rendering | **deliberately contradicted** — see below |
### The one rule this codebase deliberately contradicts
The skill says to prefer React 19's `<Activity>` over unmounting for tabs, because it avoids
remount cost. `keepMounted={false}`, which this pass added to two widgets, is exactly the opposite.
That is a considered trade, not an oversight. The rule optimises for **switching**; a dashboard
optimises for **mounting**. Every board load mounts every widget, while a user switches a widget's
inner tabs rarely or never — so paying to build a hidden monitoring view on every single page load, and
holding its DOM for the whole session, buys a faster tab switch nobody performs. Where state loss
would be user-visible — `custom-api`'s user JSX, the edit modal's forms — the panels stay mounted.
The cost is real and worth stating: switching tabs in `health-monitoring` or `media-missing` now
remounts the panel. react-query still has the data cached, so it repaints rather than reloads.
### Bundle: the rule does not apply here at all
The "direct imports, not barrels" rule, and the whole `optimizePackageImports` lever behind it, is
**inert in this repo** — measured, then explained.
Seven Mantine entry points were missing from `optimizePackageImports`: `dates` (13 files), `dropzone`
(12), `charts` (10), `spotlight` (7), `form` (4), `notifications` (3), `tiptap` (1). Adding all seven
and rebuilding produced a client bundle that was **byte-for-byte identical** — 482 files, 18050.7 KiB
in both arms, down to the individual chunk hashes.
Identical output including hashes looks like a cached build, especially with
`turbopackFileSystemCacheForBuild` enabled, so it was checked instead of reported. The Next.js docs
settle it: *"This is not needed when using Turbopack. Turbopack automatically analyzes and optimizes
imports without requiring this configuration."* This app builds with Turbopack — the build banner says
so — and the config is dead weight.
Which also disposes of the sub-question underneath it. `@homarr/ui` is the largest barrel in the repo
at 111 importers, and I had noted it as ineligible because Next's barrel optimiser only rewrites pure
re-export files while `packages/ui/src/index.ts` also defines a runtime `uiConfiguration`. True, and
irrelevant: Turbopack does not use that optimiser and tree-shakes the barrel regardless.
Measuring first-load JS per route was not possible the usual way, incidentally: Next 16 + Turbopack
prints no size columns in the route table and emits no `app-build-manifest.json`. Total bytes under
`.next/static/chunks` is the substitute.
### `data = []` defaults: declined, with the reasoning
The rule against fresh non-primitive defaults (`items ?? []` creating a new array identity per render)
matches **20+ sites**, all of the form `const { data = [] } = useQuery(...)`.
It is declined because the window is narrow: react-query returns a stable reference once data has
loaded, so the fresh `[]` only appears while `data` is `undefined` — initial load, error, or no
integration configured. Outside that window the default never evaluates and nothing is destabilised.
20 edits to improve the render path of the loading state, with no measurement, is the same churn as the
29 inline-style sites already declined.
## Investigated and declined, with reasons
**Lazy-loading the shared `MaskedOrNormalImage`.** This looked like one change covering `releases`,
`bookmarks`, `dns-hole` and `app` at once. It is not: `hasColor` defaults to `true`, so the default
branch renders a CSS `mask-image`, not an `<img>`, and `loading="lazy"` has nothing to attach to. Only
the minority `hasColor={false}` branch would benefit. The row-level lever for masked icons is
`content-visibility`, not lazy loading.
**RSS payload size.** Feed entries looked like a likely over-fetch, since RSS `description` is often a
whole article. `@extractus/feed-extractor` truncates it at `descriptionMaxLen`, default **250**
characters, so the payload is already bounded. The router also limits entries twice — `count` at the
handler and `.slice(0, maximumAmountPosts)` after merging. No finding.
**Widget routers without result limits.** Only 4 of 39 widget routers limit their results, which reads
alarming, but the rest return data whose length is set by the remote service (containers, interfaces,
active downloads) rather than by anything unbounded. Not a defect on its own.
**Per-row inline styles (29 sites).** A fresh object per row only defeats memoisation if the child is
memoised, and these children are not. 29 edits with no measurement behind them is churn.
**Index keys (11 sites).** All safe — static text splits, or stateless read-only rows from
user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts.
## One instrument corrected
The inventory first reported `downloads` — the top-ranked widget — as "12 maps, no memo", because it
counted `memo(` and not `useMemo(`. `downloads` is in fact one of the better-memoised widgets in the
codebase. Fixed to count either form before the ranking was used for anything.
That is the sixth time in this work that a measurement, rather than the application, was the thing at
fault — and this pass produced a seventh of a different kind: I reported that the `skills` CLI did not
exist on this machine after checking only `which` and the plugin directory. It does, and it is where
the Vercel rule set above came from. Both are recorded in [`METHODOLOGY.md`](./METHODOLOGY.md).
+2 -1
View File
@@ -45,7 +45,8 @@
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --"
"with-env": "dotenv -e .env --",
"docker:profiling": "docker build --build-arg HOMARR_PROFILING=true -t homarr:performance . && node --experimental-strip-types scripts/benchmarks/verify-react-profiling.mts homarr:performance"
},
"dependencies": {
"jsonpath-plus": "catalog:"
+2
View File
@@ -10,6 +10,8 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./cache-invalidation": "./src/cache-invalidation.ts",
"./cache-tags": "./src/cache-tags.ts",
"./query-cache": "./src/query-cache.ts",
"./server-settings-server": "./src/server-settings-server.ts",
"./user-server": "./src/user-server.ts",
@@ -0,0 +1,127 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getCachedIntegrationData, invalidateIntegrationDataCache } from "./integration-data-cache";
afterEach(() => {
invalidateIntegrationDataCache("integration-a");
invalidateIntegrationDataCache("integration-b");
});
describe("getCachedIntegrationData", () => {
test("returns cached data within TTL without refetching", async () => {
const fetcher = vi.fn(async () => ({ value: 1 }));
await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({
value: 1,
});
await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({
value: 1,
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
test("serves stale data while revalidating after TTL", async () => {
vi.useFakeTimers();
const fetcher = vi
.fn()
.mockResolvedValueOnce({ value: 1 })
.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve({ value: 2 }), 100)));
await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50);
vi.advanceTimersByTime(60);
const staleResult = await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50);
expect(staleResult).toEqual({ value: 1 });
expect(fetcher).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(100);
await vi.advanceTimersByTimeAsync(0);
await expect(getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50)).resolves.toEqual({ value: 2 });
vi.useRealTimers();
});
test("throws on initial fetch failure when no stale data exists", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("upstream down"));
await expect(getCachedIntegrationData("integration-a", "q1", fetcher)).rejects.toThrow("upstream down");
});
test("returns stale data when refetch fails", async () => {
vi.useFakeTimers();
const fetcher = vi
.fn()
.mockResolvedValueOnce({ value: 1 })
.mockRejectedValueOnce(new Error("upstream unavailable"));
await getCachedIntegrationData("integration-a", "widget:error", fetcher, 50);
vi.advanceTimersByTime(60);
await expect(getCachedIntegrationData("integration-a", "widget:error", fetcher, 50)).resolves.toEqual({
value: 1,
});
vi.useRealTimers();
});
test("concurrent cold-cache calls coalesce into one fetch, never return undefined", async () => {
let resolveOuter!: (v: { value: number }) => void;
const fetcher = vi.fn(
() =>
new Promise<{ value: number }>((resolve) => {
resolveOuter = resolve;
}),
);
const p1 = getCachedIntegrationData("integration-a", "widget:concurrent", fetcher);
const p2 = getCachedIntegrationData("integration-a", "widget:concurrent", fetcher);
resolveOuter({ value: 42 });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual({ value: 42 });
expect(r2).toEqual({ value: 42 });
expect(fetcher).toHaveBeenCalledTimes(1);
});
test("invalidation during pending fetch prevents stale write-back", async () => {
let resolveOuter!: (v: { value: string }) => void;
const fetcher = vi.fn(
() =>
new Promise<{ value: string }>((resolve) => {
resolveOuter = resolve;
}),
);
const p1 = getCachedIntegrationData("integration-a", "widget:race", fetcher);
invalidateIntegrationDataCache("integration-a");
resolveOuter({ value: "stale" });
await p1;
const freshFetcher = vi.fn(async () => ({ value: "fresh" }));
const result = await getCachedIntegrationData("integration-a", "widget:race", freshFetcher);
expect(result).toEqual({ value: "fresh" });
expect(freshFetcher).toHaveBeenCalledTimes(1);
});
test("invalidates all cache entries for an integration", async () => {
const fetcherOne = vi.fn(async () => ({ value: "one" }));
const fetcherTwo = vi.fn(async () => ({ value: "two" }));
const fetcherOther = vi.fn(async () => ({ value: "other" }));
await getCachedIntegrationData("integration-a", "widget:one", fetcherOne);
await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo);
await getCachedIntegrationData("integration-b", "widget:one", fetcherOther);
invalidateIntegrationDataCache("integration-a");
await getCachedIntegrationData("integration-a", "widget:one", fetcherOne);
await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo);
await getCachedIntegrationData("integration-b", "widget:one", fetcherOther);
expect(fetcherOne).toHaveBeenCalledTimes(2);
expect(fetcherTwo).toHaveBeenCalledTimes(2);
expect(fetcherOther).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,75 @@
const isMultiInstance = process.env.REDIS_IS_EXTERNAL === "true";
interface CachedEntry<T> {
data: T;
timestamp: number;
pending?: Promise<T>;
}
const cache = new Map<string, CachedEntry<unknown>>();
const DEFAULT_TTL_MS = 60_000;
const MAX_ENTRIES = 200;
export const getCachedIntegrationData = async <T>(
integrationId: string,
queryKey: string,
fetcher: () => Promise<T>,
ttlMs = DEFAULT_TTL_MS,
): Promise<T> => {
if (isMultiInstance) {
return fetcher();
}
const key = `${integrationId}:${queryKey}`;
const existing = cache.get(key) as CachedEntry<T> | undefined;
const now = Date.now();
if (existing?.pending) return existing.timestamp > 0 ? existing.data : existing.pending;
if (existing && existing.timestamp > 0 && now - existing.timestamp < ttlMs) {
return existing.data;
}
// Set pending synchronously BEFORE creating the fetch promise to prevent
// concurrent stale-revalidation from spawning duplicate upstream calls.
const placeholder = existing ?? { data: undefined as T, timestamp: 0 };
if (!existing) cache.set(key, placeholder);
const pending = fetcher()
.then((data) => {
if (cache.get(key) === placeholder) {
cache.set(key, { data, timestamp: Date.now() });
if (cache.size > MAX_ENTRIES) {
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [k, e] of cache) {
if (e.timestamp < oldestTime) {
oldestTime = e.timestamp;
oldestKey = k;
}
}
if (oldestKey) cache.delete(oldestKey);
}
}
return data;
})
.catch((error) => {
const entry = cache.get(key) as CachedEntry<T> | undefined;
if (entry) {
delete entry.pending;
if (entry.timestamp > 0) return entry.data;
}
cache.delete(key);
throw error;
});
placeholder.pending = pending;
return existing ? existing.data : pending;
};
export const invalidateIntegrationDataCache = (integrationId: string) => {
const prefix = `${integrationId}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) cache.delete(key);
}
};
@@ -5,7 +5,7 @@ import type { Database } from "@homarr/db";
import { eq, or } from "@homarr/db";
import { items } from "@homarr/db/schema";
import type { WidgetComponentProps } from "../../../../widgets/src";
import type { WidgetComponentProps } from "../../../../widgets/src/definition";
export class AppAccessControl {
constructor(
@@ -2,6 +2,8 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
@@ -37,6 +39,7 @@ import {
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
@@ -368,6 +371,9 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
await invalidateIntegrationCacheAsync(integrationId);
invalidateIntegrationDataCache(integrationId);
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -474,6 +480,7 @@ export const integrationRouter = createTRPCRouter({
// Invalidate all cached data for this integration so that widgets pick up the
// new configuration immediately instead of serving stale (or errored) data.
await invalidateIntegrationCacheAsync(input.id);
invalidateIntegrationDataCache(input.id);
}),
delete: protectedProcedure
.meta({
@@ -498,6 +505,7 @@ export const integrationRouter = createTRPCRouter({
// Clean up any cached data left behind by the deleted integration.
await invalidateIntegrationCacheAsync(input.id);
invalidateIntegrationDataCache(input.id);
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -616,6 +624,8 @@ export const integrationRouter = createTRPCRouter({
});
},
});
invalidateIntegrationDataCache(input.entityId);
}),
saveGroupIntegrationPermissions: protectedProcedure
.input(integrationSavePermissionsSchema)
@@ -662,6 +672,8 @@ export const integrationRouter = createTRPCRouter({
});
},
});
invalidateIntegrationDataCache(input.entityId);
}),
searchInIntegration: protectedProcedure
.meta({
@@ -781,7 +793,11 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
invalidateIntegrationDataCache(ctx.integration.id);
mediaRequestListRequestHandler.invalidateCache();
mediaRequestStatsRequestHandler.invalidateCache();
return result;
}),
});
@@ -1,10 +1,15 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import type { Integration } from "@homarr/integrations";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
const mockIntegration = (): Pick<Integration, "testConnectionAsync"> => ({
testConnectionAsync: async () => ({ success: true }),
});
vi.mock("@homarr/common/server", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/common/server")>();
@@ -20,10 +25,8 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -63,10 +66,8 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -113,10 +114,8 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -163,10 +162,8 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
@@ -217,10 +214,8 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
@@ -7,6 +7,7 @@ import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrat
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -75,6 +76,9 @@ export const anchorNotesRouter = createTRPCRouter({
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
invalidateIntegrationDataCache(ctx.integration.id);
anchorNoteRequestHandler.invalidateCache();
anchorNotesListRequestHandler.invalidateCache();
return updatedNote;
} catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) {
+6 -1
View File
@@ -10,7 +10,7 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -47,6 +47,7 @@ export const beszelRouter = createTRPCRouter({
};
},
{
queryKey: integrationQueryKey("beszel", "getSystems"),
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
@@ -119,6 +120,10 @@ export const beszelRouter = createTRPCRouter({
};
},
{
queryKey: integrationQueryKey("beszel", "getAlerts", {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
}),
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
+16 -12
View File
@@ -1,24 +1,28 @@
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("coolify", "getInstancesInfo") },
);
}),
});
+17 -8
View File
@@ -5,7 +5,8 @@ import { createIntegrationAsync } from "@homarr/integrations";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
@@ -19,13 +20,17 @@ export const dnsHoleRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
},
{ queryKey: integrationQueryKey("dns-hole", "summary") },
);
}),
enable: protectedProcedure
@@ -40,6 +45,8 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
invalidateIntegrationDataCache(integration.id);
dnsHoleRequestHandler.invalidateCache();
}),
disable: protectedProcedure
@@ -59,5 +66,7 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
invalidateIntegrationDataCache(integration.id);
dnsHoleRequestHandler.invalidateCache();
}),
});
+33 -9
View File
@@ -6,7 +6,8 @@ import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
@@ -24,14 +25,22 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
},
{
queryKey: integrationQueryKey("downloads", "getJobsAndStatuses", {
limitPerIntegration: input.limitPerIntegration,
}),
},
);
}),
pause: protectedProcedure
.meta({
@@ -49,6 +58,9 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseQueueAsync();
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
@@ -61,6 +73,9 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseItemAsync(input.item);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
@@ -79,6 +94,9 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeQueueAsync();
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
@@ -91,6 +109,9 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeItemAsync(input.item);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
@@ -103,6 +124,9 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
});
+29 -12
View File
@@ -7,7 +7,7 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
@@ -18,20 +18,37 @@ const queryFirewall = <
},
>(
handler: THandler,
queryKey: string,
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
},
{ queryKey },
),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
getFirewallCpuStatus: queryFirewall(
firewallCpuRequestHandler,
integrationQueryKey("firewall", "getFirewallCpuStatus"),
),
getFirewallInterfacesStatus: queryFirewall(
firewallInterfacesRequestHandler,
integrationQueryKey("firewall", "getFirewallInterfacesStatus"),
),
getFirewallVersionStatus: queryFirewall(
firewallVersionRequestHandler,
integrationQueryKey("firewall", "getFirewallVersionStatus"),
),
getFirewallMemoryStatus: queryFirewall(
firewallMemoryRequestHandler,
integrationQueryKey("firewall", "getFirewallMemoryStatus"),
),
});
@@ -1,9 +1,8 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
@@ -27,15 +26,19 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("health-monitoring", "getSystemHealthStatus") },
);
}),
listStorageVolumes: publicProcedure
.meta({
@@ -47,11 +50,12 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
if (ctx.integration.kind !== "synology") {
throw new Error("Expected Synology integration");
}
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
@@ -6,7 +6,8 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -16,15 +17,19 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
indexers,
};
});
return {
integrationId: integration.id,
indexers,
};
},
{ queryKey: integrationQueryKey("indexer-manager", "getIndexersStatus") },
);
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -41,5 +46,9 @@ export const indexerManagerRouter = createTRPCRouter({
});
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
indexerManagerRequestHandler.invalidateCache();
}),
});
@@ -2,27 +2,31 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
},
{ queryKey: integrationQueryKey("media-release", "getMediaReleases") },
);
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
@@ -10,7 +10,8 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({
@@ -25,10 +26,14 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ queryKey: integrationQueryKey("media-requests", "getLatestRequests", input) },
);
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -57,10 +62,14 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ queryKey: integrationQueryKey("media-requests", "getStats") },
);
return {
stats: results.flatMap((result) => result.data.stats),
users: results
@@ -99,5 +108,8 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
invalidateIntegrationDataCache(integration.id);
mediaRequestListRequestHandler.invalidateCache();
mediaRequestStatsRequestHandler.invalidateCache();
}),
});
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -23,11 +23,19 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
},
{
queryKey: integrationQueryKey("media-server", "getCurrentStreams", {
showOnlyPlaying: input.showOnlyPlaying,
}),
},
);
}),
});
@@ -2,26 +2,30 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("network-controller", "summary") },
);
}),
});
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -13,19 +13,23 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
},
{ queryKey: integrationQueryKey("notifications", "getNotifications") },
);
}),
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { eq } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import type { WidgetOptionsSettings } from "../../../../widgets/src/definition";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
+1 -1
View File
@@ -9,7 +9,7 @@ import { items, users } from "@homarr/db/schema";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../../widgets/src";
import type { WidgetComponentProps } from "../../../../widgets/src/definition";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
@@ -6,6 +6,7 @@ import { smartHomeEntityStateRequestHandler } from "@homarr/request-handler/smar
import type { IntegrationAction } from "../../middlewares/integration";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createSmartHomeIntegrationMiddleware = (action: IntegrationAction) =>
@@ -41,6 +42,8 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
invalidateIntegrationDataCache(integration.id);
smartHomeEntityStateRequestHandler.invalidateCache();
return success;
}),
executeAutomation: protectedProcedure
@@ -56,5 +59,7 @@ export const smartHomeRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
invalidateIntegrationDataCache(integration.id);
smartHomeEntityStateRequestHandler.invalidateCache();
}),
});
@@ -1,24 +1,28 @@
import { speedtestTrackerRequestHandler } from "@homarr/request-handler/speedtest-tracker";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const speedtestTrackerRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "speedtestTracker"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = speedtestTrackerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("speedtest-tracker", "getDashboard") },
);
}),
});
+16 -12
View File
@@ -1,22 +1,26 @@
import { tracearrRequestHandler } from "@homarr/request-handler/tracearr";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const tracearrRouter = createTRPCRouter({
getDashboard: publicProcedure.concat(createManyIntegrationMiddleware("query", "tracearr")).query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = tracearrRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("tracearr", "getDashboard") },
);
}),
});
+16 -12
View File
@@ -1,7 +1,7 @@
import { traefikRequestHandler } from "@homarr/request-handler/traefik";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const traefikRouter = createTRPCRouter({
@@ -15,17 +15,21 @@ export const traefikRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", "traefik"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = traefikRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("traefik", "getDashboard") },
);
}),
});
+26 -16
View File
@@ -13,7 +13,7 @@ import {
} from "@homarr/request-handler/umami";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "umami-router" });
@@ -39,22 +39,32 @@ export const umamiRouter = createTRPCRouter({
)
.concat(createManyIntegrationMiddleware("query", "umami"))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = umamiRequestHandler.handler(integration, {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
visitorStats: data,
updatedAt: timestamp,
};
},
{
queryKey: integrationQueryKey("umami", "getVisitorStats", {
websiteId: input.websiteId,
timeFrame: input.timeFrame,
eventName: input.eventName,
}),
},
);
}),
getEventNames: publicProcedure
+16 -12
View File
@@ -2,24 +2,28 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { upsSummariesRequestHandler } from "@homarr/request-handler/ups";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const upsRouter = createTRPCRouter({
getSummaries: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("ups")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = upsSummariesRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
summaries: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("ups", "getSummaries") },
);
}),
});
+16 -12
View File
@@ -1,24 +1,28 @@
import { uptimeKumaRequestHandler } from "@homarr/request-handler/uptime-kuma";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const uptimeKumaRouter = createTRPCRouter({
getDashboard: publicProcedure
.concat(createManyIntegrationMiddleware("query", "uptimeKuma"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = uptimeKumaRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
});
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
dashboard: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("uptime-kuma", "getDashboard") },
);
}),
});
+17 -13
View File
@@ -2,24 +2,28 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { vpnSummaryHandler } from "@homarr/request-handler/vpn";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const vpnRouter = createTRPCRouter({
getSummaries: publicProcedure
.unstable_concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("vpn")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
});
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await vpnSummaryHandler.handler(integration, {}).getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
summary: data,
};
},
{ queryKey: integrationQueryKey("vpn", "getSummaries") },
);
}),
});
+8 -3
View File
@@ -1,8 +1,13 @@
import "server-only";
import { cache } from "react";
import { db } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
// One settings snapshot per RSC request, shared by the root layout, theme, and
// widget prefetching. React invalidates this cache between requests.
export const getRscServerSettingsAsync = cache(async () => await getServerSettingsAsync(db));
// ponytail: React cache() dedupes within a single request render.
// The `use cache` layer (when enabled) dedupes across requests.
// Keep both: inner avoids repeated deserialization of cached payload per render.
export const getRscServerSettingsAsync = cache(async () => {
return await getServerSettingsAsync(db);
});
+18 -1
View File
@@ -1,6 +1,8 @@
import { createLogger } from "@homarr/core/infrastructure/logs";
import { ErrorWithMetadata } from "@homarr/core/infrastructure/logs/error";
import { getCachedIntegrationData } from "./integration-data-cache";
const logger = createLogger({ module: "settleIntegrations" });
interface IntegrationLike {
@@ -11,14 +13,29 @@ interface IntegrationLike {
interface Options<TIntegration extends IntegrationLike, TResult> {
fallback?: (integration: TIntegration, error: unknown) => TResult;
/** Widget procedure key, e.g. `downloads:getJobsAndStatuses:{"limitPerIntegration":50}` */
queryKey?: string;
}
export const integrationQueryKey = (widget: string, procedure: string, params?: unknown) => {
if (params === undefined) return `${widget}:${procedure}`;
return `${widget}:${procedure}:${JSON.stringify(params)}`;
};
export async function settleIntegrationQueries<TIntegration extends IntegrationLike, TResult>(
integrations: TIntegration[],
fn: (integration: TIntegration) => Promise<TResult>,
options?: Options<TIntegration, TResult>,
): Promise<TResult[]> {
const settled = await Promise.allSettled(integrations.map(async (integration) => fn(integration)));
const queryKey = options?.queryKey;
const runQuery = async (integration: TIntegration) => {
if (queryKey) {
return getCachedIntegrationData(integration.id, queryKey, () => fn(integration));
}
return fn(integration);
};
const settled = await Promise.allSettled(integrations.map(runQuery));
const results: TResult[] = [];
const errors: unknown[] = [];
+8 -1
View File
@@ -18,7 +18,14 @@ export const useTimeAgo = (timestamp: Date, updateFrequency = 1000) => {
}, [timestamp]);
useEffect(() => {
const intervalId = setInterval(() => setTimeAgo(calculateTimeAgo(timestamp)), updateFrequency);
// Only re-render when the rendered text actually changes. The label is coarse
// ("3 minutes ago" holds for a whole minute) but the timer ticks every second,
// so setting state unconditionally re-rendered the subtree ~60x/minute per
// consumer for an identical string.
const intervalId = setInterval(() => {
const next = calculateTimeAgo(timestamp);
setTimeAgo((previous) => (previous === next ? previous : next));
}, updateFrequency);
return () => clearInterval(intervalId); // clear interval on hook unmount
}, [timestamp, updateFrequency]);
+1 -1
View File
@@ -11,4 +11,4 @@ export * from "./number";
export * from "./error";
export * from "./theme";
export * from "./function";
export * from "./id";
export * from "./retryable-loader";
+18
View File
@@ -0,0 +1,18 @@
export function createRetryableLoader<TKey, TValue>(loaders: ReadonlyMap<TKey, () => Promise<TValue>>) {
const promises = new Map<TKey, Promise<TValue>>();
return function load(key: TKey) {
const existing = promises.get(key);
if (existing) return existing;
const loader = loaders.get(key);
if (!loader) throw new Error(`No loader is registered for ${String(key)}`);
const promise = loader();
promises.set(key, promise);
void promise.catch(() => {
if (promises.get(key) === promise) promises.delete(key);
});
return promise;
};
}
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
import type { AgentOptions } from "node:https";
import { Agent as HttpsAgent } from "node:https";
import { checkServerIdentity } from "node:tls";
import { checkServerIdentity, rootCertificates } from "node:tls";
import axios from "axios";
import type { Agent as UndiciAgent, RequestInfo, RequestInit, Response } from "undici";
import { fetch } from "undici";
@@ -8,6 +9,7 @@ import { fetch } from "undici";
import {
getAllTrustedCertificatesAsync,
getTrustedCertificateHostnamesAsync,
loadCustomRootCertificatesAsync,
} from "@homarr/core/infrastructure/certificates";
import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
@@ -29,6 +31,59 @@ export const createCustomCheckServerIdentity = (
};
};
/**
* An agent owns its connection pool and its TLS session cache, so building a new one per request
* makes every request pay a full handshake. These caches hold one agent per configuration and are
* discarded whenever the trust material changes.
*
* The trust material itself is still read on every call, so adding or removing a certificate - by any
* means, including dropping a file into the mounted folder - takes effect on the very next request,
* exactly as it did before.
*/
const undiciAgentCache = new Map<string, UndiciHttpAgent>();
const httpsAgentCache = new Map<string, HttpsAgent>();
let cachedTrustFingerprint: string | null = null;
/**
* Reads the current trust material and invalidates the agent caches if it changed.
* Node's root store is a constant, so only the custom certificates and trusted hostnames are
* hashed - on a default install that is nothing at all.
*/
const getTrustMaterialAsync = async () => {
const [customCertificates, hostnames] = await Promise.all([
loadCustomRootCertificatesAsync(),
getTrustedCertificateHostnamesAsync(),
]);
const fingerprint = createHash("sha1")
.update(
JSON.stringify([
customCertificates.map((certificate) => [certificate.fileName, certificate.content]),
hostnames.map((hostname) => [hostname.hostname, hostname.thumbprint]),
]),
)
.digest("hex");
if (fingerprint !== cachedTrustFingerprint) {
// undici's close() waits for enqueued requests to finish, so releasing the pool cannot interrupt
// a request already in flight.
//
// The node agents are deliberately *not* destroyed: agent.destroy() destroys sockets that are
// currently in use, so an admin adding a certificate could abort an in-flight calendar sync.
// They run without keepAlive, so dropping the reference is enough - each socket closes once its
// own response completes.
for (const agent of undiciAgentCache.values()) void agent.close();
undiciAgentCache.clear();
httpsAgentCache.clear();
cachedTrustFingerprint = fingerprint;
}
return {
hostnames,
getCertificates: () => rootCertificates.concat(customCertificates.map((certificate) => certificate.content)),
};
};
export const createCertificateAgentAsync = async (
override?: {
ca: string | string[];
@@ -36,23 +91,49 @@ export const createCertificateAgentAsync = async (
},
agentOptions?: Pick<UndiciAgent.Options, "bodyTimeout">,
) => {
return new UndiciHttpAgent({
// A caller bringing its own trust material gets its own agent rather than polluting the cache.
if (override) {
return new UndiciHttpAgent({ ...agentOptions, connect: override });
}
const trust = await getTrustMaterialAsync();
const cacheKey = `bodyTimeout:${agentOptions?.bodyTimeout ?? "default"}`;
const cached = undiciAgentCache.get(cacheKey);
if (cached) return cached;
const agent = new UndiciHttpAgent({
...agentOptions,
connect: override ?? {
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
connect: {
ca: trust.getCertificates(),
checkServerIdentity: createCustomCheckServerIdentity(trust.hostnames),
},
});
undiciAgentCache.set(cacheKey, agent);
return agent;
};
export const createHttpsAgentAsync = async (override?: Pick<AgentOptions, "ca" | "checkServerIdentity">) => {
return new HttpsAgent({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
// Override the ca and checkServerIdentity if provided
...override,
if (override) {
return new HttpsAgent({
ca: await getAllTrustedCertificatesAsync(),
checkServerIdentity: createCustomCheckServerIdentity(await getTrustedCertificateHostnamesAsync()),
// Override the ca and checkServerIdentity if provided
...override,
proxyEnv: process.env,
});
}
const trust = await getTrustMaterialAsync();
const cached = httpsAgentCache.get("default");
if (cached) return cached;
const agent = new HttpsAgent({
ca: trust.getCertificates(),
checkServerIdentity: createCustomCheckServerIdentity(trust.hostnames),
proxyEnv: process.env,
});
httpsAgentCache.set("default", agent);
return agent;
};
export const createAxiosCertificateInstanceAsync = async (
@@ -0,0 +1,174 @@
// @vitest-environment node
import { beforeEach, describe, expect, test, vi } from "vitest";
const trustMaterial = vi.hoisted(() => ({
certificates: [] as { content: string; fileName: string }[],
hostnames: [] as { hostname: string; thumbprint: string; certificate: string }[],
loadCalls: 0,
}));
vi.mock("@homarr/core/infrastructure/certificates", () => ({
loadCustomRootCertificatesAsync: () => {
trustMaterial.loadCalls++;
return Promise.resolve(trustMaterial.certificates);
},
getTrustedCertificateHostnamesAsync: () => Promise.resolve(trustMaterial.hostnames),
getAllTrustedCertificatesAsync: () => Promise.resolve(trustMaterial.certificates.map(({ content }) => content)),
}));
// The agent caches live at module scope, so every test needs a fresh copy of the module.
const importRequestModuleAsync = async () => {
vi.resetModules();
return await import("../../../infrastructure/http/request");
};
beforeEach(() => {
trustMaterial.certificates = [];
trustMaterial.hostnames = [];
trustMaterial.loadCalls = 0;
});
describe("createCertificateAgentAsync", () => {
test("should reuse one agent so requests share a connection pool", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
const first = await createCertificateAgentAsync();
const second = await createCertificateAgentAsync();
// Assert
expect(second).toBe(first);
});
test("should still read the trust material on every call", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
await createCertificateAgentAsync();
await createCertificateAgentAsync();
// Assert
expect(trustMaterial.loadCalls).toBe(2);
});
test("should replace the agent when a certificate is added", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const before = await createCertificateAgentAsync();
const closeSpy = vi.spyOn(before, "close");
// Act
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
const after = await createCertificateAgentAsync();
// Assert
expect(after).not.toBe(before);
expect(closeSpy).toHaveBeenCalled();
});
test("should replace the agent when a trusted hostname changes", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const before = await createCertificateAgentAsync();
// Act
trustMaterial.hostnames = [{ hostname: "homarr.dev", thumbprint: "AA:BB", certificate: "cert" }];
const after = await createCertificateAgentAsync();
// Assert
expect(after).not.toBe(before);
});
test("should not share an agent between different body timeouts", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
// Act
const streaming = await createCertificateAgentAsync(undefined, { bodyTimeout: 0 });
const regular = await createCertificateAgentAsync();
// Assert
expect(streaming).not.toBe(regular);
expect(await createCertificateAgentAsync(undefined, { bodyTimeout: 0 })).toBe(streaming);
});
test("should give a caller supplying its own trust material a dedicated agent", async () => {
// Arrange
const { createCertificateAgentAsync } = await importRequestModuleAsync();
const shared = await createCertificateAgentAsync();
const override = { ca: "custom", checkServerIdentity: () => undefined };
// Act
const first = await createCertificateAgentAsync(override);
const second = await createCertificateAgentAsync(override);
// Assert
expect(first).not.toBe(shared);
expect(second).not.toBe(first);
// The cached agent must survive a call that opted out of the cache.
expect(await createCertificateAgentAsync()).toBe(shared);
});
});
describe("createHttpsAgentAsync", () => {
test("should reuse one agent so TLS sessions can be resumed", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
// Act
const first = await createHttpsAgentAsync();
const second = await createHttpsAgentAsync();
// Assert
expect(second).toBe(first);
});
test("should replace the agent when a certificate is added", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
const before = await createHttpsAgentAsync();
// Act
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
const after = await createHttpsAgentAsync();
// Assert
expect(after).not.toBe(before);
});
test("should not destroy a superseded agent, which would abort requests in flight", async () => {
// agent.destroy() destroys sockets that are currently in use, so destroying the old agent when an
// admin adds a certificate could abort an in-flight calendar sync. These agents run without
// keepAlive, so dropping the reference is enough.
const { createHttpsAgentAsync } = await importRequestModuleAsync();
const before = await createHttpsAgentAsync();
// keepAlive is a real runtime property but is absent from @types/node's public Agent surface.
// It is asserted because it is the premise of not destroying: without keepAlive, a dropped agent's
// sockets close on their own once their response completes.
expect((before as unknown as { keepAlive: boolean }).keepAlive).toBe(false);
const destroySpy = vi.spyOn(before, "destroy");
// Act
trustMaterial.certificates = [{ fileName: "added.crt", content: "-----BEGIN CERTIFICATE-----" }];
const after = await createHttpsAgentAsync();
// Assert
expect(after).not.toBe(before);
expect(destroySpy).not.toHaveBeenCalled();
});
test("should give a caller supplying an override a dedicated agent", async () => {
// Arrange
const { createHttpsAgentAsync } = await importRequestModuleAsync();
const shared = await createHttpsAgentAsync();
// Act
const overridden = await createHttpsAgentAsync({ ca: "custom" });
// Assert
expect(overridden).not.toBe(shared);
expect(await createHttpsAgentAsync()).toBe(shared);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import type { WidgetKind } from "@homarr/definitions";
import type { Database } from "..";
import { inArray } from "..";
import type { inferSupportedIntegrations } from "../../widgets/src";
import type { inferSupportedIntegrations } from "../../widgets/src/import";
import { items } from "../schema";
export const getItemsWithIntegrationsAsync = async <TKind extends WidgetKind>(
+13 -4
View File
@@ -19,8 +19,11 @@ const createUrlByIdChannel = (id: string) =>
headers: `${string}.${string}`;
}>(`image-proxy:url:${id}`);
/** The upstream body, forwarded as-is rather than buffered. */
type ProxiedImageBody = Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>["body"];
export type ForwardImageResult =
| { image: ArrayBuffer; contentType: string | null }
| { image: ProxiedImageBody; contentType: string | null; contentLength: string | null }
| { error: "not-found" }
| { error: "upstream-error"; statusCode: number }
| { error: "fetch-error" };
@@ -94,16 +97,22 @@ export class ImageProxy {
return { error: "upstream-error", statusCode: response.status };
}
const arrayBuffer = await response.arrayBuffer();
const contentLength = response.headers.get("content-length");
logger.debug("Forwarding image succeeded", {
id,
url: this.redactUrl(urlAndHeaders.url),
headers: this.redactHeaders(urlAndHeaders.headers),
proxyUrl,
size: `${(arrayBuffer.byteLength / 1024).toFixed(1)}KB`,
size: contentLength ? `${(Number(contentLength) / 1024).toFixed(1)}KB` : "unknown",
});
return { image: arrayBuffer, contentType: response.headers.get("content-type") };
// The body is forwarded as a stream rather than buffered: a board can request dozens of posters
// at once, and buffering held every one of them in memory in full before responding.
return {
image: response.body,
contentType: response.headers.get("content-type"),
contentLength,
};
}
private createImageUrl(id: string): string {
@@ -0,0 +1,11 @@
import { describe, expect, it } from "vitest";
import { integrationKinds } from "@homarr/definitions";
import { integrationLoaderKinds } from "./creator";
describe("integration creator", () => {
it("should have a loader for every IntegrationKind", () => {
expect(new Set(integrationLoaderKinds)).toEqual(new Set(integrationKinds));
});
});
+87 -130
View File
@@ -1,146 +1,103 @@
import type { IntegrationKind } from "@homarr/definitions";
import { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import { BeszelIntegration } from "../beszel/beszel-integration";
import { BazarrIntegration } from "../bazarr/bazarr-integration";
import { AnchorIntegration } from "../anchor/anchor-integration";
import { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import { CoolifyIntegration } from "../coolify/coolify-integration";
import { DashDotIntegration } from "../dashdot/dashdot-integration";
import { Aria2Integration } from "../download-client/aria2/aria2-integration";
import { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
import { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import { EmbyIntegration } from "../emby/emby-integration";
import { GlancesIntegration } from "../glances/glances-integration";
import { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import { GluetunIntegration } from "../gluetun/gluetun-integration";
import { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import { ICalIntegration } from "../ical/ical-integration";
import { ImmichIntegration } from "../immich/immich-integration";
import { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import { MockIntegration } from "../mock/mock-integration";
import { NavidromeIntegration } from "../navidrome/navidrome-integration";
import { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import { GotifyIntegration } from "../gotify/gotify-integration";
import { NTFYIntegration } from "../ntfy/ntfy-integration";
import { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import { OverseerrIntegration } from "../overseerr/overseerr-integration";
import { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import { PatchMonIntegration } from "../patchmon/patchmon-integration";
import { PeaNutIntegration } from "../peanut/peanut-integration";
import { createPiHoleIntegrationAsync } from "../pi-hole/pi-hole-integration-factory";
import { createTechnitiumDnsIntegrationAsync } from "../technitium/technitium-integration-factory";
import { PlexIntegration } from "../plex/plex-integration";
import { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import { SeerrIntegration } from "../seerr/seerr-integration";
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import { TracearrIntegration } from "../tracearr/tracearr-integration";
import { TraefikIntegration } from "../traefik/traefik-integration";
import { SynologyIntegration } from "../synology/synology-integration";
import { TrueNasIntegration } from "../truenas/truenas-integration";
import { UmamiIntegration } from "../umami/umami-integration";
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import { UnraidIntegration } from "../unraid/unraid-integration";
import { WudIntegration } from "../wud/wud-integration";
import type { Integration, IntegrationInput } from "./integration";
import { createRetryableLoader } from "@homarr/common";
export const createIntegrationAsync = async <TKind extends keyof typeof integrationCreators>(
import type { Integration, IntegrationInput } from "./integration";
import type { IntegrationInstanceOfKind } from "./kind-map";
type IntegrationCtor = new (integration: IntegrationInput) => Integration;
type IntegrationFactory = (integration: IntegrationInput) => Promise<Integration>;
type IntegrationLoader = () => Promise<IntegrationCtor | [IntegrationFactory]>;
const integrationLoaders: Record<IntegrationKind, IntegrationLoader> = {
anchor: () => import("../anchor/anchor-integration").then((m) => m.AnchorIntegration),
adGuardHome: () => import("../adguard-home/adguard-home-integration").then((m) => m.AdGuardHomeIntegration),
archiveTeamWarrior: () =>
import("../archive-team-warrior/archive-team-warrior-integration").then((m) => m.ArchiveTeamWarriorIntegration),
aria2: () => import("../download-client/aria2/aria2-integration").then((m) => m.Aria2Integration),
audiobookshelf: () => import("../audiobookshelf/audiobookshelf-integration").then((m) => m.AudiobookshelfIntegration),
bazarr: () => import("../bazarr/bazarr-integration").then((m) => m.BazarrIntegration),
beszel: () => import("../beszel/beszel-integration").then((m) => m.BeszelIntegration),
coolify: () => import("../coolify/coolify-integration").then((m) => m.CoolifyIntegration),
dashDot: () => import("../dashdot/dashdot-integration").then((m) => m.DashDotIntegration),
deluge: () => import("../download-client/deluge/deluge-integration").then((m) => m.DelugeIntegration),
emby: () => import("../emby/emby-integration").then((m) => m.EmbyIntegration),
glances: () => import("../glances/glances-integration").then((m) => m.GlancesIntegration),
gluetun: () => import("../gluetun/gluetun-integration").then((m) => m.GluetunIntegration),
gotify: () => import("../gotify/gotify-integration").then((m) => m.GotifyIntegration),
homeAssistant: () => import("../homeassistant/homeassistant-integration").then((m) => m.HomeAssistantIntegration),
ical: () => import("../ical/ical-integration").then((m) => m.ICalIntegration),
immich: () => import("../immich/immich-integration").then((m) => m.ImmichIntegration),
jellyfin: () => import("../jellyfin/jellyfin-integration").then((m) => m.JellyfinIntegration),
jellyseerr: () => import("../jellyseerr/jellyseerr-integration").then((m) => m.JellyseerrIntegration),
lidarr: () => import("../media-organizer/lidarr/lidarr-integration").then((m) => m.LidarrIntegration),
mock: () => import("../mock/mock-integration").then((m) => m.MockIntegration),
navidrome: () => import("../navidrome/navidrome-integration").then((m) => m.NavidromeIntegration),
nextcloud: () => import("../nextcloud/nextcloud.integration").then((m) => m.NextcloudIntegration),
ntfy: () => import("../ntfy/ntfy-integration").then((m) => m.NTFYIntegration),
nzbGet: () => import("../download-client/nzbget/nzbget-integration").then((m) => m.NzbGetIntegration),
openmediavault: () => import("../openmediavault/openmediavault-integration").then((m) => m.OpenMediaVaultIntegration),
opnsense: () => import("../opnsense/opnsense-integration").then((m) => m.OPNsenseIntegration),
overseerr: () => import("../overseerr/overseerr-integration").then((m) => m.OverseerrIntegration),
paperlessNgx: () => import("../paperless-ngx/paperless-ngx-integration").then((m) => m.PaperlessNgxIntegration),
patchmon: () => import("../patchmon/patchmon-integration").then((m) => m.PatchMonIntegration),
peaNut: () => import("../peanut/peanut-integration").then((m) => m.PeaNutIntegration),
piHole: () =>
import("../pi-hole/pi-hole-integration-factory").then(
(m) => [m.createPiHoleIntegrationAsync] as [IntegrationFactory],
),
plex: () => import("../plex/plex-integration").then((m) => m.PlexIntegration),
prowlarr: () => import("../prowlarr/prowlarr-integration").then((m) => m.ProwlarrIntegration),
proxmox: () => import("../proxmox/proxmox-integration").then((m) => m.ProxmoxIntegration),
qBittorrent: () =>
import("../download-client/qbittorrent/qbittorrent-integration").then((m) => m.QBitTorrentIntegration),
radarr: () => import("../media-organizer/radarr/radarr-integration").then((m) => m.RadarrIntegration),
readarr: () => import("../media-organizer/readarr/readarr-integration").then((m) => m.ReadarrIntegration),
sabNzbd: () => import("../download-client/sabnzbd/sabnzbd-integration").then((m) => m.SabnzbdIntegration),
seerr: () => import("../seerr/seerr-integration").then((m) => m.SeerrIntegration),
slskd: () => import("../download-client/slskd/slskd-integration").then((m) => m.SlskdIntegration),
sonarr: () => import("../media-organizer/sonarr/sonarr-integration").then((m) => m.SonarrIntegration),
speedtestTracker: () =>
import("../speedtest-tracker/speedtest-tracker-integration").then((m) => m.SpeedtestTrackerIntegration),
synology: () => import("../synology/synology-integration").then((m) => m.SynologyIntegration),
tdarr: () => import("../media-transcoding/tdarr-integration").then((m) => m.TdarrIntegration),
technitiumDns: () =>
import("../technitium/technitium-integration-factory").then(
(m) => [m.createTechnitiumDnsIntegrationAsync] as [IntegrationFactory],
),
tracearr: () => import("../tracearr/tracearr-integration").then((m) => m.TracearrIntegration),
traefik: () => import("../traefik/traefik-integration").then((m) => m.TraefikIntegration),
transmission: () =>
import("../download-client/transmission/transmission-integration").then((m) => m.TransmissionIntegration),
truenas: () => import("../truenas/truenas-integration").then((m) => m.TrueNasIntegration),
umami: () => import("../umami/umami-integration").then((m) => m.UmamiIntegration),
unifiController: () =>
import("../unifi-controller/unifi-controller-integration").then((m) => m.UnifiControllerIntegration),
unraid: () => import("../unraid/unraid-integration").then((m) => m.UnraidIntegration),
uptimeKuma: () => import("../uptime-kuma/uptime-kuma-integration").then((m) => m.UptimeKumaIntegration),
wud: () => import("../wud/wud-integration").then((m) => m.WudIntegration),
};
export const integrationLoaderKinds = Object.keys(integrationLoaders) as IntegrationKind[];
const loadIntegrationCreator = createRetryableLoader(
new Map(Object.entries(integrationLoaders) as [IntegrationKind, IntegrationLoader][]),
);
export const createIntegrationAsync = async <TKind extends IntegrationKind>(
integration: IntegrationInput & { kind: TKind },
) => {
if (!(integration.kind in integrationCreators)) {
): Promise<IntegrationInstanceOfKind<TKind>> => {
if (!(integration.kind in integrationLoaders)) {
throw new Error(
`Unknown integration kind ${integration.kind}. Did you forget to add it to the integration creator?`,
);
}
const creator = integrationCreators[integration.kind];
const creator = await loadIntegrationCreator(integration.kind);
// factories are an array, to differentiate in js between class constructors and functions
if (Array.isArray(creator)) {
return (await creator[0](integration)) as IntegrationInstanceOfKind<TKind>;
}
return new creator(integration) as IntegrationInstanceOfKind<TKind>;
};
type IntegrationInstance = new (integration: IntegrationInput) => Integration;
// factories are an array, to differentiate in js between class constructors and functions
export const integrationCreators = {
anchor: AnchorIntegration,
piHole: [createPiHoleIntegrationAsync],
adGuardHome: AdGuardHomeIntegration,
technitiumDns: [createTechnitiumDnsIntegrationAsync],
homeAssistant: HomeAssistantIntegration,
jellyfin: JellyfinIntegration,
plex: PlexIntegration,
sonarr: SonarrIntegration,
radarr: RadarrIntegration,
sabNzbd: SabnzbdIntegration,
nzbGet: NzbGetIntegration,
qBittorrent: QBitTorrentIntegration,
deluge: DelugeIntegration,
transmission: TransmissionIntegration,
slskd: SlskdIntegration,
aria2: Aria2Integration,
jellyseerr: JellyseerrIntegration,
seerr: SeerrIntegration,
overseerr: OverseerrIntegration,
prowlarr: ProwlarrIntegration,
openmediavault: OpenMediaVaultIntegration,
lidarr: LidarrIntegration,
readarr: ReadarrIntegration,
dashDot: DashDotIntegration,
tdarr: TdarrIntegration,
proxmox: ProxmoxIntegration,
emby: EmbyIntegration,
nextcloud: NextcloudIntegration,
unifiController: UnifiControllerIntegration,
opnsense: OPNsenseIntegration,
ical: ICalIntegration,
ntfy: NTFYIntegration,
gotify: GotifyIntegration,
mock: MockIntegration,
truenas: TrueNasIntegration,
synology: SynologyIntegration,
unraid: UnraidIntegration,
coolify: CoolifyIntegration,
tracearr: TracearrIntegration,
glances: GlancesIntegration,
immich: ImmichIntegration,
paperlessNgx: PaperlessNgxIntegration,
patchmon: PatchMonIntegration,
speedtestTracker: SpeedtestTrackerIntegration,
audiobookshelf: AudiobookshelfIntegration,
navidrome: NavidromeIntegration,
umami: UmamiIntegration,
gluetun: GluetunIntegration,
archiveTeamWarrior: ArchiveTeamWarriorIntegration,
uptimeKuma: UptimeKumaIntegration,
peaNut: PeaNutIntegration,
beszel: BeszelIntegration,
bazarr: BazarrIntegration,
traefik: TraefikIntegration,
wud: WudIntegration,
} satisfies Record<IntegrationKind, IntegrationInstance | [(input: IntegrationInput) => Promise<Integration>]>;
type IntegrationInstanceOfKind<TKind extends keyof typeof integrationCreators> = {
[kind in TKind]: (typeof integrationCreators)[kind] extends [(input: IntegrationInput) => Promise<Integration>]
? Awaited<ReturnType<(typeof integrationCreators)[kind][0]>>
: (typeof integrationCreators)[kind] extends IntegrationInstance
? InstanceType<(typeof integrationCreators)[kind]>
: never;
}[TKind];
+121
View File
@@ -0,0 +1,121 @@
import type { IntegrationKind } from "@homarr/definitions";
// Type-only imports — fully erased at build, zero runtime modules loaded.
import type { AdGuardHomeIntegration } from "../adguard-home/adguard-home-integration";
import type { AnchorIntegration } from "../anchor/anchor-integration";
import type { ArchiveTeamWarriorIntegration } from "../archive-team-warrior/archive-team-warrior-integration";
import type { AudiobookshelfIntegration } from "../audiobookshelf/audiobookshelf-integration";
import type { BazarrIntegration } from "../bazarr/bazarr-integration";
import type { BeszelIntegration } from "../beszel/beszel-integration";
import type { CoolifyIntegration } from "../coolify/coolify-integration";
import type { DashDotIntegration } from "../dashdot/dashdot-integration";
import type { Aria2Integration } from "../download-client/aria2/aria2-integration";
import type { DelugeIntegration } from "../download-client/deluge/deluge-integration";
import type { NzbGetIntegration } from "../download-client/nzbget/nzbget-integration";
import type { QBitTorrentIntegration } from "../download-client/qbittorrent/qbittorrent-integration";
import type { SabnzbdIntegration } from "../download-client/sabnzbd/sabnzbd-integration";
import type { SlskdIntegration } from "../download-client/slskd/slskd-integration";
import type { TransmissionIntegration } from "../download-client/transmission/transmission-integration";
import type { EmbyIntegration } from "../emby/emby-integration";
import type { GlancesIntegration } from "../glances/glances-integration";
import type { GluetunIntegration } from "../gluetun/gluetun-integration";
import type { GotifyIntegration } from "../gotify/gotify-integration";
import type { HomeAssistantIntegration } from "../homeassistant/homeassistant-integration";
import type { ICalIntegration } from "../ical/ical-integration";
import type { ImmichIntegration } from "../immich/immich-integration";
import type { JellyfinIntegration } from "../jellyfin/jellyfin-integration";
import type { JellyseerrIntegration } from "../jellyseerr/jellyseerr-integration";
import type { LidarrIntegration } from "../media-organizer/lidarr/lidarr-integration";
import type { RadarrIntegration } from "../media-organizer/radarr/radarr-integration";
import type { ReadarrIntegration } from "../media-organizer/readarr/readarr-integration";
import type { SonarrIntegration } from "../media-organizer/sonarr/sonarr-integration";
import type { TdarrIntegration } from "../media-transcoding/tdarr-integration";
import type { MockIntegration } from "../mock/mock-integration";
import type { NavidromeIntegration } from "../navidrome/navidrome-integration";
import type { NextcloudIntegration } from "../nextcloud/nextcloud.integration";
import type { NTFYIntegration } from "../ntfy/ntfy-integration";
import type { OpenMediaVaultIntegration } from "../openmediavault/openmediavault-integration";
import type { OPNsenseIntegration } from "../opnsense/opnsense-integration";
import type { OverseerrIntegration } from "../overseerr/overseerr-integration";
import type { PaperlessNgxIntegration } from "../paperless-ngx/paperless-ngx-integration";
import type { PatchMonIntegration } from "../patchmon/patchmon-integration";
import type { PeaNutIntegration } from "../peanut/peanut-integration";
import type { PiHoleIntegrationV5 } from "../pi-hole/v5/pi-hole-integration-v5";
import type { PiHoleIntegrationV6 } from "../pi-hole/v6/pi-hole-integration-v6";
import type { PlexIntegration } from "../plex/plex-integration";
import type { ProwlarrIntegration } from "../prowlarr/prowlarr-integration";
import type { ProxmoxIntegration } from "../proxmox/proxmox-integration";
import type { SeerrIntegration } from "../seerr/seerr-integration";
import type { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
import type { SynologyIntegration } from "../synology/synology-integration";
import type { TechnitiumDnsIntegration } from "../technitium/technitium-integration";
import type { TracearrIntegration } from "../tracearr/tracearr-integration";
import type { TraefikIntegration } from "../traefik/traefik-integration";
import type { TrueNasIntegration } from "../truenas/truenas-integration";
import type { UmamiIntegration } from "../umami/umami-integration";
import type { UnifiControllerIntegration } from "../unifi-controller/unifi-controller-integration";
import type { UnraidIntegration } from "../unraid/unraid-integration";
import type { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
import type { WudIntegration } from "../wud/wud-integration";
export interface IntegrationInstanceMap {
anchor: AnchorIntegration;
adGuardHome: AdGuardHomeIntegration;
archiveTeamWarrior: ArchiveTeamWarriorIntegration;
aria2: Aria2Integration;
audiobookshelf: AudiobookshelfIntegration;
bazarr: BazarrIntegration;
beszel: BeszelIntegration;
coolify: CoolifyIntegration;
dashDot: DashDotIntegration;
deluge: DelugeIntegration;
emby: EmbyIntegration;
glances: GlancesIntegration;
gluetun: GluetunIntegration;
gotify: GotifyIntegration;
homeAssistant: HomeAssistantIntegration;
ical: ICalIntegration;
immich: ImmichIntegration;
jellyfin: JellyfinIntegration;
jellyseerr: JellyseerrIntegration;
lidarr: LidarrIntegration;
mock: MockIntegration;
navidrome: NavidromeIntegration;
nextcloud: NextcloudIntegration;
ntfy: NTFYIntegration;
nzbGet: NzbGetIntegration;
openmediavault: OpenMediaVaultIntegration;
opnsense: OPNsenseIntegration;
overseerr: OverseerrIntegration;
paperlessNgx: PaperlessNgxIntegration;
patchmon: PatchMonIntegration;
peaNut: PeaNutIntegration;
piHole: PiHoleIntegrationV5 | PiHoleIntegrationV6;
plex: PlexIntegration;
prowlarr: ProwlarrIntegration;
proxmox: ProxmoxIntegration;
qBittorrent: QBitTorrentIntegration;
radarr: RadarrIntegration;
readarr: ReadarrIntegration;
sabNzbd: SabnzbdIntegration;
seerr: SeerrIntegration;
slskd: SlskdIntegration;
sonarr: SonarrIntegration;
speedtestTracker: SpeedtestTrackerIntegration;
synology: SynologyIntegration;
tdarr: TdarrIntegration;
technitiumDns: TechnitiumDnsIntegration;
tracearr: TracearrIntegration;
traefik: TraefikIntegration;
transmission: TransmissionIntegration;
truenas: TrueNasIntegration;
umami: UmamiIntegration;
unifiController: UnifiControllerIntegration;
unraid: UnraidIntegration;
uptimeKuma: UptimeKumaIntegration;
wud: WudIntegration;
}
export type IntegrationInstanceOfKind<TKind extends IntegrationKind> = TKind extends keyof IntegrationInstanceMap
? IntegrationInstanceMap[TKind]
: never;
+5 -51
View File
@@ -1,50 +1,7 @@
// General integrations
export { ArchiveTeamWarriorIntegration } from "./archive-team-warrior/archive-team-warrior-integration";
export { WudIntegration } from "./wud/wud-integration";
export { AdGuardHomeIntegration } from "./adguard-home/adguard-home-integration";
export { TechnitiumDnsIntegration } from "./technitium/technitium-integration";
export { AnchorIntegration } from "./anchor/anchor-integration";
export { Aria2Integration } from "./download-client/aria2/aria2-integration";
export { DelugeIntegration } from "./download-client/deluge/deluge-integration";
export { NzbGetIntegration } from "./download-client/nzbget/nzbget-integration";
export { QBitTorrentIntegration } from "./download-client/qbittorrent/qbittorrent-integration";
export { SabnzbdIntegration } from "./download-client/sabnzbd/sabnzbd-integration";
export { SlskdIntegration } from "./download-client/slskd/slskd-integration";
export { TransmissionIntegration } from "./download-client/transmission/transmission-integration";
export { HomeAssistantIntegration } from "./homeassistant/homeassistant-integration";
export { JellyfinIntegration } from "./jellyfin/jellyfin-integration";
export { JellyseerrIntegration } from "./jellyseerr/jellyseerr-integration";
export { LidarrIntegration } from "./media-organizer/lidarr/lidarr-integration";
export { RadarrIntegration } from "./media-organizer/radarr/radarr-integration";
export { ReadarrIntegration } from "./media-organizer/readarr/readarr-integration";
export { SonarrIntegration } from "./media-organizer/sonarr/sonarr-integration";
export { NextcloudIntegration } from "./nextcloud/nextcloud.integration";
export { GotifyIntegration } from "./gotify/gotify-integration";
export { NTFYIntegration } from "./ntfy/ntfy-integration";
export { OpenMediaVaultIntegration } from "./openmediavault/openmediavault-integration";
export { GlancesIntegration } from "./glances/glances-integration";
export { OverseerrIntegration } from "./overseerr/overseerr-integration";
export { SeerrIntegration } from "./seerr/seerr-integration";
export { PiHoleIntegrationV5 } from "./pi-hole/v5/pi-hole-integration-v5";
export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
export { PlexIntegration } from "./plex/plex-integration";
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
export { TrueNasIntegration } from "./truenas/truenas-integration";
export { SynologyIntegration } from "./synology/synology-integration";
export { UnraidIntegration } from "./unraid/unraid-integration";
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
export { ICalIntegration } from "./ical/ical-integration";
export { CoolifyIntegration } from "./coolify/coolify-integration";
export { ImmichIntegration } from "./immich/immich-integration";
export { TracearrIntegration } from "./tracearr/tracearr-integration";
export { SpeedtestTrackerIntegration } from "./speedtest-tracker/speedtest-tracker-integration";
export { AudiobookshelfIntegration } from "./audiobookshelf/audiobookshelf-integration";
export { NavidromeIntegration } from "./navidrome/navidrome-integration";
export { UptimeKumaIntegration } from "./uptime-kuma/uptime-kuma-integration";
export { UmamiIntegration } from "./umami/umami-integration";
export { PeaNutIntegration } from "./peanut/peanut-integration";
export { BazarrIntegration } from "./bazarr/bazarr-integration";
export { TraefikIntegration } from "./traefik/traefik-integration";
// Helpers
export { createIntegrationAsync } from "./base/creator";
export { Integration } from "./base/integration";
export type { IntegrationInstanceOfKind } from "./base/kind-map";
// Types
export type { IntegrationInput } from "./base/integration";
@@ -72,6 +29,7 @@ export type { Notification } from "./interfaces/notifications/notification-types
export type { ImmichServerStats, ImmichAlbum, ImmichAsset } from "./immich/immich-integration";
export type { PaperlessNgxStats } from "./paperless-ngx/paperless-ngx-types";
export type { PatchMonStats, PatchMonOsDistributionEntry } from "./patchmon/patchmon-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
export type {
AnchorNote,
AnchorNotePermission,
@@ -92,12 +50,8 @@ export type {
ArchiveTeamWarriorItem,
ArchiveTeamWarriorStatus,
} from "./archive-team-warrior/archive-team-warrior-types";
export type { WudStats, WudContainerUpdate } from "./wud/wud-types";
// Schemas
export { anchorNotesListInputSchema } from "./anchor/anchor-types";
export { anchorNoteUpdateInputSchema } from "./anchor/anchor-types";
export { downloadClientItemSchema } from "./interfaces/downloads/download-client-items";
// Helpers
export { createIntegrationAsync } from "./base/creator";
@@ -0,0 +1,179 @@
import { Request, Response } from "undici";
import { beforeEach, describe, expect, test, vi } from "vitest";
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
import { UmamiIntegration } from "../umami-integration";
vi.mock("@homarr/core/infrastructure/http", () => ({
fetchWithTrustedCertificatesAsync: vi.fn(),
}));
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
/** Matches the page size the integration asks for; a full page is what triggers the next request. */
const PAGE_SIZE = 5_000;
const WEBSITE_ID = "site-1";
interface RequestedUrl {
path: string;
params: URLSearchParams;
}
const requestedUrls: RequestedUrl[] = [];
/**
* Routes on the last path segment(s) and records every request, so tests can assert both the
* result and how many round trips it took — the point of these changes is the number and size
* of requests, not only the value returned.
*/
const setupMockFetch = (handler: (requested: RequestedUrl) => unknown) => {
mockFetch.mockImplementation((url) => {
const urlString = typeof url === "string" ? url : url instanceof Request ? url.url : url.toString();
const parsed = new URL(urlString);
const requested = { path: parsed.pathname, params: parsed.searchParams };
requestedUrls.push(requested);
const body = handler(requested);
return Promise.resolve(
new Response(JSON.stringify(body ?? []), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>,
);
});
};
const createIntegration = () =>
new UmamiIntegration({
id: "test-umami",
name: "Test Umami",
url: "https://umami.example.com/api",
externalUrl: null,
decryptedSecrets: [{ kind: "apiKey", value: "test-key" }],
});
/** A raw Umami Cloud event record, the shape that used to be downloaded 100k at a time. */
const rawEvent = (index: number, eventName: string, hour: number) => ({
id: `event-${index}`,
websiteId: WEBSITE_ID,
sessionId: `session-${index}`,
eventType: 2,
eventName,
createdAt: `2026-08-09T${String(hour).padStart(2, "0")}:30:00.000Z`,
});
describe("UmamiIntegration", () => {
beforeEach(() => {
vi.clearAllMocks();
requestedUrls.length = 0;
});
describe("getEventNamesAsync", () => {
test("reads names from the aggregated metrics endpoint, never the raw event list", async () => {
setupMockFetch(({ path }) => {
if (path.endsWith("/metrics")) {
return [
{ x: "signup", y: 12 },
{ x: "purchase", y: 3 },
{ x: "signup", y: 1 },
];
}
throw new Error(`unexpected request to ${path}`);
});
const result = await createIntegration().getEventNamesAsync(WEBSITE_ID);
expect(result).toEqual(["purchase", "signup"]);
expect(requestedUrls).toHaveLength(1);
expect(requestedUrls[0]?.path).toContain("/metrics");
expect(requestedUrls[0]?.params.get("type")).toBe("event");
// The regression this guards: listing /events pulled every raw record to derive names.
expect(requestedUrls.some((requested) => requested.path.endsWith("/events"))).toBe(false);
});
test("drops empty names and returns them sorted", async () => {
setupMockFetch(() => [
{ x: "beta", y: 1 },
{ x: null, y: 5 },
{ x: "alpha", y: 2 },
]);
expect(await createIntegration().getEventNamesAsync(WEBSITE_ID)).toEqual(["alpha", "beta"]);
});
});
describe("getMultiEventTimeSeriesAsync", () => {
test("pages through raw event records and aggregates across pages", async () => {
// Page 1 is full, so a second page is requested; page 2 is short, which ends it.
const firstPage = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
const secondPage = Array.from({ length: 5 }, (_, index) => rawEvent(PAGE_SIZE + index, "signup", 11));
setupMockFetch(({ path, params }) => {
if (!path.endsWith("/events")) return [];
return { data: params.get("page") === "1" ? firstPage : secondPage };
});
const result = await createIntegration().getMultiEventTimeSeriesAsync("site-1", "24h", ["signup"]);
expect(requestedUrls.map((requested) => requested.params.get("page"))).toEqual(["1", "2"]);
expect(requestedUrls[0]?.params.get("pageSize")).toBe(String(PAGE_SIZE));
expect(result).toHaveLength(1);
// Both pages must be counted: a full page in the 10:00 bucket, 5 in the 11:00 bucket.
const total = result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0);
expect(total).toBe(PAGE_SIZE + 5);
expect(result[0]?.dataPoints).toHaveLength(2);
});
test("stops after one page when the instance returns aggregated buckets", async () => {
setupMockFetch(() => [
{ x: "2026-08-09 10:00:00", y: 4 },
{ x: "2026-08-09 11:00:00", y: 7 },
]);
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
expect(requestedUrls).toHaveLength(1);
expect(result[0]?.dataPoints).toEqual([
{ x: "2026-08-09 10:00:00", y: 4 },
{ x: "2026-08-09 11:00:00", y: 7 },
]);
});
test("stops instead of double counting when the instance ignores the page parameter", async () => {
// Always returns the same full page, whatever page was asked for.
const page = Array.from({ length: PAGE_SIZE }, (_, index) => rawEvent(index, "signup", 10));
setupMockFetch(() => ({ data: page }));
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", ["signup"]);
// Page 1 is consumed, page 2 is recognised as a repeat and discarded.
expect(requestedUrls).toHaveLength(2);
expect(result[0]?.dataPoints.reduce((sum, point) => sum + point.y, 0)).toBe(PAGE_SIZE);
});
test("requests event series one at a time rather than all at once", async () => {
let inFlight = 0;
let maxInFlight = 0;
mockFetch.mockImplementation(async () => {
inFlight++;
maxInFlight = Math.max(maxInFlight, inFlight);
await new Promise((resolve) => setTimeout(resolve, 1));
inFlight--;
return new Response(JSON.stringify([{ x: "2026-08-09 10:00:00", y: 1 }]), {
status: 200,
headers: { "content-type": "application/json" },
}) as unknown as Awaited<ReturnType<typeof fetchWithTrustedCertificatesAsync>>;
});
const result = await createIntegration().getMultiEventTimeSeriesAsync(WEBSITE_ID, "24h", [
"signup",
"purchase",
"refund",
]);
expect(result).toHaveLength(3);
// Concurrent fan-out multiplied resident event pages by the number of tracked events.
expect(maxInFlight).toBe(1);
});
});
});
@@ -30,8 +30,39 @@ import {
// Umami event type ID for custom events (type 1 = page view, type 2 = custom event)
const UMAMI_CUSTOM_EVENT_TYPE = 2;
/**
* Records per page when /events returns raw event rows rather than aggregated buckets.
*
* Requesting the whole list at once makes the server hold all of it: one measured refresh
* pulled a 46.3 MiB JSON body assembled from 3,946 socket chunks, and `Response.json()`
* keeps the chunk list and the joined copy alive at the same time, so the true peak was
* roughly double. Pages are folded into the bucket map and released, so only one page is
* ever resident.
*
* 5,000 rather than something smaller because this trades against round trips, not just
* memory: a page is ~2.3 MiB, so the peak stays small, while any site with fewer than 5,000
* events in the window — which is most of them — still completes in a single request.
*/
const UMAMI_EVENT_PAGE_SIZE = 5_000;
/**
* Ceiling on pages, so a very busy site degrades into an undercount that is logged rather
* than into unbounded memory. Preserves the previous 100k-record ceiling.
*/
const UMAMI_EVENT_PAGE_MAX = 20;
const logger = createLogger({ module: "umami-integration" });
const pad = (number: number) => String(number).padStart(2, "0");
/** Umami returns either a bare array or `{ data: [...] }` depending on endpoint and version. */
const extractDataArray = (json: unknown): unknown[] => {
if (Array.isArray(json)) return json;
if (json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)) {
return (json as { data: unknown[] }).data;
}
return [];
};
export class UmamiIntegration extends Integration {
protected async testingAsync(input: IntegrationTestingInput): Promise<TestingResult> {
const authHeaders = await this.getAuthHeadersAsync();
@@ -168,34 +199,31 @@ export class UmamiIntegration extends Integration {
};
}
/**
* Distinct custom event names over the last 30 days.
*
* Uses the aggregated `/metrics?type=event` endpoint, which returns one row per event
* name. The obvious alternative — listing `/events` and collecting the distinct
* `eventName` values — downloads every raw event record to compute a handful of strings:
* on a busy site that measured a 46.3 MiB JSON response, and `Response.json()` holds the
* socket chunks and the joined copy simultaneously, so the real cost was about twice that.
*
* The two are not exactly equivalent: /metrics returns the top names by count, so an
* extremely rare event could fall outside it. The list endpoint was bounded too — it
* capped at a record count, which on a busy site does not even span the 30-day window —
* so this trades one incomplete answer for a much cheaper one, in a picker where the
* names worth choosing are the ones with traffic.
*/
public async getEventNamesAsync(websiteId: string): Promise<string[]> {
const authHeaders = await this.getAuthHeadersAsync();
const now = Date.now();
const endAt = Date.now();
const startAt = dayjs().subtract(30, "day").valueOf();
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: now.toString(),
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, { headers: authHeaders });
if (!response.ok) {
logger.warn("Failed to load event names", { status: response.status, websiteId });
return [];
}
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
const metrics = await this.getWebsiteMetricsAsync(websiteId, startAt, endAt, "event", authHeaders);
const names = new Set<string>();
for (const item of rawArray) {
const record = item as Record<string, unknown>;
if (typeof record.eventName === "string" && record.eventName) {
names.add(record.eventName);
}
for (const metric of metrics) {
if (metric.x) names.add(metric.x);
}
return [...names].sort();
return [...names].toSorted();
}
public async getActiveVisitorsAsync(websiteId: string): Promise<number> {
@@ -243,19 +271,23 @@ export class UmamiIntegration extends Integration {
): Promise<UmamiEventSeries[]> {
const authHeaders = await this.getAuthHeadersAsync();
const { startAt, endAt, unit } = this.computeTimeRange(timeFrame);
const results = await Promise.all(
eventNames.map(async (eventName) => {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
return { eventName, dataPoints: dataPoints ?? [] };
}),
);
// Sequentially, not with Promise.all over every name. Each series can be several pages
// of raw events on a busy site, and running them all at once multiplies the resident
// pages by the number of tracked events — which is how one widget refresh was able to
// put well over a hundred megabytes in flight at the same time.
const results: UmamiEventSeries[] = [];
for (const eventName of eventNames) {
const dataPoints = await this.getWebsiteEventTimeSeriesAsync(
websiteId,
startAt,
endAt,
unit,
eventName,
authHeaders,
);
results.push({ eventName, dataPoints: dataPoints ?? [] });
}
return results;
}
@@ -298,6 +330,16 @@ export class UmamiIntegration extends Integration {
return [];
}
/**
* Per-event time series.
*
* Two response shapes coexist:
* - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
* - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
* The raw records are bucketed here onto the same grid so the result joins cleanly with
* /pageviews timestamps. Raw pages are folded in one at a time — see UMAMI_EVENT_PAGE_SIZE
* for why fetching them in a single page was the largest allocation in the server.
*/
private async getWebsiteEventTimeSeriesAsync(
websiteId: string,
startAt: number,
@@ -306,79 +348,98 @@ export class UmamiIntegration extends Integration {
eventName: string,
authHeaders: Record<string, string>,
): Promise<UmamiPageviewDataPoint[] | undefined> {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
pageSize: "100000",
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName });
return undefined;
}
const buckets = new Map<string, number>();
let previousFirstId: string | undefined;
let truncated = false;
const json = await response.json();
const rawArray: unknown[] = Array.isArray(json)
? json
: json && typeof json === "object" && "data" in json && Array.isArray((json as { data: unknown }).data)
? (json as { data: unknown[] }).data
: [];
for (let page = 1; ; page++) {
const url = this.url(`/websites/${websiteId}/events`, {
startAt: startAt.toString(),
endAt: endAt.toString(),
unit,
timezone: "UTC",
eventName,
page: page.toString(),
pageSize: UMAMI_EVENT_PAGE_SIZE.toString(),
});
const response = await fetchWithTrustedCertificatesAsync(url, {
headers: authHeaders,
});
if (rawArray.length === 0) return undefined;
if (!response.ok) {
logger.warn("Failed to load event time series", { status: response.status, websiteId, eventName, page });
break;
}
const firstItem = rawArray[0] as Record<string, unknown>;
const rawArray = extractDataArray(await response.json());
if (rawArray.length === 0) break;
// Two response shapes coexist:
// - Umami self-hosted returns aggregated buckets: { x: timestamp, y: count }
// - Umami Cloud returns raw event records: { createdAt, eventName, eventType }
// We aggregate the raw Cloud records ourselves into the same bucket grid so
// the result joins cleanly with /pageviews timestamps.
const firstItem = rawArray[0] as Record<string, unknown>;
// Aggregated { x, y } format — parse directly (self-hosted instances)
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
// Aggregated { x, y }: this instance ignored paging and returned the whole series, so
// there is nothing to accumulate across pages.
if ("x" in firstItem && "y" in firstItem) {
const points = umamiPageviewDataPointSchema.array().parse(rawArray);
return points.length === 0 ? undefined : points;
}
if (!("createdAt" in firstItem)) return undefined;
// Raw event records — aggregate by time bucket (Umami Cloud)
if ("createdAt" in firstItem) {
const pad = (number: number) => String(number).padStart(2, "0");
// Format must match Umami's pageview x-axis timestamps: "YYYY-MM-DD HH:MM:SS"
const truncate = (isoDate: string): string => {
const date = new Date(isoDate);
const dateString = `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-${pad(date.getUTCDate())}`;
if (unit === "hour") {
return `${dateString} ${pad(date.getUTCHours())}:00:00`;
}
if (unit === "month") {
return `${date.getUTCFullYear()}-${pad(date.getUTCMonth() + 1)}-01 00:00:00`;
}
return `${dateString} 00:00:00`;
};
// An instance that accepts pageSize but ignores page would hand back the same rows
// forever, and each pass would count them into the buckets again. Falls back to
// session+timestamp where records carry no id, so the guard still holds.
const firstId =
typeof firstItem.id === "string"
? firstItem.id
: typeof firstItem.createdAt === "string"
? `${String(firstItem.sessionId ?? "")}@${firstItem.createdAt}`
: undefined;
if (page > 1 && firstId !== undefined && firstId === previousFirstId) {
logger.warn("Umami ignored event paging, stopping after the first page", { websiteId, eventName });
break;
}
previousFirstId = firstId;
const buckets = new Map<string, number>();
for (const item of rawArray) {
const event = item as { eventType?: number; eventName?: string; createdAt: string };
if (event.eventType !== UMAMI_CUSTOM_EVENT_TYPE || event.eventName !== eventName) continue;
const bucket = truncate(event.createdAt);
buckets.set(bucket, (buckets.get(bucket) ?? 0) + 1);
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.sort(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
if (rawArray.length < UMAMI_EVENT_PAGE_SIZE) break;
if (page >= UMAMI_EVENT_PAGE_MAX) {
truncated = true;
break;
}
}
return undefined;
if (truncated) {
logger.warn("Event time series hit the page ceiling, counts are a lower bound", {
websiteId,
eventName,
maxRecords: UMAMI_EVENT_PAGE_SIZE * UMAMI_EVENT_PAGE_MAX,
});
}
if (buckets.size === 0) return undefined;
return (
Array.from(buckets.entries())
.toSorted(([itemA], [itemB]) => itemA.localeCompare(itemB))
// eslint-disable-next-line id-length
.map(([x, y]) => ({ x, y }))
);
}
private async getWebsiteStatsAsync(
+1 -1
View File
@@ -4,7 +4,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { Aria2Integration } from "../src";
import { Aria2Integration } from "../src/download-client/aria2/aria2-integration";
vi.mock("@homarr/db", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
@@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { HomeAssistantIntegration } from "../src";
import { HomeAssistantIntegration } from "../src/homeassistant/homeassistant-integration";
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
vi.mock("@homarr/db", async (importActual) => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createId } from "@homarr/common";
import { createDb } from "@homarr/db/test";
import { NextcloudIntegration } from "../src";
import { NextcloudIntegration } from "../src/nextcloud/nextcloud.integration";
// Mock the database
vi.mock("@homarr/db", async (importActual) => {
+1 -1
View File
@@ -6,7 +6,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { NzbGetIntegration } from "../src";
import { NzbGetIntegration } from "../src/download-client/nzbget/nzbget-integration";
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
vi.mock("@homarr/db", async (importActual) => {
+2 -1
View File
@@ -4,7 +4,8 @@ import { describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { PiHoleIntegrationV5, PiHoleIntegrationV6 } from "../src";
import { PiHoleIntegrationV5 } from "../src/pi-hole/v5/pi-hole-integration-v5";
import { PiHoleIntegrationV6 } from "../src/pi-hole/v6/pi-hole-integration-v6";
import type { SessionStore } from "../src/base/session-store";
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
+1 -1
View File
@@ -5,7 +5,7 @@ import { beforeAll, describe, expect, test, vi } from "vitest";
import { createDb } from "@homarr/db/test";
import { SabnzbdIntegration } from "../src";
import { SabnzbdIntegration } from "../src/download-client/sabnzbd/sabnzbd-integration";
import { TestConnectionError } from "../src/base/test-connection/test-connection-error";
import type { DownloadClientItem } from "../src/interfaces/downloads/download-client-items";
@@ -39,4 +39,6 @@ export const importSingleOldmarrConfigAsync = async (
boardInsertCollection.insertAll(db);
},
});
return boardInsertCollection.boards.map(({ id, name }) => ({ id, name }));
};
+1 -1
View File
@@ -9,5 +9,5 @@ export const importOldmarrAsync = async (
old: OldmarrConfig,
configuration: OldmarrImportConfiguration,
) => {
await importSingleOldmarrConfigAsync(db, old, configuration);
return await importSingleOldmarrConfigAsync(db, old, configuration);
};
+5 -1
View File
@@ -8,6 +8,8 @@ import { createRedisConnection } from "./connection";
const publisher = createRedisConnection();
const lastDataClient = createRedisConnection();
const LAST_DATA_TTL_SECONDS = 86_400;
/**
* Creates a new pub/sub channel.
* @param name name of the channel
@@ -39,7 +41,9 @@ export const createSubPubChannel = <TData>(name: string, { persist }: { persist:
*/
publishAsync: async (data: TData) => {
if (persist) {
await lastDataClient.set(lastChannelName, superjson.stringify(data));
// Without an expiry these keys accumulate one payload per channel forever.
// getLastDataAsync already tolerates a miss, so expiring them is safe.
await lastDataClient.set(lastChannelName, superjson.stringify(data), "EX", LAST_DATA_TTL_SECONDS);
}
await publisher.publish(channelName, superjson.stringify(data));
},
+4 -3
View File
@@ -46,9 +46,10 @@ const getImageFromStringAsFallback = (feedUrl: string, content: string) => {
return null;
}
console.debug(
`Falling back to regex image search for '${feedUrl}'. Found ${result.length} matches in content: ${content}`,
);
// This used to log the entire serialised feed entry through a raw console.debug, so it could not be
// suppressed by log level and produced one enormous line per entry of every feed. It also reported
// `result.length`, which is the capture-group count and so was always 2 rather than a match count.
logger.debug(`Falling back to regex image search for '${feedUrl}'`, { image: result[0] });
return result[0];
};
+38 -44
View File
@@ -6,52 +6,46 @@ import { fallbackLocale, isLocaleSupported } from ".";
import type { SupportedLanguage } from "./config";
import { createLanguageMapping } from "./mapping";
// This file is referenced in the `next.config.js` file. See https://next-intl-docs.vercel.app/docs/usage/configuration
// ponytail: module-level memo — one merged object per locale used (~250-400 KB each).
// Ceiling: ~2 MB for 5 active locales. Upgrade: evict when size > 8.
const mergedMessages = new Map<SupportedLanguage, Promise<TranslationObject>>();
function loadMessagesAsync(locale: SupportedLanguage): Promise<TranslationObject> {
const existing = mergedMessages.get(locale);
if (existing) return existing;
const promise = (async () => {
const languageMap = createLanguageMapping();
const current = removeEmptyTranslations((await languageMap[locale]()).default) as TranslationObject;
if (locale === fallbackLocale) return current;
const fallback = (await languageMap[fallbackLocale]()).default;
return deepmerge(fallback, current) as TranslationObject;
})();
mergedMessages.set(locale, promise);
void promise.catch(() => {
if (mergedMessages.get(locale) === promise) mergedMessages.delete(locale);
});
return promise;
}
export default getRequestConfig(async ({ requestLocale }) => {
let currentLocale = await requestLocale;
if (!currentLocale || !isLocaleSupported(currentLocale)) {
currentLocale = fallbackLocale;
}
const typedLocale = currentLocale as SupportedLanguage;
const languageMap = createLanguageMapping();
const currentMessages = removeEmptyTranslations((await languageMap[typedLocale]()).default) as TranslationObject;
// Fallback to default locale if the current locales messages if not all messages are present
if (currentLocale !== fallbackLocale) {
const fallbackMessages = (await languageMap[fallbackLocale]()).default;
return {
locale: typedLocale,
messages: deepmerge(fallbackMessages, currentMessages),
};
}
return {
locale: currentLocale,
messages: currentMessages,
};
const requested = await requestLocale;
const locale: SupportedLanguage =
requested && isLocaleSupported(requested) ? (requested as SupportedLanguage) : fallbackLocale;
return { locale, messages: await loadMessagesAsync(locale) };
});
const removeEmptyTranslations = (translations: Record<string, unknown>): Record<string, unknown> => {
return Object.entries(translations).reduce(
(acc, [key, value]) => {
if (typeof value !== "string") {
return {
...acc,
[key]: removeEmptyTranslations(value as Record<string, unknown>),
};
}
function removeEmptyTranslations(translations: Record<string, unknown>): Record<string, unknown> {
const result: Record<string, unknown> = {};
if (value.trim() === "") {
return acc;
}
for (const [key, value] of Object.entries(translations)) {
if (typeof value === "string") {
if (value.trim() !== "") result[key] = value;
continue;
}
result[key] = removeEmptyTranslations(value as Record<string, unknown>);
}
return {
...acc,
[key]: value,
};
},
{} as Record<string, unknown>,
);
};
return result;
}
+36 -16
View File
@@ -19,23 +19,43 @@ export function OverflowBadge({
radius: "sm",
...props,
};
return (
<Popover width="content" shadow="md" disabled={disablePopover}>
const hasOverflow = data.length > overflowCount;
const badges = data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
));
const overflowTrigger = hasOverflow ? (
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
) : null;
// Mantine mounts a Popover's machinery — the Popover itself plus its Dropdown — regardless of
// `disabled`, and this component previously mounted both even when nothing overflowed, which is the
// common case. That made it a per-row cost anywhere it appears in a list: this is shared UI, used by
// media-releases (which passes `disablePopover`), releases, and the widget inputs. Returning the
// badges directly when there is no popover to show keeps the rendered markup identical — a disabled
// Popover.Target only forwards a ref — while creating none of the overlay components.
if (disablePopover || !hasOverflow) {
return (
<Group gap={groupGap}>
{data.slice(0, overflowCount).map((item) => (
<Badge key={item} px="xs" {...badgeProps}>
{item}
</Badge>
))}
{data.length > overflowCount && (
<Popover.Target>
<UnstyledButton display="flex">
<Badge px="xs" style={{ cursor: "pointer", ...badgeProps.style }} {...badgeProps}>
+{data.length - overflowCount}
</Badge>
</UnstyledButton>
</Popover.Target>
)}
{badges}
{overflowTrigger}
</Group>
);
}
return (
<Popover width="content" shadow="md">
<Group gap={groupGap}>
{badges}
<Popover.Target>{overflowTrigger}</Popover.Target>
</Group>
<Popover.Dropdown>
<Stack>
-1
View File
@@ -1 +0,0 @@
export * from "./src";
+1 -1
View File
@@ -12,12 +12,12 @@
}
},
"exports": {
".": "./index.ts",
"./_inputs/json-path-tree-picker": "./src/_inputs/json-path-tree-picker.tsx",
"./custom-api/component": "./src/custom-api/component.tsx",
"./custom-api/extract-display-data": "./src/custom-api/extract-display-data.ts",
"./catalog": "./src/catalog.ts",
"./definition": "./src/definition.ts",
"./import": "./src/import.ts",
"./errors": "./src/errors/component.tsx",
"./manifest": "./src/manifest.ts",
"./options": "./src/options.ts",
@@ -177,20 +177,28 @@ const Item = <TItem, TOptionValue extends UniqueIdentifier>({
id,
});
const Handle = (props: Partial<ActionIconProps>) => {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
};
// Declared with useMemo because it is passed to a child as `handle={Handle}`, i.e. as a component
// *type*. A type that changes identity makes React unmount and remount the subtree rather than
// update it, and this component re-renders on every frame of a drag as dnd-kit updates `transform`
// — so the drag handle was being torn down and rebuilt continuously while being dragged.
const Handle = useMemo(
() =>
function DragHandle(props: Partial<ActionIconProps>) {
return (
<ActionIcon
variant="transparent"
color="gray"
{...props}
{...listeners}
ref={setActivatorNodeRef}
style={{ cursor: "grab" }}
>
<IconGripHorizontal />
</ActionIcon>
);
},
[listeners, setActivatorNodeRef],
);
return (
<Card
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { Center, Group, Indicator, Loader, Progress, Text } from "@mantine/core";
import type { DataTableColumn, DataTableSortStatus } from "mantine-datatable";
import {
@@ -42,6 +42,34 @@ import { BeszelIntegrationErrorIndicator } from "../beszel/_shared/error-indicat
import { BeszelSystemStatsModal } from "../beszel/_shared/system-stats-modal";
import { DiskUsage } from "../beszel/_shared/disk-usage";
/**
* One cpu/memory/gpu cell.
*
* This lived inside the widget body, which made it a **new component type on every render**: React
* cannot match a new type to the previous one, so it unmounted and remounted every cell's subtree
* (Group, Text and Progress) instead of updating it. This widget re-renders about once a second from
* its live Beszel subscription, so that was three cells per system torn down and rebuilt every
* second. Declaring it here makes the type stable, and memo keeps a cell from re-rendering when its
* own value has not changed.
*
* `size` is passed rather than closed over — it was the only thing the nested version captured.
*/
const PercentCell = memo(function PercentCell({ value, size }: { value: number; size: SizeConfig }) {
return (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
});
const directionMultiplier: Record<string, number> = { asc: 1, desc: -1 };
type SystemRowWithKey = BeszelSystemRow & { _key: string };
@@ -149,20 +177,6 @@ export default function BeszelSystemTableWidget({
});
}, [filteredSystems, sortStatus]);
const PercentCell = ({ value }: { value: number }) => (
<Group gap={8} wrap="nowrap" style={{ flex: 1 }}>
<Text size={size.fontSize} fw={500} w={size.valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(value)}
</Text>
<Progress
value={value}
color={thresholdColor(value)}
size={getProgressTrackSize(size.progressSize)}
style={{ flex: 1 }}
/>
</Group>
);
const columns = useMemo((): DataTableColumn<SystemRowWithKey>[] => {
const cols: (DataTableColumn<SystemRowWithKey> | false)[] = [
{
@@ -195,7 +209,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.cpu} />,
render: (record) => <PercentCell value={record.cpu} size={size} />,
},
options.showMemory && {
accessor: "memory",
@@ -207,7 +221,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.memory} />,
render: (record) => <PercentCell value={record.memory} size={size} />,
},
options.showDisk && {
accessor: "disk",
@@ -239,7 +253,7 @@ export default function BeszelSystemTableWidget({
</Group>
),
sortable: true,
render: (record) => <PercentCell value={record.gpu} />,
render: (record) => <PercentCell value={record.gpu} size={size} />,
},
options.showLoadAvg && {
accessor: "loadAvg",
@@ -22,65 +22,68 @@ export const DiskUsage = ({ system, fontSize, progressSize, valueMiw, valueGap =
const dotSize = progressSize === "xs" ? 2 : 3;
const dotGap = 2;
// Mantine mounts a HoverCard's Popover machinery even when it is disabled, and this component
// renders per table row. A system reporting no extra filesystems has nothing to show on hover — it
// already fell back to a plain bar inside the target — so the whole overlay was created for nothing.
if (filesystems.length === 0) {
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
</Box>
</Group>
);
}
return (
<Group gap={valueGap} wrap="nowrap" style={{ flex: 1, minWidth: 0, marginLeft: "auto" }}>
<Text size={fontSize} fw={500} w={valueMiw} ta="left" style={{ whiteSpace: "nowrap", flexShrink: 0 }}>
{formatPercent(system.disk)}
</Text>
<HoverCard
position="right"
withArrow
shadow="md"
openDelay={200}
closeDelay={100}
disabled={filesystems.length === 0}
>
<HoverCard position="right" withArrow shadow="md" openDelay={200} closeDelay={100}>
<HoverCard.Target>
{filesystems.length > 0 ? (
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
</UnstyledButton>
) : (
<Box style={{ display: "flex", alignItems: "center", flex: 1, minWidth: 24 }}>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
</Box>
<UnstyledButton
aria-label={`Show usage for ${filesystems.length + 1} filesystems`}
style={{
display: "flex",
alignItems: "center",
flex: 1,
minWidth: 24,
cursor: "pointer",
}}
>
<Box pos="relative" style={{ flex: 1, minWidth: 24 }}>
<Progress value={system.disk} color={thresholdColor(system.disk)} size={trackSize} />
<Group
gap={dotGap}
wrap="nowrap"
aria-hidden
pos="absolute"
top="50%"
right={Math.max(2, dotGap)}
style={{ transform: "translateY(-50%)" }}
>
{filesystems.map(([path, value]) => (
<Box
key={path}
w={dotSize}
h={dotSize}
style={{
borderRadius: "50%",
backgroundColor: severityColor(value),
boxShadow: "0 0 0 0.5px var(--mantine-color-body)",
flex: "0 0 auto",
}}
/>
))}
</Group>
</Box>
)}
</UnstyledButton>
</HoverCard.Target>
<HoverCard.Dropdown p={8}>
<Stack gap={6} miw={145}>
@@ -19,6 +19,15 @@ import type { CalendarEvent } from "@homarr/integrations/types";
import { useI18n } from "@homarr/translation/client";
import classes from "./calendar-event-list.module.css";
import { offscreenRowStyle } from "../common/offscreen-rows";
/**
* A broken poster used to fall back to `https://placehold.co/400x400`, so a self-hosted dashboard
* reached out to a third party - once per failing event - to draw a grey square. This is the same
* grey square, inline, and it still works when the host has no internet access at all.
*/
const NO_IMAGE_FALLBACK =
"data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20viewBox='0%200%20400%20400'%3E%3Crect%20width='400'%20height='400'%20fill='%23868e96'%20fill-opacity='0.2'/%3E%3C/svg%3E";
interface CalendarEventListProps {
events: CalendarEvent[];
@@ -40,11 +49,12 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
>
<Stack>
{events.map((event, eventIndex) => (
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap">
<Group key={`event-${eventIndex}`} align={"stretch"} wrap="nowrap" style={offscreenRowStyle(110)}>
{event.image !== null && (
<Box pos="relative">
<Image
src={event.image.src}
loading="lazy"
w={70}
mah={150}
style={{
@@ -53,7 +63,7 @@ export const CalendarEventList = ({ events }: CalendarEventListProps) => {
: "1/1",
}}
radius="sm"
fallbackSrc="https://placehold.co/400x400?text=No%20image"
fallbackSrc={NO_IMAGE_FALLBACK}
/>
{event.image.badge !== undefined && (
<Badge pos="absolute" bottom={-6} left="50%" w="90%" className={classes.badge}>
+31 -22
View File
@@ -22,6 +22,36 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
const shouldScaleDown = minAxisSize < 350;
const isSmall = rootHeight < 256;
const cell = (
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
);
// Mantine mounts a HoverCard's Popover machinery — Popover, PopoverTarget and PopoverDropdown —
// even when it is disabled. A month grid is around 42 cells, and `disabled` is
// `isEditMode || eventsForDate.length === 0`, so on a typical month most days, and in edit mode
// every day, mounted three components for a card that can never open. Measured on a real board:
// 42 Popovers, 42 PopoverDropdowns and 42 PopoverTargets, 126 of this widget's 171
// listener-attaching components, in a widget with only 251 DOM nodes.
if (disabled) return cell;
return (
<HoverCard
position="bottom"
@@ -32,29 +62,8 @@ export const CalendarDay = ({ date, events, disabled, rootHeight, rootWidth }: C
transitionProps={{ transition: "pop" }}
openDelay={350}
closeDelay={400}
disabled={disabled}
>
<HoverCard.Target>
<Container
h="100%"
w="100%"
p={0}
pt={isSmall ? 0 : 10}
pb={isSmall ? 0 : 10}
m={0}
pos="relative"
style={{
alignContent: "center",
borderRadius: actualItemRadius,
cursor: disabled ? "default" : "pointer",
}}
>
<Text ta={"center"} size={shouldScaleDown ? "xs" : "md"} lh={1}>
{date.getDate()}
</Text>
<NotificationIndicator events={events} isSmall={isSmall} />
</Container>
</HoverCard.Target>
<HoverCard.Target>{cell}</HoverCard.Target>
<HoverCard.Dropdown maw="calc(100vw - 24px)" w={512} pe={4} pb={0} style={{ overflow: "hidden" }}>
<CalendarEventList events={events} />
</HoverCard.Dropdown>
@@ -0,0 +1,170 @@
import { Accordion, MantineProvider, Tabs } from "@mantine/core";
import { act, createElement } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeAll, describe, expect, it } from "vitest";
import type { Root } from "react-dom/client";
/**
* `health-monitoring` and `media-missing` pass `keepMounted={false}` to `Tabs` because Mantine
* otherwise builds the inactive panel's whole subtree - two live monitoring views, or two full lists of
* media cards, when only one is ever visible.
*
* That reasoning came from Mantine's documentation, not from this codebase, and it is the entire
* justification for the prop. If a future Mantine version stops rendering inactive panels, the prop
* becomes dead weight and the remount cost it buys is being paid for nothing. This pins the behaviour
* in the version actually installed.
*/
describe("Mantine Tabs keepMounted", () => {
let container: HTMLDivElement;
let root: Root;
beforeAll(() => {
Object.defineProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT", { value: true, writable: true });
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => undefined,
removeListener: () => undefined,
addEventListener: () => undefined,
removeEventListener: () => undefined,
dispatchEvent: () => false,
}),
});
});
afterEach(() => {
root?.unmount();
container?.remove();
});
/** Two panels, each with a countable marker per "row", so hidden work is visible to the assertions. */
const renderTabs = async (keepMounted: boolean | undefined) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
// `children` goes in the props object rather than as variadic arguments, because Tabs.Panel
// declares it as a required prop.
const panel = (value: string, marker: string) =>
createElement(Tabs.Panel, {
value,
key: value,
children: Array.from({ length: 3 }, (_unused, index) =>
createElement("span", { className: marker, key: index }, `${marker}-${index}`),
),
});
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(
Tabs,
{ defaultValue: "visible", ...(keepMounted === undefined ? {} : { keepMounted }) },
createElement(
Tabs.List,
null,
createElement(Tabs.Tab, { value: "visible" }, "Visible"),
createElement(Tabs.Tab, { value: "hidden" }, "Hidden"),
),
panel("visible", "shown"),
panel("hidden", "offscreen"),
),
),
);
});
return {
shown: container.querySelectorAll(".shown").length,
offscreen: container.querySelectorAll(".offscreen").length,
};
};
it("renders the inactive panel's children by default, which is the cost being avoided", async () => {
const counts = await renderTabs(undefined);
expect(counts.shown).toBe(3);
// The whole reason the widgets pass keepMounted={false}: this is not 0.
expect(counts.offscreen).toBe(3);
});
it("does not render the inactive panel's children when keepMounted is false", async () => {
const counts = await renderTabs(false);
expect(counts.shown).toBe(3);
expect(counts.offscreen).toBe(0);
});
});
/**
* `Accordion` has the same behaviour and the same prop, which the widget sweep originally missed - it
* checked `Tabs` and not `Accordion`. Collapsed panels matter more here, because a collapsed accordion
* section is the *normal* state rather than the exception: coolify renders three remote-data resource
* lists this way, cluster-health its node tables, firewall its interface rows.
*/
describe("Mantine Accordion keepMounted", () => {
let container: HTMLDivElement;
let root: Root;
afterEach(() => {
root?.unmount();
container?.remove();
});
const renderAccordion = async (keepMounted: boolean | undefined) => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
const item = (value: string, marker: string) =>
createElement(Accordion.Item, { value, key: value }, [
createElement(Accordion.Control, { key: "control" }, value),
createElement(
Accordion.Panel,
{ key: "panel" },
Array.from({ length: 3 }, (_unused, index) =>
createElement("span", { className: marker, key: index }, `${marker}-${index}`),
),
),
]);
await act(async () => {
root.render(
createElement(
MantineProvider,
null,
createElement(
Accordion,
{ defaultValue: "open", ...(keepMounted === undefined ? {} : { keepMounted }) },
item("open", "shown"),
item("collapsed", "offscreen"),
),
),
);
});
return {
shown: container.querySelectorAll(".shown").length,
offscreen: container.querySelectorAll(".offscreen").length,
};
};
it("renders collapsed panel children by default, which is the cost being avoided", async () => {
const counts = await renderAccordion(undefined);
expect(counts.shown).toBe(3);
expect(counts.offscreen).toBe(3);
});
it("does not render collapsed panel children when keepMounted is false", async () => {
const counts = await renderAccordion(false);
expect(counts.shown).toBe(3);
expect(counts.offscreen).toBe(0);
});
});
@@ -0,0 +1,31 @@
import type { CSSProperties } from "react";
/**
* Style for a row in a widget that renders a list it does not bound.
*
* Widgets like these map over everything an integration returns and put it all in the DOM, while the
* tile shows a handful of rows. Measured on a real board: the media-releases widget rendered 80 cards
* into a 539 px tile whose content was 7,206 px tall — 73 of them entirely off-screen, each still
* costing layout, paint, raster, and an image fetch and decode. Decoded bitmaps are far larger in
* memory than the files they come from, which is what makes this expensive rather than merely untidy.
*
* Applying it there measured, over three repeats per side:
* image requests 135 -> 55 (-59%)
* layout objects 8346 -> 5117 (-41%)
*
* `content-visibility: auto` lets the browser skip all of that for a row that is scrolled out of
* view. Nothing leaves the DOM, so scrolling, keyboard navigation and in-page search behave exactly
* as before — this is not virtualisation.
*
* `contain-intrinsic-size` supplies a placeholder height for skipped rows. Without it the scrollbar
* jumps as rows enter and leave, because a skipped row would otherwise measure zero. Pass the
* approximate row height; being wrong only affects scrollbar smoothness, not correctness.
*
* Only worth applying to a list whose length is driven by remote data. On a list that always fits its
* tile the containment costs a little and saves nothing, and on non-list content — charts, editors,
* anything that measures itself — skipping layout can be actively wrong.
*/
export const offscreenRowStyle = (approximateRowHeight: number): CSSProperties => ({
contentVisibility: "auto",
containIntrinsicSize: `auto ${approximateRowHeight}px`,
});
+10 -1
View File
@@ -90,7 +90,16 @@ export function InstanceCard({ instance, options, isTiny, widgetKey }: InstanceC
</Group>
</Group>
<Accordion variant="filled" chevronPosition="right" multiple value={openSections} onChange={setOpenSections}>
{/* Collapsed panels are rendered by default, so all three resource lists were built even when
none is open. The open sections are parent state, so nothing is lost by unmounting. */}
<Accordion
variant="filled"
chevronPosition="right"
multiple
keepMounted={false}
value={openSections}
onChange={setOpenSections}
>
{options.showServers && (
<ServersSection
servers={instance.instanceInfo.servers}
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff. Show more