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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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).
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
@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.
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.
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.
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.
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.
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.
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.
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.
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.
- 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
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.
* fix: keep nginx IPv6 listen only when IPv6 is available (#4596)
Commit 2c48b1896 (1.45.2) made the internal nginx proxy listen on the IPv6
wildcard ('listen [::]:7575;'), which aborts nginx startup on hosts/VMs with
IPv6 disabled:
nginx: [emerg] socket() [::]:7575 failed (97: Address family not supported by protocol)
A plain revert would reintroduce #2697 and break IPv6-only/dual-stack
environments where this listener is required. Instead, only emit the IPv6
listen directive when the host actually has IPv6 configured.
Reuse the envsubst step run.sh already performs: compute NGINX_LISTEN_IPV6
from /proc/net/if_inet6 and substitute it into the template. proc files always
report size 0, so presence is detected by grepping the file (not 'test -s').
* fix: honor HOMARR_DISABLE_IPV6 override in nginx config generation
Review feedback on #6539: give users an explicit opt-out from the IPv6
listener even when the host reports IPv6. The issue reporter (#4596)
requested such an env var, and the pattern matches the existing
ENABLE_DNS_CACHING IPv6 toggle. Auto-detection behavior is unchanged when
the var is unset. Validated in containers: override wins, dual-stack
preserved by default, IPv4-only fallback on IPv6-less hosts.
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.
The barrel no longer exports PiHoleIntegrationV6 after the dynamic
import refactor. The mock only needs testConnectionAsync, so a type
cast is sufficient.
- 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
- 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)
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).
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.
- 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
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.
- 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
- 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)
* ci: drop --delete-branch from reconcile auto-merge call
The reconcile job runs without a git checkout, so gh pr merge --delete-branch
fails with 'not a git repository' before enabling auto-merge. Auto-merge
itself needs no local git state; branch cleanup is already handled by the
PR-creating workflows.
* ci: checkout repo in bot auto-merge workflows
gh pr merge fails with 'not a git repository' when no checkout is present,
which prevented the reconcile job from enabling auto-merge. Restore the
checkout the previous approval workflow relied on.
The reconcile job runs without a git checkout, so gh pr merge --delete-branch
fails with 'not a git repository' before enabling auto-merge. Auto-merge
itself needs no local git state; branch cleanup is already handled by the
PR-creating workflows.
* ci: auto-approve and auto-merge trusted bot pull requests
Replace the approve-only automatic-approval workflow with a combined
approve + enable-auto-merge workflow that also re-runs on every push,
plus a scheduled reconcile job that re-enables auto-merge for prepared
bot PRs if GitHub ever disables it. Bot PRs are gated to the known bot
actors, same-repo head, and base branch dev.
* ci: match bot authors by app slug in reconcile filter
gh pr list reports pull request authors of GitHub Apps as app/<slug>;
adjust the allowlist so the reconcile job actually matches bot PRs.
- 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
- 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)
- 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
Remove incompatible persisted widget query data before resetting a crashed widget so it can fetch fresh data instead of re-rendering the same stale payload.
* fix(immich): use thumbnail endpoint instead of original for album carousel
Switches from /api/assets/:id/original to /api/assets/:id/thumbnail to fix
HEIC image incompatibility (thumbnails are always JPEG), reduce bandwidth,
and improve loading speed.
Closes#6388
* working on immich
* Improve image proxy error handling
* fix(image-proxy): use trusted fetch response type
* docs(immich): require Asset View permission
The pnpm fetch docs recommend `pnpm install -r --offline` without
--frozen-lockfile. The --offline flag already enforces no registry
access, making --frozen-lockfile redundant.
https://pnpm.io/cli/fetch
PR #6296 introduced pnpm fetch in the Dockerfile for Docker cache
optimization. However, pnpm fetch crashes with ENOENT when
patchedDependencies are configured with nodeLinker: hoisted because
applyPatchToDir tries to chdir into node_modules/<pkg> which doesn't
exist during fetch (only the content-addressable store is populated).
Fix: temporarily switch nodeLinker to isolated during pnpm fetch so
patches apply inside the virtual store (node_modules/.pnpm/...) which
IS created by pnpm fetch. Restore hoisted before the install step so
the final node_modules layout stays flat.
Refs: pnpm/pnpm#5268, pnpm/pnpm#8726
* fix: context menu refresh stays open with loading spinner, cache age reacts to refetch
* style: auto-fix lint and format
* refactor: pass isFetching as prop to WidgetCacheAge to avoid duplicate useIsFetching
---------
Co-authored-by: ajnart <ajnart@users.noreply.github.com>
Next.js 16.2.9 does not fully support TypeScript 7 (tsgo) as the
primary typescript package. The Go-native compiler has no in-process
JavaScript API — lib/typescript.js is missing — which causes Next.js's
hasNecessaryDependencies check to fail and the build to SIGABRT with
CI=true. Vercel is working on native tsgo integration expected in Q3
2026 alongside TypeScript 7 stable.
The Mantine Indicator component (used by the Proxmox cluster health
widget for VM/LXC status dots) sits at z-index 200, which is higher
than the AppShellHeader's default 100. This caused the status dots
to bleed above the header when scrolling.
Set the header z-index to 201 so it stacks above Indicator content
without conflicting with modal/popover layers (300+).
Closes#6194
* fix(widget-secrets): add missing widget_secret migration and add error notifications
The widget_secret table was defined in all three Drizzle schema files but
no SQL migration was ever generated to create it, causing setSecret to
fail with 'no such table: widget_secret' in production (drizzle-kit push
in development hid the problem).
- Add migration SQL for sqlite (0040), mysql (0042), postgresql (0008)
- Update journal entries for all three dialects
- Add onError handlers to setSecret/deleteSecret mutations so errors
are shown as red notifications instead of silently swallowed
Fixes#6186
* fix(widget-secrets): add missing drizzle snapshots for merge-url and widget_secret migrations
The merge_custom_widget_url migration (0039/0041/0007) was committed
without a corresponding snapshot, breaking drizzle-kit generate for
all future migrations. This adds:
- 0039/0041/0007 snapshots reflecting the merge-url changes
(base_url+endpoint -> url, add enabled column)
- 0040/0042/0008 snapshots reflecting the widget_secret table
All snapshot chains now match journal entries (41/43/9 entries each).
Widgets sit inside board cards that have user-controlled opacity. The table must be fully transparent so the card background shows through. mantine-datatable defaults to `var(--mantine-color-body)` which is opaque.
function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
if (!ctx.optionsColumnSet.has(accessor)) return false;
const check = columnVisibilityChecks[accessor];
if (check && !check(ctx)) return false;
return true;
}
```
---
## 4. Column Persistence (Drag/Resize → Server)
`useDataTableColumns` stores column order and widths in localStorage by default. For widgets, this must persist server-side so it works across devices.
### Hidden widget options
In `index.ts`, add hidden text options:
```typescript
columnOrder: factory.text({ defaultValue: "" }),
columnWidths: factory.text({ defaultValue: "" }),
```
With visibility override:
```typescript
{
columnOrder: { shouldHide: () => true },
columnWidths: { shouldHide: () => true },
}
```
### Translation keys required
Even hidden options need translation keys or the translation test fails:
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
**Key fix:** Use `closeOnItemClick={false}` if any menu item needs a two-step confirmation (like delete → confirm). Close the menu on scroll: `onScroll={() => { if (contextMenu) closeContextMenu(); }}`.
When a column has `ellipsis: true`, the tooltip target element can still extend beyond the cell bounds if inner elements don't clip. Fix:
```tsx
<Tooltip position="bottom-start" ...>
<Group style={{ overflow: "hidden" }}>
...
</Group>
</Tooltip>
```
Use `position="bottom-start"` instead of `position="right"` so the tooltip never escapes the widget horizontally.
### Dark mode tooltips
Mantine tooltips default to the theme's tooltip color which may be white in light mode and stay white in dark mode. Always set `color="dark"` on informational tooltips inside widgets.
### Division by zero
Guard all ratio calculations:
```typescript
if (item.size > 0) ratio = item.sent / item.size;
if (totalDown > 0) ratio = totalUp / totalDown;
```
### Composite idAccessor
When rows can come from multiple integrations, IDs may collide. Use a composite key:
Install [Act](https://nektosact.com/installation/index.html) and ensure Docker is running, then use:
```bash
pnpm ci:act:fast
pnpm ci:act:docker
pnpm ci:act:all
pnpm ci:act:cleanup
```
`fast` runs the quality gate. `docker` and `all` run the gate followed by the single container build, E2E suite, and memory regression test. `cleanup` validates the manual cleanup path without calling GitHub. The wrapper supports regular clones and Git worktrees, including non-default Docker contexts. Linux dependencies are kept in Docker volumes instead of overwriting host `node_modules`. The first run downloads the Act runner and Playwright browser dependencies.
Remote caching is optional locally. Copy `.github/act/.secrets.example` to `.github/act/.secrets`, populate it with rotated credentials, and run with:
The tag is replaced when the pull request is updated and deleted when it closes. Production Homarr images remain the supported multi-architecture releases. The `homarr-test` package must be configured as public in GHCR for unauthenticated testers.
## Dokploy preview cache
Keep `needs-demo` as the Dokploy preview label. Under the application's **Preview Build-time Secrets**, configure `TURBO_API`, `TURBO_TEAM`, `TURBO_TOKEN`, and `TURBO_REMOTE_CACHE_SIGNATURE_KEY`. Dokploy passes these to the Dockerfile as BuildKit secrets. Do not configure the token or signature key as Docker build arguments.
Rotate any credentials that have appeared in chat, logs, or committed configuration before enabling the cache.
The host-side developer CLI builds and runs local Homarr images and pull-request previews. Run it directly from a checkout with `pnpm dev:cli -- ...`; no shell-specific setup script is required. It is separate from the recovery CLI included in production images.
To install an optional `homarr` binary, run `pnpm dev:cli:install` and make your Go bin directory available on `PATH`. GitHub CLI authentication is only needed for pull-request features.
## Browse development images
```bash
pnpm dev:cli --
```
The main screen combines local `homarr:*` images with open pull requests and their GHCR preview status. Select an image to start or stop it, use `R` to build a remote pull request locally in the background or rebuild a local image, or use `p` to force a remote preview pull. Press `d` to switch to the instance dashboard and `d` again to return without losing the browser state. The explicit `dev` and `dash` subcommands start on either screen.
A locally built PR image is folded into its remote PR row only when its recorded revision exactly matches the current GitHub head SHA. The row is marked `◆ local`, and starting it prefers that local image without a registry pull. Outdated local builds remain separate rows. Navigation and dashboard switching remain available during background builds.
## Build images
```bash
pnpm dev:cli -- build <name>
pnpm dev:cli -- build --pr <number> [name]
```
The first command builds the current checkout as `homarr:<name>`. The pull-request form uses a depth-one clone in a temporary directory and defaults to `homarr:pr-<number>` when `name` is omitted.
## Rebuild images
```bash
pnpm dev:cli -- rebuild <name>
```
Images built by this CLI record their source checkout, revision, and optional pull-request number. Rebuild uses that provenance to select the original checkout or create a fresh temporary pull-request checkout.
## Run images
```bash
homarr run <name>
homarr run --pr <number>
```
Add `--detach` to run in the background, `--demo` to enable demo data, or repeat `--env KEY=VALUE` to provide additional environment variables.
@@ -14,11 +14,12 @@ This guide will help you set up your development environment to start contributi
### Prerequisites
- **Node.js (lts)**: You can download it from the [official website](https://nodejs.org/).
- **pnpm (latest)**: You can install it by running `npm install -g pnpm`.
- **Node.js 24.18 or newer**: You can download it from the [official website](https://nodejs.org/).
- **Corepack**: Run `corepack enable` so the repository selects its pinned pnpm version.
- **Git**: You can download it from the [official website](https://git-scm.com/).
- **Docker or Docker Desktop**: You can download it from the [official website](https://www.docker.com/).
- **A GitHub account**: You can create one on the [official website](https://github.com/).
- **Docker or Docker Desktop**: Required for the Redis service and the developer Docker CLI.
- **Go 1.25+**: Required only for the developer Docker CLI. You can download it from the [official website](https://go.dev/).
- **GitHub CLI**: Required only for pull-request image features. Install it from the [official website](https://cli.github.com/), then authenticate with `gh auth login`.
### Setting up the project
@@ -34,11 +35,11 @@ This guide will help you set up your development environment to start contributi
4. Install the dependencies with `pnpm install`.
5. Start redis by running `pnpm run docker:dev`. This will run a docker-compose file with a redis service.
5. Create `.env` from `.env.example`. In a POSIX shell, run `cp .env.example .env`; in PowerShell, run `Copy-Item .env.example .env`. Set `DB_URL` to an absolute path for your SQLite file.
6. Copy the `.env.example` file to `.env` and replace `DB_URL` with the full path you want to use for the sqlite database file. For example `DB_URL='C:\\Dev\\homarr\\packages\\db\\db.sqlite'`
6. Start Redis in the background with `pnpm docker:dev:up` if your development workflow needs it.
7. Run the migrations to create the sqlite db file with `pnpm run db:migration:sqlite:run`.
7. Create or update the database with `pnpm db:migration:sqlite:run`. This also seeds the default data. To run the seed explicitly later, use `pnpm db:seed`.
8. Start the development server with `pnpm dev` and wait for a message that says `✓ Ready in 10.7s`
@@ -48,9 +49,19 @@ This guide will help you set up your development environment to start contributi
#### During development
- `pnpm dev`: Start the development server.
- `pnpm dev`: Start the Next.js development server.
- `pnpm dev:benchmark`: Run the local authenticated-board benchmark. Set `DEV_BENCHMARK_BOARD_URL` and
`DEV_BENCHMARK_STORAGE_STATE`; readiness-only measurements require the explicit `--smoke` flag and are not
suitable for performance claims.
- `pnpm dev:cli -- dev`: Browse and run local images and remote pull-request images.
- `pnpm dev:cli -- build <name>`: Build the current checkout as `homarr:<name>`.
- `pnpm dev:cli -- build --pr <number>`: Build a pull request locally from a temporary checkout.
- `pnpm dev:cli -- rebuild <name>`: Rebuild a local image from its recorded checkout or pull request.
- `pnpm dev:cli:install`: Optionally install the developer CLI as a `homarr` binary.
- `pnpm cli`: Run cli commands.
- `pnpm db:migration:sqlite:run`: Run the migrations to create or update the sqlite db file.
- `pnpm db:seed`: Explicitly seed the default database data.
- `pnpm docker:dev:up`: Start the Redis development service in the background.
- `pnpm db:migration:sqlite:generate`: Generate a new migration file for the sqlite db. Also works with `mysql`.
- `pnpm db:studio`: Open a visual studio for the database.
- `pnpm package:new`: Create a new package in the monorepo.
@@ -63,6 +74,8 @@ This guide will help you set up your development environment to start contributi
- `pnpm typecheck`: Run the typescript type checker.
#### Building the project
- `pnpm build`: Build the project for production.
- `pnpm dev:cli -- build <name>`: Build a `homarr:<name>` image and record its checkout for later rebuilds.
- `docker build -t homarr .`: Build a docker image for the project.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:latest`: Run the created docker image.
- `docker run -p 7575:7575 -e SECRET_ENCRYPTION_KEY='your_64_character_hex_string' homarr:<name>`: Run the created docker image.
@@ -25,15 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
| ``PUID`` | User ID to run the container as | Any valid user ID | ``0`` |
| ``PGID`` | Group ID to run the container as | Any valid group ID | ``0`` |
| ``LOG_LEVEL`` | Log level to use | ``debug`` / ``info`` / ``warn`` / ``error`` | ``info`` |
| ``NO_EXTERNAL_CONNECTION`` | Disables some requests that need internet connection | `true` or `false` | `false` |
| ``ENABLE_DNS_CACHING`` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
## Authentication
@@ -43,9 +42,9 @@ See [Single Sign-On](/docs/advanced/single-sign-on) for more informations.
The `SECRET_ENCRYPTION_KEY` is required. If none is specified before starting the container, a random key will be shown in the error message and the container will exit.
| `DOCKER_HOSTNAMES` | Comma-separated list of hostnames to connect to | For example `localhost,docker.example.com` | - |
| `DOCKER_PORTS` | Comma-separated list of ports to connect to | For example `2375,2376` | - |
| `DOCKER_SOCKET_PATHS` | Comma-separated list of mounted Docker-compatible socket paths. See the [Docker and Podman integration guide](/docs/integrations/docker) | For example `/var/run/docker.sock,/var/run/podman.sock` | - |
| ``DB_DRIVER`` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | ``better-sqlite3`` / ``mysql2`` / ``node-postgres`` | ``better-sqlite3`` |
| `DB_DRIVER` | Database driver to use. Currently `better-sqlite3` is used for sqlite, `mysql2` for mysql and `node-postgres` for postgresql | `better-sqlite3` / `mysql2` / `node-postgres` | `better-sqlite3` |
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
| `REDIS_HOST` | Hostname of the Redis instance. | Any valid hostname | - |
| `REDIS_PORT` | Port of the Redis instance. | Any valid port | `6379` |
| `REDIS_USERNAME` | Username to connect to the Redis instance. | Any valid username | - |
| `REDIS_PASSWORD` | Password to connect to the Redis instance. | Any valid password | - |
| `REDIS_DATABASE_INDEX` | Select which database of your redis instance should be used. | Any numeric index | - |
| `REDIS_TLS_CA` | CA certificate for Redis TLS connections. If a certificate is specified the connection will use TLS. | Any valid CA certificate | - |
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
If you'd rather manage group memberships for OIDC users locally (instead of via the IdP), set `AUTH_OIDC_GROUPS_LOCAL_MANAGEMENT=true`. While enabled, Homarr stops syncing OIDC users' group memberships from the groups claim, and admins can add/remove OIDC users on a group's members page. If you turn this back to `false`, the next login of each user resumes the regular sync and reconciles their groups with the IdP's claim again, which may remove memberships that were added manually while local management was enabled.
To let an existing credentials user also sign in with OIDC, set `AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING=true`. On the first OIDC sign-in, Homarr links a credentials user with the same email address instead of creating a second user. The user keeps their password login, local username and profile, groups, permissions, boards, settings, and API keys. OIDC profile and group claims do not overwrite the credentials-owned data. Homarr requires the OIDC profile to contain `email_verified: true` while this option is enabled. Only enable this when your OIDC provider verifies email addresses, because a matching email could otherwise be used to take over an existing account.
| ``AUTH_OIDC_NAME_ATTRIBUTE_OVERWRITE`` | Overwrite name attribute. By default it will use preferred_username if it does not contain a `@` and otherwise name. | --- |
| ``AUTH_OIDC_FORCE_USERINFO`` | Force userinfo endpoint to be used for user information. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_ACCOUNT_LINKING`` | Enable account linking for OIDC provider. This will link the OIDC accounts by email. Be sure that you have verified emails to prevent stealing accounts. | false |
| ``AUTH_OIDC_ENABLE_DANGEROUS_CREDENTIALS_LINKING`` | Link an OIDC identity to an existing credentials user with the same email. The credentials login and existing user data are preserved. Requires the OIDC profile to contain `email_verified: true`. | false |
| ``AUTH_OIDC_TOKEN_ENDPOINT_AUTH_METHOD`` | Override method for the token endpoint authentication. Supported values are `client_secret_basic`, `client_secret_post`, `client_secret_jwt` and `none`. | client_secret_basic |
@@ -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.
| hostAliases | list | `[]` | Add static entries to /etc/hosts in the Pod. This is useful in the following cases: - You are running in a dual-stack cluster (IPv4 + IPv6) and want to force usage of IPv4 for specific hostnames - Your application is having DNS resolution issues or IPv6 preference issues - You need to override or simulate DNS entries without changing global DNS - You are running in an air-gapped or isolated environment without external DNS Example: hostAliases: - ip: "192.168.1.10" hostnames: - "example.com" - "example.internal" |
| httproute.hostnames | list | `["chart-example.local"]` | Hostnames this route matches (similar to ingress.hosts.host) |
| httproute.parentRefs | list | `[{"name":"my-gateway","namespace":"default"}]` | References to the parent Gateway(s) this route attaches to. Each item must include at least a `name`, and optionally a `namespace`. |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":8080}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules | list | `[{"backendRefs":[{"name":"homarr","port":7575}],"filters":[],"matches":[{"path":{"type":"PathPrefix","value":"/"}}]}]` | List of routing rules. Each rule can include: - matches: path/header/query matching - filters: optional transformations (redirects, header modifications, etc.) - backendRefs: one or more Kubernetes Services to forward traffic to |
| httproute.rules[0].filters | list | `[]` | Optional filters for this rule (default: empty) |
| httproute.rules[0].matches[0].path.type | string | `"PathPrefix"` | Path match type. One of: Exact, PathPrefix, RegularExpression |
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
description: "Bazarr is a companion application to Sonarr and Radarr that manages and downloads subtitles."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { bazarrIntegration } from '.';
import { bazarrWidget } from '@site/docs/widgets/bazarr';
<IntegrationHeader
integration={bazarrIntegration}
categories={['Subtitles', 'Media']}
/>
Bazarr complements Sonarr and Radarr by managing subtitle downloads. The Homarr integration uses your Bazarr API key to fetch missing subtitle counts and health badges.
### Screenshots
import apiKeySettings from './img/api-key-settings.png';
import widgetOverview from '@site/docs/widgets/bazarr/img/widget-overview.png';
<div className="flex flex-col gap-4">
<div>
<p>API key location in Bazarr (<strong>Settings → General → Security</strong>):</p>
description: "PatchMon is a patch management and monitoring solution for Linux hosts."
hide_title: true
---
import { IntegrationHeader } from '@site/src/components/integrations/header';
import { IntegrationCapabilites } from '@site/src/components/integrations/widgets';
import { AddingIntegration } from '@site/src/components/integrations/adding';
import { IntegrationSecrets } from '@site/src/components/integrations/secrets';
import { patchmonIntegration } from '.';
import { patchmonWidget } from '@site/docs/widgets/patchmon';
<IntegrationHeader
integration={patchmonIntegration}
categories={['Health Monitoring']}
/>
PatchMon provides host and package update statistics including total hosts, hosts needing updates, and security update counts.
The PatchMon widget requires users to be **signed in to Homarr**; see the [PatchMon widget documentation](/docs/widgets/patchmon#authentication) for details on authentication and public boards.
| GPU | Not available | Typical DiskStation models |
### Adding the integration
<AddingIntegration />
<Alert type="info" title="Use the correct DSM URL">
Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example <code>http://diskstation.local:5000</code>{" "}
or <code>https://diskstation.local:5001</code>. If DSM uses a self-signed certificate, add it under{" "}
<a href="/docs/management/certificates">Management > Tools > Certificates</a> so Homarr can connect securely.
</Alert>
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: ["username", "password"],
steps: [
"Create a dedicated DSM user with a strong password for Homarr.",
"Add the user to the default Administrators group so Homarr can read `SYNO.Core.System` metrics.",
"Under User Groups and Permissions, deny access to shared folders and most applications.",
"Allow login to DSM only, optionally restricted by source IP address.",
"Log in once with the service account in DSM to accept terms and conditions.",
],
body: (
<>
<Alert type="caution" title="Administrators group required">
Homarr reads system, storage, and utilization data from APIs that require membership in the default
Administrators group.
</Alert>
<Alert type="warning" title="Two-factor authentication is not supported">
Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service
account without 2FA.
</Alert>
<Alert type="tip" title="First-time setup">
If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel
> Security > Protection, then re-enable it after Homarr connects successfully.
</Alert>
</>
),
},
]}
/>
### Storage volume selection
Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name.
<Alert type="note" title="Synology-only option">
The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid.
</Alert>
<Alert type="note" title="Volume ordering">
When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`.
Use the URL where Traefik exposes its dashboard/API, for example `http://traefik:8080` or `https://traefik.example.com`. Homarr calls Traefik endpoints under `/api` automatically.
:::
:::warning API requirement
Traefik must have the API enabled. For local or internal setups this is commonly exposed through `api.insecure=true`; for production setups, expose the `api@internal` service through an authenticated router.
:::
### Secrets
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [
"Choose this option when the Traefik API is only reachable from a trusted internal network and does not require authentication.",
],
},
{
credentials: ["username", "password"],
steps: [
"Use this option when your Traefik dashboard route is protected by Basic Auth.",
"Enter the same username and password required to open the Traefik dashboard.",
],
},
{
credentials: ["apiKey"],
steps: [
"Use this option when your Traefik dashboard route accepts a bearer token.",
"Paste the token into the API Key field in Homarr.",
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are the building blocks of your Homarr board.
Most of the time they are a simple link to a website, but used on your board they also support a simple ping to check if your service is up.
Apps are used in the bookmark and app widget.
## Create an app
@@ -27,8 +27,8 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
It can be a url to an image in the internet, one from the included icon repositories or a local file uploaded through the upload button.
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.

@@ -40,14 +40,17 @@ An optional description that can be shown as tooltip on the app widget.
The URL to the website you want to link to. Clicking on the app widget or bookmark will open this URL.
You can also use custom URI schemes such as `vscode://file/path`, `microsoft-edge:https://example.com`, or `myapp://open` to link to local applications or third-party programs. We trust that you know what you are doing — only `javascript:` URIs are blocked for security reasons.
### Ping URL
When you want to use another url for the simple status check you can set it here.
When you want to use another url for the simple status check you can set it here.
This will allow you to set a different URL for the ping than the URL that is opened when clicking on the app widget.
It's useful when you want to ping the service internally for example with a custom ip but open the website externally.
Note that the ping URL only accepts `http://` and `https://` URLs — custom URI schemes are not supported for pings.
## List of apps
On the apps management page you find all apps that exist in your Homarr instance. You can edit and delete them.
import itemRadiusImage from './img/item-radius.png'
import layoutLargeImage from './img/layout-large.png'
import layoutMediumImage from './img/layout-medium.png'
import layoutSmallImage from './img/layout-small.png'
import iconColorImage from "./img/icon-color.png";
import itemRadiusImage from "./img/item-radius.png";
import layoutLargeImage from "./img/layout-large.png";
import layoutMediumImage from "./img/layout-medium.png";
import layoutSmallImage from "./img/layout-small.png";
Each dashboard is called a "board".
You can create as many boards as you want.
@@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
Names of boards must be unique because this will be used in the URL to load the desired board.
## Create a new board
When creating a board, the availability of the board name will be checked for you.

### Name
The name must not include any special characters and must be at least one character long.
Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag and drop system that can be used from the browser.
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
You navigate to the board settings by clicking on these three dots:

@@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic

All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
### General
#### Page title
This is the title that will be visible at the top left in the navigation bar:

@@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar:
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
#### Meta title
This is the secondary title of the page that is not directly visible in the page itself.
Browser will use it to set the title of tabs:
@@ -67,10 +77,12 @@ Browser will use it to set the title of tabs:
Homarr will fall back to the board name and a suffix if nothing is set.
#### Logo image URL
This is the URL that is used for the image at the top left.
Homarr will fall back to the Homarr logo if nothing is set.
#### Favicon image URL
This is the URL that is used for the favicon in the tab title.
Homarr will fall back to the Homarr logo if nothing is set.
@@ -94,51 +106,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minmum of the next layout is reached the layout will change.
Minimum width of the screen where the layout will be used.
Once the minimum of the next layout is reached the layout will change.
#### Preview
<img src={layoutSmallImage} height={256} />
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
<img src={layoutSmallImage} height={256} />
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
### Background
#### Background image URL
Homarr can display a background image behind apps and widgets.
This URL can be used to define what image should be shown.
#### Background image attachment
This defines whether the background should move when the user scrolls.
#### Background image size
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
#### Background image repeat
This defines whether the background should be repeated if it cannot cover the entire page.
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):

### Appearance
#### Primary color
Color that can be chosen freely to give accents to active elements.
#### Secondary color
Color that can be chosen freely to give accents.
#### Opacity
Opacity of the colors.
#### Icon color
Change the color of your bookmark / app icons.
<img src={iconColorImage} height={100} />
<img src={iconColorImage} height={100} />
#### Item radius
Change the radius of your items / categories.
<img src={itemRadiusImage} height={100} />
<img src={itemRadiusImage} height={100} />
### Custom CSS
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
@@ -147,14 +173,16 @@ The styling will only be applied on the respective board page.
### Behaviour
#### Disable app status
This will disable the status check for apps.
### Access control
With the access control, you can permit a single user or a group to your board.
The users and groups can have one of these three levels of access:
The settings page has a few options that will be applied to the entire Homarr application.
They may require a full page reload for changes to apply.
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
By default, Homarr is collecting anonymous analytics to identify issues and prioritize bugfixes and improvements.
The data is never shared with any third-parties and is fully GDPR & CCPA compliant.
The software being used is [Umami Analytics](https://umami.is/) which is fully open source.
The main server software can be found [here](https://github.com/umami-software/umami) and [this node library](https://github.com/umami-software/node) to perform server side calls to the API.
A full list of the collected data by default can be found here: https://umami.is/docs/metric-definitions.
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
We collect this data to see what features people ACTUALLY use.
We use this data to decide where we spend our development time.
We understand that you might not want Homarr to communicate with our services.
By unchecking the ``Send anonymous analytics`` checkbox and reloading the full page, all communication by Umami will be disabled and no data will be collected.
If you are paranoid, you can permanently block ``umami.homarr.dev`` from your DNS / firewall.
We do not sell the data. We do not share the data with third parties.
The data does not contain personal information and does not identify you or your installation.
Additionally, you opt in to more data collection to help us provide more useful data.
Homarr sends the data to our own PostHog server at `https://hog.homarr.dev`.
The server sends the data once a week and when the server starts.
### Integration data
Enabling this optional data will send the types of integrations (e.g. SABnzbd, Sonarr, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
### What we collect
### Widget data
Enabling this optional data will send the types of widgets (e.g. downloads, media streams, iframe, ...) and how many you have configured of them.
This will not include any URLs, names, secrets, errors and others. This is disabled by default.
Homarr collects technical data about your installation:
### Users data
Enabling this optional data will send the count of users and whether you activated SSO.
This will not include any usernames, passwords, emails, URLs, authentication flows or client IDs.
This is disabled by default.
- Your Homarr version
- Your database type (SQLite, MySQL, or PostgreSQL)
- Whether Docker or Kubernetes is enabled
- Your enabled authentication providers
- Your operating system and CPU architecture
- Your default language
- The number of boards, apps, sections, layouts, users, groups, search engines, icon repositories, cron jobs, API keys, invites, sessions, and accounts
- The integration types you use and how many you have of each (for example, Sonarr or SABnzbd)
- The widget types you use and how many you have of each
Homarr does NOT collect:
- URLs
- Names
- Usernames, emails, or passwords
- Integration secrets or API keys
- Error messages or logs
- Any data that can identify you
Each installation gets a random ID. We use this ID only to count unique installations. We cannot use this ID to identify you.
Homarr does not use cookies for analytics. Homarr does not track page views automatically. Homarr only sends the data listed above.
### Disable analytics
Analytics are enabled by default. To disable analytics:
1. Open the Settings page.
2. Uncheck the "Help us improve Homarr" checkbox.
3. Reload the full page.
When analytics are disabled, Homarr does not send any data to PostHog.
You can also set the `NO_EXTERNAL_CONNECTION` environment variable to `true`. This stops all external requests, including analytics. See the [environment variables](/docs/advanced/environment-variables) page for more information.
You can block `hog.homarr.dev` in your DNS or firewall. Homarr cannot send data when this address is blocked.
## Search Engine Optimization (SEO)
Big companies [like Google, Microsoft, Baidu and Yandex](https://de.wikipedia.org/wiki/Web-Index) will use [web crawlers](https://de.wikipedia.org/wiki/Webcrawler) to index the Internet for new websites & pages.
This is desirable in many cases (for example when selling products in an online shop) but can lead to unwanted traffic and attention to your personal dashboard.
Homarr is requesting the crawlers to not index Homarr instances by default for this reason.
If you use Homarr as a public website and want it to be searchable on these search engines, you can disable these settings.
Please note that Homarr doesn't have control over what the crawlers do; it is a mere request to them to not perform these actions but will not guarantee that your website doesn't land on some index.
Search engines use web crawlers to index websites. This can cause unwanted traffic to your dashboard.
Homarr asks crawlers to not index your instance by default. Use these settings to change this request.
Homarr cannot control what the crawlers do. A crawler can ignore the request. Your website can still appear in an index.
### No index
This will ask crawlers to not index your Homarr instance on any indexes.
It is up to the crawler to properly respect this request.
By default, this setting is enabled. Disabling it will allow indexing.
This setting asks crawlers to not index your instance.
This setting is enabled by default. Disable this setting to allow indexing.
### No follow
If indexing is allowed, crawlers will also attempt to follow any links on your page to further "discover" new parts of the Internet and on your website.
By default, this setting is enabled and will ask crawlers to not follow any links.
Crawler of different companies may behave differently when indexing is prohibited and following is allowed.
Crawlers follow links to discover new pages.
This setting asks crawlers to not follow links. This setting is enabled by default.
Crawler behavior can differ between search engines.
### No translate
Google and Chrome on Android will prompt users to translate pages that are not in their device language.
This can lead to some shifting and can be undesired in some cases. By enabling this,
you ask crawlers to never translate and never prompt users for translation.
It should be noted that some browsers will ignore this and translate regardless.
Some browsers offer to translate pages.
This setting asks crawlers to not offer translation. Some browsers ignore this setting.
### No site links search box
This setting is exclusive to Google.
[Google will create a box below your domain](https://developers.google.com/search/docs/appearance/structured-data/sitelinks-searchbox) that lists the paths that were discovered by the "folowing".
The user can search them directly in Google.
You can ask the crawlers to not allow searches by enabling this setting.
This setting is disabled by default.
This setting applies to Google only.
Google can show a search box below your domain.
This setting asks Google to not show this box. This setting is disabled by default.
## Boards
### Global home board
You can set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
Note that the board must be public to be used as a global home board.
You can set a global home board.
This board is the fallback for users without their own home board. It is also the fallback for anonymous users.
The board must be public.
### Global mobile board
You can set a global mobile board. It will be shown instead of the global home board on mobile devices that can be detected by Homarr. (Using the User-Agent header).
It will similar to the global home board be as fallback for normal users and for any anonymous users.
It also has to be public.
You can set a global mobile board.
Homarr shows this board on mobile devices instead of the global home board.
Homarr detects mobile devices with the User-Agent header.
The board must be public.
### App status
#### Enable status by default
You can decide if the app status should be enabled by default when adding a new app to the board.
You can decide if app status is enabled when you add a new app.
#### Force disable status
You can decide if the app status should be disabled for all boards.
###
You can decide if app status is disabled on all boards.
## Appearance
### Default color scheme
You can set the default color scheme for all users that have not set a color scheme themselves.
You can set the default color scheme for users without their own color scheme.
## Culture
### Default language
You can set the default language for all users that have not set a language themselves.
You can set the default language for users without their own language.
@@ -18,7 +18,9 @@ import beszelSystemGrid from './img/beszel-system-grid.png';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space.
This widget displays all your Beszel-monitored systems as a responsive card grid. Each card shows real-time metrics like CPU, memory, disk, network, temperature, and more. Cards adapt their layout based on available space. If Beszel reports multiple filesystems, the disk bar exposes a hover breakdown for the root disk and each extra mount.
Select a system card to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. Data is cached and refreshed periodically. You can select a time period ranging from 1 hour to 30 days.
This widget displays time-series charts for a selected Beszel system, including CPU, memory, disk, network, and Docker container metrics. The Disk Usage chart combines the root disk and every extra filesystem reported by Beszel. You can use the realtime Live view or select a historical period ranging from 1 hour to 30 days. Live view keeps a one-minute window and pauses while the browser tab is hidden. If the live stream drops, the widget now shows the connection failure state immediately and can switch back to historical data.
### Screenshots
@@ -26,14 +23,19 @@ This widget displays time-series charts for a selected Beszel system, including
### Supported Integrations
<WidgetIntegrations items={[{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system"
}]} />
<WidgetIntegrations
items={[
{
integration: beszelIntegration,
note: "Displays time-series charts for a selected system",
@@ -17,7 +17,16 @@ import { beszelIntegration } from '@site/docs/integrations/beszel';
categories={['System Monitoring']}
/>
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, and the table supports sorting by any column.
This widget displays all your Beszel-monitored systems in a sortable data table. Each row shows system metrics like CPU, memory, disk, network, temperature, and more. Columns can be toggled on or off, dragged into a different order, and resized from their headers. Column order and widths are saved automatically for each widget. When Beszel reports extra filesystems, the disk column adds a hover breakdown for each mount alongside the root disk.
Select a system row to open the complete statistics view, including system, filesystem, Docker, historical, and realtime charts.
### Interactions
- **Reorder columns** by hovering a header to reveal its grab handle, then dragging it
- **Resize columns** by dragging a header's right edge
- **Sort systems** by selecting a sortable column header
@@ -40,12 +40,19 @@ import dockerTable from './img/table.png';
### Interactions
Each container row has an action menu (accessible via the three-dot icon) with the following options:
Hover a column header to reveal its grab handle, then drag it to reorder the table or drag the header's edge to resize it. The layout is saved automatically for each widget instance and restored after a reload. These controls are disabled while the board is in edit mode.
Each container row has an action menu, accessible via the three-dot icon or by right-clicking the row, with the following options:
- **View logs** - opens a dedicated terminal page streaming the container's logs
- **Start** / **Stop** / **Restart** - manage the container's state
- **Remove** - remove the container
- **Remove** - permanently remove the container after a second confirmation click
- **Add to Homarr** - create a bookmark app from the container
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information.
This widget displays a list of torrents or usenet-articles that are currently being downloaded, along with their progress and other relevant information. It supports 7 download client integrations and provides a rich, responsive table interface.
### Screenshots
import downloadsCard from './img/screenshot.png';
import contextMenuImg from './img/context-menu.png';
<img src={contextMenuImg} alt="Downloads widget context menu with pause, delete, and copy actions" width={128 * 4} />
</div>
### Features
- **Responsive table** — columns automatically adapt to widget width, hiding less important columns at smaller sizes
- **Sortable columns** — click any column header to sort ascending/descending
- **Column customization** — choose which columns to display, then hover a header to reveal its grab handle for reordering or drag its edge to resize it; layouts save automatically per widget
- **Right-click context menu** — pause, resume, delete downloads, or copy download names directly from the context menu
- **Row expansion** — click any row to see full details inline without opening a separate modal
- **Hover tooltips** — hover a download name to see size, speeds, ETA, ratio, and category
- **Global stats bar** — toggle a summary bar showing queue progress, total size, speeds, ratio, and per-state counts
- **Client filtering** — filter downloads by integration or status using the footer filter panel
- **Global controls** — pause/resume all downloads across all clients from the footer bar
- **Status indicators** — color-coded badges show download state at a glance
- **Speed overview** — total download and upload speeds displayed in the footer
### Supported Integrations
<WidgetIntegrations items={[{
@@ -52,16 +71,23 @@ import downloadsCard from './img/screenshot.png';
}, {
integration: qBittorentIntegration,
note: "Torrent client"
}, {
integration: aria2Integration,
note: "Multi-protocol download client"
}, {
integration: slskdIntegration,
note: "Soulseek client"
note: "Soulseek client (read-only)"
}]} />
### Interactions
- Pause / Resume one download by clicking on the according icon.
- Delete one download by clicking on the trash icon. (Can also delete the files)
- Pause / Resume all downloads by using the bottom button.
- **Right-click** any download to open a context menu with pause/resume, delete, and copy actions
- **Click** any row to expand it and see full download details inline
- **Hover** a download name for a quick stats tooltip
- **Stats toggle** — show or hide the global stats bar from the footer
- **Pause / Resume** all downloads using the footer controls
- **Filter** by integration or status using the filter button in the footer
- **Sort** by clicking column headers (with persistent sort direction)
description: 'Track missing media and active downloads from Radarr and Sonarr'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { mediaMissingWidget } from '.';
import { radarrIntegration } from '@site/docs/integrations/radarr';
import { sonarrIntegration } from '@site/docs/integrations/sonarr';
<WidgetHeader
widget={mediaMissingWidget}
categories={['Media', 'Downloads']}
/>
Monitor which movies and episodes are missing from your library, and track what is currently being downloaded. The widget pulls data from Radarr and Sonarr and groups results by type in two tabs.
### Screenshots
import widgetMissing from './img/missing.png';
import widgetQueued from './img/queued.png';
import widgetCompact from './img/compact.png';
<div className="flex gap-2 flex-wrap">
<img src={widgetMissing} alt="media-missing widget showing missing movies and episodes in a grid" width={360} />
<img src={widgetCompact} alt="media-missing widget in a compact wide-and-short size" width={256} />
</div>
### Supported Integrations
<WidgetIntegrations items={[{
integration: radarrIntegration,
}, {
integration: sonarrIntegration,
}]} />
### Tabs
| Tab | Description |
|---|---|
| **Missing** | Lists movies (Radarr) and episodes (Sonarr) that are monitored but not yet available in your library. |
| **Queued** | Shows items currently being downloaded with a progress bar and estimated time remaining. |
### Sizing
The widget adapts to its container size, so it looks good at any grid size:
- **Wide** — fits as many columns of cards as the width allows, filling larger widgets instead of stretching a single column.
- **Short** — packs items into more, tighter columns so they stay visible in the limited height.
- **Narrow (1×N)** — tab labels collapse to counts only and posters scale down.
Each item shows its poster art (with the artwork bleeding softly behind the card), a type badge, and — on the queue — an animated progress bar with the estimated time remaining.
@@ -62,35 +61,51 @@ The notebook offers a variaty of functions to edit the text and more.
| Clear formatting | Erases any of the preceding function's effects on the selected text |
#### Heading
Choose a heading size for the line, making the text bigger. Working like titles and sub-titles.
#### Text Alignement
Select the alignement on the line. Left, center or right.
#### Text Alignment
Select the alignment on the line. Left, center or right.
#### Text direction
Set the current paragraph or heading to left-to-right or right-to-left. This is useful when writing right-to-left text while Homarr is using a left-to-right language, or vice versa.
#### Misc functions
##### Blockquote
Puts the current line in a quoting block.
##### Horizontal line
Adds a line that goes from the far right to the far left as a separator.
#### Lists
##### Bullet lists
Simple list with a dot preceding each element.
##### Ordered lists
List with numbers preceding each element.
##### Check lists
List with a checkbox on each element.
##### Indentation changes

When you are currently in a list of any sort, the last 2 buttons will appear and will allow you to change the indentation of the current list item.
#### Insert functions
##### Link controls
The first buttons is to insert a link, or edit an existing link. The link will be applied on the selected text.
Pasting a link directly into the text will automatically put the link anchor on it too.
@@ -102,16 +117,20 @@ The second button will remove the link while leaving the text intact.
This is the modal for the link, The little button in the textbox can be clicked to make the link open in a new tab.
##### Image controls

##### Source
You can use a normal link to an image or you can also use locally stored images.
##### Width
The aspect ratio will always be respected, so only the width field is necessary.
You can provide a value in a plain number or you can also use percents to have your image size dynamically change on different screens.
#### Table
This last control allows you to add a table, for which you can determine the number of columns and row before inserting it.

@@ -119,6 +138,7 @@ This last control allows you to add a table, for which you can determine the num
Clicking that button while in a table will delete it.
##### Table controls

While in a table, further controls will appear to help edit the table.
@@ -134,4 +154,4 @@ All the following ones are for editing the wize of the table:
description: 'Displays host patch statistics from PatchMon including update and security counts.'
hide_title: true
---
import { WidgetHeader } from '@site/src/components/widgets/header';
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetIntegrations } from '@site/src/components/widgets/integrations';
import { patchmonWidget } from '.';
import { patchmonIntegration } from '@site/docs/integrations/patchmon';
<WidgetHeader
widget={patchmonWidget}
categories={['Health Monitoring']}
/>
This widget displays patch statistics from your PatchMon instance, including total hosts, hosts needing updates, and security update counts. Each stat is shown with a dedicated icon and severity-tinted card background for quick visual scanning.
PatchMon does not render a built-in widget title. To add a label on your board, open **Edit item → Advanced options → Title** — the same optional overlay badge used by other Homarr widgets.
### Authentication
:::note
This widget requires a **signed-in Homarr session**. Anonymous visitors cannot load PatchMon data, even when the board is [public](/docs/management/boards#access-control). Placing this widget on a public board does not expose patch or security statistics to guests.
Stat visibility toggles in the widget settings control **display only**. Authenticated users with integration query access still receive the full API payload regardless of which stats are hidden in the UI.
:::
### Compliance hero
When **Show compliance hero** is enabled (default), the widget displays a ring showing the percentage of hosts that are fully up to date. The ring color follows the same threshold rules as the "Up-to-Date Hosts" stat. When the widget is **2 or more columns wide**, the hero also shows the "Patch Compliance" label and exact host count (e.g. "27 / 42") beside the ring.
In **1-column-wide** layouts (such as 1×1), the compliance hero switches to a ring-only view so useful compliance data remains visible in tight spaces. If the compliance hero is disabled, compact widgets fall back to the next selected content: OS distribution when enabled, then the first enabled stat.
### Threshold colors
When **Enable threshold colors** is on (default), stat values and card backgrounds are colored for at-a-glance severity:
| Stat | Green | Yellow | Red |
|------|-------|--------|-----|
| Needs Updates | 0 | 1–4 | 5+ |
| Security Updates | 0 | 1–9 | 10+ |
| Hosts with Security Updates | 0 | — | 1+ |
| Up-to-Date Hosts | 100% of total | 90–99% | below 90% |
| Outdated Packages | 0 | 1–49 | 50+ |
Enable **Advanced thresholds** to override warning and critical values per stat, using either absolute counts or percentages of total hosts.
### OS distribution
When **Show OS distribution** is enabled, the widget shows a breakdown of monitored hosts by operating system. Two display modes are available:
- **Horizontal bars** (default) — a scrollable list of colored progress bars showing each OS's share
- **Donut chart** — a compact donut chart visualization that scales down in narrow layouts
The OS distribution limit defaults to **All**. A widget size of **2×3** or larger is recommended when using this section alongside the default stats, but compact 1×1 and 2×1 layouts can still show multiple OS rows when OS distribution is the primary selected content. Wider donut layouts can also show a compact legend beside the chart.
### Responsive sizing
The widget adapts its layout based on the available space:
| Widget size | Layout |
|-------------|--------|
| **1×1** | Primary selected content: compliance ring (no label), compact OS distribution, or up to two enabled stats |
| **2×1** / **1×2** | Compact primary content; **2×1** shows compliance label and host ratio beside the ring; **1×2** uses ring-only hero |
| **2×2** (default) | Compliance hero, stat grid with icons, and tinted cards |
| **2×3** / **3×2** | Hero, stats, OS distribution, and "last updated" footer |
| **3×3** or larger | Full layout with up to 4-column stat grid, donut chart, and optional OS legend |
### Last updated
When the widget has enough space, a "last updated" timestamp appears at the bottom showing how recently PatchMon refreshed its data. Narrow layouts use a shortened timestamp such as `5m ago`; wider short layouts can show the full translated text.
### Supported Integrations
<WidgetIntegrations items={[{
integration: patchmonIntegration,
note: "Displays host patch and security update statistics"
description:"Displays the number of updates applied in the last 24 hours",
values:{type:"boolean"},
defaultValue:"no",
},
{
name:"Show outdated packages",
description:"Displays the total number of outdated packages across all hosts",
values:{type:"boolean"},
defaultValue:"no",
},
{
name:"Show repositories",
description:"Displays the number of active repositories being monitored",
values:{type:"boolean"},
defaultValue:"no",
},
{
name:"Enable threshold colors",
description:"Colors stat values green, yellow, or red based on built-in severity presets",
values:{type:"boolean"},
defaultValue:"yes",
},
{
name:"Advanced thresholds",
description:"Customize warning and critical thresholds per stat (absolute count or % of total hosts)",
values:{type:"boolean"},
defaultValue:"no",
},
{
name:"Needs updates threshold mode",
description:
"Compare the needs-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values:{type:"select",options:["Absolute count","Percent of total hosts"]},
defaultValue:"Absolute count",
},
{
name:"Needs updates warning threshold",
description:"Enter yellow when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"1",
},
{
name:"Needs updates critical threshold",
description:"Enter red when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"5",
},
{
name:"Security updates threshold mode",
description:
"Compare the security-updates count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values:{type:"select",options:["Absolute count","Percent of total hosts"]},
defaultValue:"Absolute count",
},
{
name:"Security updates warning threshold",
description:"Enter yellow when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"1",
},
{
name:"Security updates critical threshold",
description:"Enter red when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"10",
},
{
name:"Hosts with security updates threshold mode",
description:
"Compare the host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values:{type:"select",options:["Absolute count","Percent of total hosts"]},
defaultValue:"Absolute count",
},
{
name:"Hosts with security updates warning threshold",
description:"Enter yellow when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"1",
},
{
name:"Hosts with security updates critical threshold",
description:"Enter red when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"1",
},
{
name:"Up-to-date hosts threshold mode",
description:
"Compare the up-to-date host count as an absolute value or percentage of total hosts. Shown when threshold colors and advanced thresholds are enabled.",
values:{type:"select",options:["Absolute count","Percent of total hosts"]},
defaultValue:"Percent of total hosts",
},
{
name:"Up-to-date hosts warning threshold",
description:"Enter yellow when the value is at or above this threshold but below full coverage",
values:"Integer ≥ 0",
defaultValue:"90",
},
{
name:"Up-to-date hosts critical threshold",
description:"Enter red when the value falls below this threshold",
values:"Integer ≥ 0",
defaultValue:"90",
},
{
name:"Outdated packages threshold mode",
description:
"Compare the outdated-packages count as an absolute value or percentage of total hosts. Shown when threshold colors, advanced thresholds, and Show outdated packages are enabled.",
values:{type:"select",options:["Absolute count","Percent of total hosts"]},
defaultValue:"Absolute count",
},
{
name:"Outdated packages warning threshold",
description:"Enter yellow when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"1",
},
{
name:"Outdated packages critical threshold",
description:"Enter red when the value reaches this threshold",
values:"Integer ≥ 0",
defaultValue:"50",
},
{
name:"Show OS distribution",
description:"Displays a horizontal bar breakdown of monitored hosts by operating system",
values:{type:"boolean"},
defaultValue:"no",
},
{
name:"OS display mode",
description:
"Choose how to display the operating system distribution. Shown when Show OS distribution is enabled.",
Loaded 100 of 784 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.