Commit Graph
840 Commits
Author SHA1 Message Date
Thomas Camlong ec79a15023 perf(widgets): stop building hidden tab panels, and drop a third-party image fallback
Mantine's Tabs keeps inactive panels mounted by default. On Mantine 9 with
React 19 that uses <Activity>, which preserves state and unmounts effects but
still builds the entire subtree - so a two-tab widget pays for both while
showing one.

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

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

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

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

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

Adds scripts/benchmarks/widget-inventory.mjs and docs/perf/WIDGET-AUDIT.md, a
verdict for all 50 widgets.
2026-08-11 16:30:22 +02:00
Thomas Camlong afa68687eb perf(widget-inputs): stabilise the drag handle component type
Last of the two nested-component findings from the widget audit. `Handle` was declared in the
render body and passed to a child as `handle={Handle}` - a component *type*. A type that
changes identity makes React unmount and remount the subtree instead of updating it, and this
component re-renders on every frame of a drag as dnd-kit updates `transform`, so the handle was
being torn down and rebuilt continuously while being dragged.

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

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

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

Two corrections to earlier claims in this branch:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Not yet measured: the listener reduction depends on how many releases carry a description,
which is integration data. The mechanism is certain (the components are simply not created)
but the size of the win on a given board is not, and calendar's 42 Popovers and downloads'
30 Tooltips are untouched. Tracked in docs/perf/NEXT-STEPS.md.
2026-08-11 09:42:59 +02:00
Thomas Camlong 040f105946 perf(widgets): share the off-screen row primitive, and apply it to the data-driven lists
Extracts the media-releases fix into packages/widgets/src/common/offscreen-rows.ts and
applies it to the remaining widgets whose list length is decided by remote data rather
than by the tile: rssFeed, notifications and the calendar event list. Images in those
lists also get loading="lazy" - the rss cards carry two each, a backdrop and a poster.

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

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

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

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

A duplicate `style` prop on the notifications Card was caught by typecheck and merged
rather than left to override the existing colour and text-decoration styles.
2026-08-11 09:37:48 +02:00
Thomas Camlong c526dd2b77 perf(media-requests): apply the off-screen fix to the request list — NOT YET MEASURED
Second instance of the pattern fixed in mediaReleases. The overdraw tool, corrected to
find row containers by child count rather than by uniform height, found only one other
widget genuinely rendering more than its tile can show:

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

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

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

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

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

Every other widget on the board fits its tile and needs nothing. The tool's "visible"
column misreads chart widgets (beszelSystemStats reports 28 rows / 0 visible because its
container is an SVG whose groups have no layout height) - content height versus tile height
is the trustworthy signal, and by that measure only these two overdraw.
2026-08-10 19:11:38 +02:00
Thomas Camlong 7550766f67 perf(media-releases): stop rendering 73 off-screen cards' images and layout
Per-widget attribution of the dashboard found one widget responsible for 44% of the
board's DOM: mediaReleases at 2,951 nodes and 8,387 fibers, including 1,935 Mantine
Box components, against a board total of 9,293 nodes.

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

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

Measured over three repeats per side on identical restored data:

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

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

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

Adds the tools this needed: measure-widget-cost.mts (per-widget DOM nodes, fibers and
heaviest components), measure-board-paint.mts (image requests, bytes, style/layout/task
time, layout objects), measure-widget-overdraw.mjs (rendered versus visible rows).
2026-08-10 19:05:17 +02:00
Thomas Camlong 6692acbd33 chore: strip formatting noise, delete dead routes, fix CodeRabbit issues
- Remove 179 format-only file changes (docs, migrations, plex, patchmon, CSS)
- Delete .cursor/goal files from git tracking
- Delete 4 dead /api/about routes (unused, data loaded from static JSON)
- Fix resolveAlias to use absolute paths (CodeRabbit)
- Fix Tailwind classes in Mantine app layout fallback (CodeRabbit)
- Return stale data during cache revalidation (CodeRabbit)
2026-08-08 08:45:16 +02:00
Thomas Camlong 39334569d6 fix: remove notifications from refetch-intervals to match widget definitions
The notifications widget has no refetchInterval in its definition,
so the static polling policy entry was causing a manifest test failure.
2026-08-07 21:42:08 +02:00
Thomas Camlong ffed73ba8f merge: incorporate latest dev changes into phase1-perf branch
Merge origin/dev to pick up service isolation (concurrently dev script,
turbopack aliases, instrumentation simplification), PostHog analytics,
color scheme, and other dev improvements while preserving all Phase 1
performance work (Suspense boundaries, cache components, Redis handler,
integration barrel diet, cache invalidation).
2026-08-07 21:36:48 +02:00
Thomas Camlong 38ac8f3075 fix: concurrent cache miss, SVG output, narrowed error handling, format
- Fix integration data cache: check pending promise before TTL to
  prevent returning undefined on concurrent cold-cache access
- Add concurrent-miss test (two parallel callers, one fetch)
- SVG sanitizer: extract <svg> from DOMPurify output to strip
  residual HTML wrapper
- Narrow safeRevalidateTag catch: log unexpected errors, silence only
  known "outside request scope" messages
- Run oxfmt across all packages (206 files formatted)
2026-08-07 20:48:17 +02:00
Thomas b32ad82850 perf: cut cold route compilation and unblock preview images (#6441) 2026-08-07 20:24:26 +02:00
Thomas Camlong 018f98e318 perf: implement Phase 1 homepage diet via Next.js Cache Components
P1-0: V8 heap cap (HOMARR_MAX_OLD_SPACE_SIZE, default 512MB)
P1-1: Module graph cuts
  - Dynamic integration imports (55 static → lazy loaders)
  - Conditional DB driver loading (only configured driver loaded)
  - Replace jsdom with linkedom for SVG sanitization
  - Delete eager widget barrel (55 import * as → 0)
  - Extract createRetryableLoader to @homarr/common
P1-2: Enable cacheComponents + restructure root layout
  - Suspense boundaries around session-dependent providers
  - ColorSchemeScript replaces server-side cookie reads
  - Board/manage/auth layouts wrapped in Suspense
P1-3: Cache read paths
  - Cache tag registry + invalidation helpers
  - Cache invalidation wired into all mutation routers
  - Translation deep-merge memoized per locale
P1-4: Prerendered shells + loading.tsx files
P1-5: Shared integration data cache (60s TTL, stale-while-revalidate)
P1-6: Redis-backed Next.js cache handler (@homarr/next-cache-handler)
P1-7: Redis optional (memory channel fallback), TTL on pubSub:last:*,
      Redis clients consolidated from 6 to 2
2026-08-07 18:59:47 +02:00
Thomas 76ddfceb13 fix: restore auto-refresh for docker and notifications widgets (#6511) 2026-08-03 17:07:46 +02:00
Thomas Camlong 08af704c8a Merge origin/dev into feat/build-performance 2026-08-03 15:21:23 +02:00
Thomas Camlong de801f3f00 perf: optimize dashboard loading and builds 2026-08-02 15:58:16 +02:00
Bergasha 49314533bf feat(Media player widget): expands on Direct Play and Transcoding badges (#6489) 2026-08-01 23:21:13 +02:00
Thomas 27ca058088 feat(widgets): persist Beszel and Docker table layouts (#6490) 2026-08-01 23:20:09 +02:00
Thomas 61128ddcc8 fix(navidrome): clean up audio stats integration (#6493) 2026-07-31 14:20:51 +02:00
ajnart 81a13d3b52 Merge remote-tracking branch 'origin/dev' into feat/build-performance 2026-07-31 13:38:16 +02:00
Matt Van Horn 06d9c07d22 feat: expose Navidrome as a media-server (now-playing) integration (#6070) 2026-07-31 13:30:44 +02:00
ajnart 6b74078766 perf(widgets): lazy load the application graph 2026-07-29 06:47:07 +02:00
Thomas 906c9f11fa feat(widgets): rework downloads widget with mantine-datatable (#6430) 2026-07-28 21:50:09 +02:00
Thomas 3419805a49 fix(beszel): restore balanced grid layout for Beszel System Grid widget (#6389) 2026-07-28 21:49:04 +02:00
Thomas 14c3fa2e76 fix(beszel): prevent live subscription memory growth (#6448)
* fix(beszel): bound live stats subscriptions

* fix(beszel): avoid blocking subscription teardown

* fix(import): map new clock options
2026-07-23 02:03:08 +02:00
Thomas 9cabb42edb fix(widgets): add 350ms hover delay before opening calendar day popover (#6431) 2026-07-23 01:54:23 +02:00
Thomas bfba3c9215 fix(releases): avoid unnecessary GitHub pagination for LinuxServer updates (#6444) 2026-07-23 00:20:54 +02:00
Bergasha 7184bc09c1 feat(clock widget): Adds animated weather and temp (#6372) 2026-07-23 00:04:32 +02:00
Thomas 8e7fa782b3 feat(weather): add humidity display (#6429) 2026-07-21 16:36:03 +02:00
Thomas 97652802f0 feat(immich): show random photos without album (#6424) 2026-07-21 16:10:45 +02:00
Thomas 1706289124 fix(media-server): guard against undefined playback from stale cached data (#6433) 2026-07-21 16:01:37 +02:00
Bergasha 5144e14da4 fix(media-server): populate playback progress, transcode status, and network location for streams (#6315) 2026-07-15 16:27:58 +02:00
Thomas 8528d5068b feat(media-requests): filter requests list by status and time window (#6320) 2026-07-15 16:27:24 +02:00
Thomas 7e4cb0201b fix(widgets): add refetchInterval to WidgetDefinition for per-widget polling (#6352) 2026-07-15 11:52:35 +02:00
Bergasha 807e5eb2a9 feat(docker-widget): replace footer timestamp text with compact refresh button (#6307) 2026-07-12 14:51:56 +02:00
Thomas 8e8c29979f fix(system-disks): format absolute storage values with shared byte helper (#6299) 2026-07-12 14:50:58 +02:00
Artur d44bc06a93 feat(notebook): support text direction controls (#6293) 2026-07-12 07:58:02 +02:00
Thomas 576856d634 fix(beszel): add reliable live stats and filesystem UI (#6289) 2026-07-11 17:55:04 +02:00
Thomas 13c9e5e181 fix(release): repair v1.70.0 quality failures (#6279)
* fix(release): repair v1.70.0 quality failures

* style: auto-fix lint and format
2026-07-10 21:59:57 +02:00
Josh PrestonandThomas 33395ae226 feat(integrations): add PatchMon integration and widget (#6119)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:48:43 +02:00
Anil K TiwariandThomas 9fb2e37605 feat(widgets): add missing & queued media widget for Radarr and Sonarr (#6078)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:31:08 +02:00
Felix-AyushandThomas 2c5368dd4e feat: add Bazarr integration and subtitle status widget (#6156)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 17:29:10 +02:00
Thomas 718f095cb7 fix: prevent stale persisted query cache from blocking widget data (#6273) 2026-07-10 17:19:24 +02:00
Thomas 24c5d04bfa fix(beszel): disable request handler cache for stats to make live mode work (#6244) 2026-07-10 17:16:23 +02:00
Thomas 0e513fffd5 feat(widgets): add per-filesystem storage chart to Beszel system stats (#6209) 2026-07-10 17:08:22 +02:00
Nerdy TechnicianandThomas 9864fed775 feat: Add Traefik Integration with dedicated widget and Add optional Icons for Gotify/Notifcation Widget (ONLY GOTIFY) (#6206)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-07-10 16:49:24 +02:00
Josh Preston 65ba866239 feat: add Synology integration support for health/system monitoring (#6121) 2026-07-09 18:23:21 +02:00
Thomas 885be1b9a1 fix(widget-secrets): add missing widget_secret migration and add error notifications (#6192)
* 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).
2026-07-04 17:57:40 +02:00