Compare commits

..
Author SHA1 Message Date
zacharyandClaude Opus 5 c2e1a49503 chore(ops): write the deploy plan before truncating it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:48:48 +02:00
zacharyandClaude Opus 5 d77f44eb6c chore(ops): itemize the dry-run so it actually shows the plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:48:25 +02:00
zacharyandClaude Opus 5 71442d362f chore(ops): fail early on a stale Node and compare migrations per dialect
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:46:38 +02:00
zacharyandClaude Opus 5 17a2e577c0 chore(ops): add LXC deploy script and fork maintenance notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:45:53 +02:00
zacharyandClaude Opus 5 7257045b8d refactor(widgets): let the UniFi OS chart fill the tile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:26:50 +02:00
zacharyandClaude Opus 5 e1fe0f8709 fix(integrations): end the health window on the last measured hour
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 16:21:46 +02:00
zacharyandClaude Opus 5 9a4d3a4dea feat(widgets): show WAN uptime in the UniFi site table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:52:05 +02:00
zacharyandClaude Opus 5 d5d7a8ae8d feat(integrations): fill the recent hours from the 5-minute ISP metrics
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 15:52:05 +02:00
zacharyandClaude Opus 5 852160e5f3 fix(widgets): make the UniFi site table sortable and fill the tile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:45:44 +02:00
zachary 122e6e03f9 Revert "fix(integrations): label sites with the console name Site Manager shows"
This reverts commit 12db2c0677.
2026-09-13 04:34:13 +02:00
zacharyandClaude Opus 5 12db2c0677 fix(integrations): label sites with the console name Site Manager shows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:29:45 +02:00
zacharyandClaude Opus 5 31994c7e1c docs(integrations): say what a null SD-WAN status does not tell us
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:22:01 +02:00
zacharyandClaude Opus 5 5adb8e93eb fix(integrations): treat an unstated console update state as unknown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 04:18:51 +02:00
zacharyandClaude Opus 5 87fcc91098 fix(widgets): hide the decorative health bar from assistive technology
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:06:24 +02:00
zacharyandClaude Opus 5 1f3ff4749a fix(widgets): report every health state in the UniFi site summary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 16:05:00 +02:00
zacharyandClaude Opus 5 29eb3bdb70 fix(widgets): omit packet loss when the cloud did not measure it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:57:55 +02:00
zacharyandClaude Opus 5 ace02b4311 feat(widgets): add UniFi Site Manager overview widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:55:48 +02:00
zacharyandClaude Opus 5 ff9c031915 fix(widgets): remove out-of-scope UniFi Site Manager widget stub
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:46:45 +02:00
zacharyandClaude Opus 5 f7bdd20f30 feat(api): expose unifiSiteManager overview query
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 15:44:11 +02:00
zacharyandClaude Opus 5 ac51887601 fix(integrations): type the Site Manager transport boundary and guard it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:48:30 +02:00
zacharyandClaude Opus 5 a79a1ae17d docs: plan — annotate test fixture arrays and require typecheck per task
Le fixture déclarait warnings: [], inféré never[], alors que le test lui
assigne un tableau de chaînes. tsc refuse ; vitest ne le voit pas.

Ajouté à la vérification de chaque tâche : pnpm turbo typecheck, pas
seulement pnpm test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:40:02 +02:00
zacharyandClaude Opus 5 436b057cfe feat(integrations): add UnifiSiteManagerIntegration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:39:02 +02:00
zacharyandClaude Opus 5 58f7d7d71f docs: plan — the SD-WAN dash means unknown, not 'no configuration'
Constat de revue : un échec total des appels de statut produit le même
tableau vide qu'un compte sans configuration. Le rendu reste honnête —
le tiret n'affirme rien — mais le commentaire, lui, tranchait à tort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:33:41 +02:00
zacharyandClaude Opus 5 df5e5fe6f5 feat(integrations): add UniFi Site Manager cloud client
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:30:01 +02:00
zacharyandClaude Opus 5 52b8385adb feat(integrations): add UniFi Site Manager schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:24:50 +02:00
zacharyandClaude Opus 5 09869aa69b feat(definitions): declare unifiSiteManager integration and widget kinds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:21:01 +02:00
zacharyandClaude Opus 5 97d1462f8c docs: plan — ESM-safe guard test and the .spec.ts inclusion constraint
Scan pré-vol. Le test de garde du bundle client lisait son source via
__dirname, qui n'existe pas en ESM et n'est utilisé par aucun autre spec
du dépôt. Remplacé par import.meta.url, avec la directive d'environnement
node puisqu'il lit le disque et que le défaut est jsdom.

Ajouté aux contraintes globales : vitest n'inclut que **/*.spec.ts, donc
un fichier .spec.tsx ne serait jamais exécuté et son absence passerait
pour un succès.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 10:19:20 +02:00
zacharyandClaude Opus 5 27b5a70bc1 docs: plan — add per-site SD-WAN tunnel status
L'état des tunnels entre dans quatre des sept tâches : schémas et index
par site (tâche 2), deux méthodes de client bornées (tâche 3),
orchestration en enrichissement non fatal (tâche 4), colonne et
traductions (tâche 6), et une étape de vérification dédiée (tâche 7).

Deux points de justesse portés par des tests :
- `ping: 0` devient `pingMs: null`. Relevé sur un tunnel connecté entre
  deux sites distants, où une latence nulle est physiquement
  impossible : le champ veut dire « non mesuré ».
- le nombre de configurations interrogées est borné à cinq. Le statut
  étant indexé par configuration, un compte à dix produirait onze
  requêtes par rafraîchissement.

La topologie reste hors périmètre : une liste de quinze réseaux n'a pas
sa place dans une ligne de tableau.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:17:22 +02:00
zacharyandClaude Opus 5 f521e92463 docs: spec — add per-site SD-WAN tunnel status
Ajout demandé après validation : l'état des tunnels, indexé par siteId,
se greffe sur les lignes existantes. La topologie reste hors v1 — une
liste de quinze réseaux n'a pas sa place dans une ligne de tableau.

Relevé au passage : ping vaut 0 sur un tunnel connecté entre deux sites
distants, où une latence nulle est physiquement impossible. Le champ
signifie « non mesuré » et le contrat le traduit en null, plutôt que de
propager un zéro que l'interface afficherait comme une performance.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 09:14:06 +02:00
zacharyandClaude Opus 5 52c0e066e1 docs: spec — health states are a status palette, with a mandatory text summary
Le validateur de palette ne couvre que les palettes catégorielles. Sain,
dégradé et coupé sont des statuts : palette réservée et fixe, assortie de
la règle qu'ils ne voyagent jamais sans icône ni libellé.

Une icône par segment étant impossible sur vingt-quatre segments, la
bande est doublée d'un résumé textuel. Mesuré : un vert/orange/rouge
classique donne un ΔE de 2,9 entre orange et vert en vision protanope.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:11:31 +02:00
zacharyandClaude Opus 5 2a631424aa docs: spec — back to a table, health bar as a column
L'utilisateur revient sur la forme liste et retient la proposition
initiale : tableau HomarrDataTable avec la bande de santé en colonne.

La bande reste dans la v1 (D4 inchangée) — c'est la forme qui change,
pas le périmètre. Le tableau rend le tri et la visibilité responsive,
absorbe la croissance du nombre de sites, et correspond à ce que fait
l'interface Ubiquiti.

Contrepartie consignée : dans une colonne la bande est étroite, ce qui
fait de l'infobulle par segment le moyen principal de lire une tranche
plutôt qu'un confort.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:06:18 +02:00
zacharyandClaude Opus 5 961e104687 docs: spec — merge ISP metrics into the site health list
Proposition de l'utilisateur, retenue : fusionner les métriques ISP avec
la barre « Internet Health » pour une liste d'état rapide des sites.

Elle inverse la décision D4. La barre avait été écartée au motif que
`internetIssues` est un tableau creux dont il faudrait reconstituer les
périodes saines — vrai de cette source, mais `isp-metrics` fournit la
même information déjà dense, une entrée par site, vérifié. La barre
coûte alors une boucle de coloration, pas un moteur de graphe.

D3 change aussi : liste d'état plutôt que tableau, la bande au centre
plutôt qu'en colonne. HomarrDataTable n'a donc plus lieu d'être, et le
tri disparaît. Limite assumée et consignée : au-delà de cinq ou six
sites la forme tableau redeviendrait préférable.

Point de justesse ajouté au contrat : une tranche horaire sans mesure
vaut "unknown" et jamais "healthy". Un site enregistré depuis douze
heures n'a que douze tranches sur vingt-quatre ; les peindre en vert
affirmerait un bon fonctionnement jamais mesuré.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 23:02:41 +02:00
zacharyandClaude Opus 5 b91a45f4d6 docs: design for UniFi Site Manager integration
Nouvelle intégration cloud-only, distincte de unifiOs, remontant une vue
multi-sites. Premier des trois projets identifiés à partir des captures
du Site Manager ; il construit aussi le socle des deux suivants.

Constat notable : il n'existe pas d'API cachée. /ea/* est un alias
complet de /v1/*, et une trentaine d'autres chemins plausibles
renvoient 404. La surface documentée est toute la surface.

La séparation types / parse est imposée dès la conception par le défaut
rencontré sur l'intégration précédente, où un import serveur dans un
module réexporté côté client empêchait l'application de démarrer.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 22:49:16 +02:00
zacharyandClaude Opus 5 dc9fb12938 feat(widgets): 5s refresh, download first, chart from the first reading
Trois demandes utilisateur, plus une adaptation rendue nécessaire par la
première.

- Ordre inversé : descendant avant montant.
- Rafraîchissement porté de 60 s à 5 s. Il fallait le faire sur les DEUX
  couches locales : l'intervalle client seul aurait relu la même entrée
  de cache serveur douze fois. Le cache cloud reste à 5 min, ses
  métriques n'évoluant que par tranches de 5 min.
- Le graphe est rendu dès la première mesure : vide plutôt qu'absent,
  ce qui évite un saut de mise en page et n'affirme rien de faux.

Adaptation : la console ne rafraîchit ses statistiques que toutes les
30 s (mesuré — `lastHeartbeatAt` avance par pas de 30 s exacts, valeurs
identiques entre deux battements). Interroger toutes les 5 s renvoie
donc six fois la même mesure. Sans filtrage, le graphe deviendrait un
escalier et 60 points ne couvriraient plus que 5 minutes. Une lecture
identique à la précédente n'est donc pas enregistrée.

Seuil de péremption locale recalibré de 150 s à 30 s : l'ancien était
dimensionné pour 60 s + 60 s et ne signalait plus rien d'utile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:35:44 +02:00
zacharyandClaude Opus 5 2bd080293a feat(widgets): chart WAN throughput instead of static figures
L'API UniFi ne fournit que l'instantané et Homarr ne stocke aucun
historique : la série est accumulée côté client, comme le fait déjà le
widget systemResources. Elle repart donc vide à chaque rechargement.

Deux décisions d'honnêteté, dans la ligne du reste du widget :
- une lecture en échec est ignorée, jamais enregistrée comme un zéro,
  qui dessinerait une chute à la ligne de base n'ayant pas eu lieu ;
- sous deux points, on affiche les chiffres seuls : une ligne plate
  tracée sur un point unique se lit comme un réseau au repos alors
  qu'on n'a rien mesuré.

Couleurs validées par le validateur de palette (bande de luminosité,
chroma, séparation daltonisme et vision normale, contraste) en thème
clair ET sombre. La paire orange.5/yellow.5 du widget systemResources
n'a délibérément pas été reprise : son ΔE en vision normale est de
12,1, sous le plancher de lisibilité de 15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 21:24:51 +02:00
zacharyandClaude Opus 5 f1e4f43fbf fix(nextjs): register the unifiOs category translation key
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 20:08:17 +02:00
zacharyandClaude Opus 5 96f6d28de0 fix(integrations): keep unifi-os-types client-safe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 19:51:27 +02:00
zacharyandClaude Opus 5 7f07bf44b6 fix: address final review — cloud TLS, MAC guard, honest ISP metrics
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:56:15 +02:00
zacharyandClaude Opus 5 45ca2bd768 docs: spec — make ISP metrics nullable, they are absent during a WAN outage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:47:49 +02:00
zacharyandClaude Opus 5 3bf3407b82 fix(widgets): stop asserting a gateway that does not exist, label the ISP gap
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:35:43 +02:00
zacharyandClaude Opus 5 fdefa65b54 feat(widgets): add UniFi OS widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 02:29:41 +02:00
zacharyandClaude Opus 5 997c223ede fix(widgets): remove out-of-scope UniFi OS widget stub from task 6
The widget stub (packages/widgets/src/unifi-os/) was incorrectly added in
Task 6 and belongs to Task 7. Its removal confirms the type system does not
require it, and prevents inconsistencies: the stub declares IconWifi while
Task 7 specifies IconRouter, and it is not registered in manifest.ts,
catalog.ts or refetch-intervals.ts.

- Delete packages/widgets/src/unifi-os/ directory entirely
- Revert packages/widgets/src/index.tsx to commit 57286d83d

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:30:30 +02:00
zacharyandClaude Opus 5 eecd37bf56 feat(api): expose unifiOs widget summary query through tRPC router and request handler
Create unifiOs request handler with 60s cache and stale-on-error fallback, and tRPC router exposing clientApi.widget.unifiOs.summary query. Include minimal widget stub for type system.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:27:35 +02:00
zacharyandClaude Opus 5 57286d83d2 fix(integrations): make client/device counts nullable and test real degradation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:21:32 +02:00
zachary 1026890e33 docs: spec — drop unused VPN servers call, make client/device counts nullable 2026-09-08 15:18:32 +02:00
zacharyandClaude Opus 5 b486196f2d feat(integrations): add UnifiOsIntegration with optional cloud enrichment
Introduces UnifiOsIntegration, orchestrating UnifiOsApi (local) and
UnifiSmApi (optional cloud) to build the UnifiOsSummary. Local endpoint
calls degrade independently via Promise.allSettled; the cloud client is
never instantiated without a configured siteManagerApiKey secret.
Registers the unifiOs kind in the integration creator, index, and types
barrels, resolving the previously-failing typecheck.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:12:58 +02:00
zacharyandClaude Opus 5 cc9fbd4264 fix(integrations): select newest ISP metric period explicitly
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:08:09 +02:00
zacharyandClaude Opus 5 46dd7c7887 fix(integrations): type unifi-os test fetch mocks for tsc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 15:03:47 +02:00
zacharyandClaude Opus 5 822e095b13 feat(integrations): add optional UniFi Site Manager client with 5min cache
Task 4 of the UniFi OS integration: an optional cloud client for
Ubiquiti's Site Manager API that enriches the local console with ISP
latency data. Instantiated only when a cloud API key is configured
(Task 5). Every failure path (bad key, non-ok status, parse error,
unmatched gateway MAC) returns null rather than throwing, since the
cloud is a pure enrichment. Metrics are cached 5 minutes, keyed by
gateway MAC, to respect the ~27 min publication lag and avoid rate
limiting; the query window is 3 hours because a 15-minute window
returns an empty array on the real API.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:53:46 +02:00
zacharyandClaude Opus 5 9c00cd14ff feat(integrations): add UniFi OS local API client
Implements HTTP client for UniFi OS console integration API with support for
fetching site information, client counts, device statistics, and VPN server
configs. Includes pagination handling with filter-based fallback when client
count exceeds API limit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:48:23 +02:00
zacharyandClaude Opus 5 01e7d150d1 fix(integrations): make unifi-os test invocation-independent with vi.hoisted()
Guarded environment validation with SKIP_ENV_VALIDATION and SECRET_ENCRYPTION_KEY
set in vi.hoisted() so that tests pass both with 'pnpm test' (NODE_ENV=development)
and bare 'pnpm vitest run' (no env override).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:44:37 +02:00
zachary 8ea55eb474 docs: fix test command in plan (pnpm test, not pnpm vitest run) 2026-09-08 14:43:24 +02:00
zacharyandClaude Opus 5 9218f208d7 feat(integrations): add UniFi OS schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:41:51 +02:00
zacharyandClaude Opus 5 c861ae6994 feat(definitions): declare unifiOs integration kind, category and widget kind
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 14:35:05 +02:00
zacharyandClaude Opus 5 de0c0bf740 docs: implementation plan for UniFi OS integration
Plan en 9 tâches avec cycle TDD par étape, du vocabulaire dans
@homarr/definitions jusqu'au déploiement sur le LXC.

Corrige aussi la spec sur deux points relevés en écrivant le plan :
- catalog.ts (widgetCatalogIcons) est un cinquième point d'enregistrement,
  vérifié par manifest.spec.ts et absent du §5.2
- le §9 décrivait un test d'agrégation multi-site devenu sans objet depuis
  la décision de ne pas agréger

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:38:06 +02:00
zacharyandClaude Opus 5 656603b829 docs: design for UniFi OS integration
Conception validée pour une intégration UniFi OS en complément de
unifiController, basée sur l'API d'intégration officielle (clé X-API-KEY)
plutôt que sur l'ancienne API du Network Controller.

Toutes les capacités et limites de l'API ont été mesurées sur une console
réelle en 10.6.102, pas déduites de la documentation publiée (qui s'arrête
à 10.4.57 et est déjà en retard sur les consoles).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 13:00:37 +02:00
homarr-releases[bot] 2b1a03849d chore: update bug report template (#6798) 2026-09-06 22:31:26 +02:00
Releases Homarr 4c243bc374 chore(release): 1.77.0 [skip ci] 2026-09-06 19:31:37 +00:00
homarr-releases[bot] ecd02f73c0 chore(release): automatic release v1.77.0 2026-09-06 19:30:29 +00:00
Thomas cd23b36f3c fix(ci): use pinned pnpm for releases (#6796) 2026-09-06 21:18:30 +02:00
homarr-releases[bot] f7f29eade9 chore(release): automatic release v1.77.0 2026-09-06 18:47:01 +00:00
Thomas 32a51c1e60 feat: announce Homarr v2 beta (#6793)
* feat: announce Homarr v2 beta

* chore: refine v2 beta announcement

* feat: expand v2 beta guidance

* chore: refine v2 beta resources

* chore: simplify v2 beta modal

* chore: improve v2 beta feedback links

* fix: keep v2 modal above header

* chore: sharpen v2 beta message

* fix: clear header for v2 modal
2026-09-06 20:36:04 +02:00
homarr-crowdin[bot] da32acb4f0 chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-06 01:52:04 +00:00
homarr-update-contributors[bot] 1da13d9bb3 chore: update contributors (#6767)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-09-04 15:58:09 +00:00
homarr-crowdin[bot] cb0fec088f chore(lang): updated translations from crowdin
* chore(lang): update translations from crowdin

* chore(lang): update translations from crowdin

---------

Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-03 02:05:21 +00:00
homarr-crowdin[bot] a2fe8dd461 chore(lang): updated translations from crowdin
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-09-01 02:36:36 +00:00
homarr-update-contributors[bot] b6dfcd69ba chore: update contributors (#6735)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-28 21:54:00 +00:00
homarr-crowdin[bot]andThomas a38b0ef71d chore(lang): updated translations from crowdin (#6732)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-28 14:07:42 +02:00
Thomas 2cf3027eeb Merge pull request #6720 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-28 08:32:40 +02:00
homarr-releases[bot] fa18f6fc71 chore: update bug report template (#6724) 2026-08-28 08:32:03 +02:00
Releases Homarr 331925c197 chore(release): 1.76.2 [skip ci] 2026-08-27 20:09:22 +00:00
Thomas 0e51f745d3 Merge pull request #6723 from homarr-labs/dev
chore(release): automatic release v1.76.2
2026-08-27 22:08:05 +02:00
Thomas d6f720d0e6 Revert "feat(http): set custom User-Agent on outbound integration requests (#6606)" (#6722)
This reverts commit ddbf05524b.
2026-08-27 22:00:23 +02:00
Thomas 5324bf12d4 fix(startup): use stable internal service addresses (#6721) 2026-08-27 21:52:54 +02:00
github-actions[bot] 2922a02ecf docs(helm): update chart installation page 2026-08-27 17:09:27 +00:00
Thomas 87cdcf28e0 fix(mcp): return valid string for void tool results (#6703) 2026-08-27 11:10:37 +02:00
homarr-releases[bot] 441b181e79 chore: update bug report template (#6716)
Co-authored-by: Releases Homarr <175486441+homarr-releases[bot]@users.noreply.github.com>
2026-08-27 11:08:43 +02:00
Releases Homarr eba639b59c chore(release): 1.76.1 [skip ci] 2026-08-26 16:43:52 +00:00
Thomas Camlong da36283ad1 chore(release): retrigger release pipeline 2026-08-26 18:30:12 +02:00
Thomas Camlong c050368782 chore(release): trigger release pipeline 2026-08-26 18:16:26 +02:00
homarr-releases[bot] 82b50f0fd7 chore(release): automatic release v1.76.1 2026-08-26 15:50:55 +00:00
Thomas a0149d5894 Merge pull request #6673 from homarr-labs/investigate-6672-immich-401 2026-08-22 19:44:03 +02:00
ajnart 8579fcbfc2 test(http,immich): address CodeRabbit review
- guarantee server teardown via try/finally in both wire-level tests
- add tuple-array HeadersInit coverage to the helper suite
2026-08-22 17:21:38 +02:00
ajnart b6f3aba23e test(http,immich): prove User-Agent fix at the wire level
Add two regression tests that fail on v1.76.0 and pass with the fix:
- wire-level test: a Headers instance (the shape @immich/sdk hands over)
  must reach the remote server with all headers intact
- real-SDK e2e: getServerStatistics through fetchWithTrustedCertificatesAsync
  against a live local server must deliver x-api-key
2026-08-22 17:16:31 +02:00
ajnart 31e218295d fix(http): preserve SDK headers when injecting User-Agent
The v1.76.0 User-Agent injection object-spread `fetchOptions.headers`, which
silently drops all headers when the value is a Headers instance. The Immich
SDK passes a Headers object with the merged x-api-key header, so requests
lost their API key and returned 401 (issue #6672). Build a real Headers
instance instead, keeping case-insensitive user-agent handling.
2026-08-22 17:12:43 +02:00
Thomas aca3b5d861 fix: correct Unraid CPU, link context menus, and board resizing (#6510)
Fix Unraid logical CPU averaging, preserve native context menus for app and bookmark widgets in view mode, and prevent scrollbar-width GridStack resize oscillation.
2026-08-22 10:53:26 +02:00
homarr-crowdin[bot] a7a2f0223a chore(lang): updated translations from crowdin (#6596)
Co-authored-by: Crowdin Homarr <190541745+homarr-crowdin[bot]@users.noreply.github.com>
2026-08-22 08:49:51 +02:00
homarr-releases[bot] 7773fdc10b chore: update bug report template (#6655) 2026-08-21 22:14:45 +02:00
Thomas 7019a1d0a4 Merge pull request #6656 from homarr-labs/docs/update-helm-docs 2026-08-21 22:14:26 +02:00
github-actions[bot] 2d535caeac docs(helm): update chart installation page 2026-08-21 17:41:09 +00:00
Releases Homarr 2013fbe80d chore(release): 1.76.0 [skip ci] 2026-08-21 17:20:50 +00:00
homarr-releases[bot] 219ef58926 chore(release): automatic release v1.76.0 2026-08-21 17:19:40 +00:00
homarr-update-contributors[bot] 97195bc463 chore: update contributors (#6649)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-21 12:53:40 +00:00
Danura30082 73a742154e docs: Proxmox certificate obtention instructions (#6613) 2026-08-20 18:51:58 +02:00
Afonso Jorge Ramos 993a267e0c fix(truenas): keep reporting pools without usable dataset space (#6635) 2026-08-20 15:37:10 +02:00
Thomas f1c49d1fbf fix(startup): only chown runtime paths for custom PUID/PGID (#6598) 2026-08-19 08:29:17 +02:00
Thomas 95c66c61eb fix(widgets): stop Health Monitoring system tab firing NOT_FOUND when Proxmox is mixed in (#6623) 2026-08-18 19:56:47 +02:00
Thomas ec2f8c6c2c fix(widgets): match OMV disk temperature via normalized partition names (#6615) (#6619)
OMV reports filesystem devices (sda1) without the /dev/ prefix, so the
partition-suffix normalization never applied and filesystem-to-SMART
matching returned no temperature. Strip partition suffixes from
unprefixed sd/vd, nvme and mmcblk names, and use the shared normalized
matcher in both the health monitoring and system disks widgets.
2026-08-18 19:39:27 +02:00
Thomas 05ba57c2fa chore: migrate cursor rules to portable agent skills + mantine MCP guidance
* chore: migrate cursor rules to portable agent skills and add mantine MCP guidance

Replace Cursor-only .cursor/rules/*.mdc with Agent Skills open-standard
SKILL.md files in .agents/skills/ (canonical), mirrored to .cursor/skills/
and .claude/skills/ so they work across Claude Code, Cursor, Codex, and
OpenCode. Add mantine MCP server guidance to AGENTS.md and un-ignore
.agents/skills/ in .gitignore.

* refactor: single-source agent skills at .agents/skills

Cursor, Codex, and OpenCode all read .agents/skills/ natively, so the
.cursor/skills/ mirror was redundant. Claude Code reads .claude/skills/,
which is now a symlink back to .agents/skills/ so the content lives once.
Deletes .cursor/skills/ and converts .claude/skills/ to a symlink.

* chore: ship mantine MCP server via project-scoped .mcp.json

Declares the keyless @mantine/mcp-server in .mcp.json (Claude Code
project-scope standard, committable) and documents MCP server setup and
the mantine entry in AGENTS.md.

* chore: address coderabbit security-review and markdown lint findings

Add explicit security-review guidance before exposing MCP tools and label
plain-text code fences to satisfy markdownlint MD040.
2026-08-16 16:27:51 +02:00
Thomas ddbf05524b feat(http): set custom User-Agent on outbound integration requests (#6606)
* feat(http): set custom User-Agent on outbound integration requests

Set a default 'User-Agent: Homarr/<version> (+https://homarr.dev)' header
on all outbound HTTP requests made via fetchWithTrustedCertificatesAsync,
so Homarr's integration traffic is distinguishable in reverse-proxy,
WAF, and security-monitoring logs instead of appearing as generic undici.

Closes #6604

* refactor(version): centralize Homarr version in @homarr/version package

Replace fragile deep-relative 'package.json' imports across the monorepo
with a single source of truth: the new @homarr/version leaf package reads
the version once from the root package.json and exports it as VERSION.

Consumers now import by package name incl. the new User-Agent header in
fetchWithTrustedCertificatesAsync (packages/core):
  - core, api (info router), analytics, request-handler (update checker)
This also removes all manually-maintained relative version imports.

* fix(http): normalize User-Agent injection with Headers

Object-spreading fetchOptions.headers breaks when callers pass a Headers
instance (spread yields no entries) or a tuple array (numeric keys), and is
case-sensitive. Build a Headers instance and set the default User-Agent only
when no case-insensitive User-Agent entry exists, via a shared helper in both
fetch paths.

Adds unit tests covering record, Headers, tuple-array, and casing variants.

* revert(version): undo version refactor and tests, keep single-file change

Keep the User-Agent change as a single edit to
packages/core/src/infrastructure/http/request.ts. Remove the @homarr/version
package, the consumer migrations, and the unit tests.
2026-08-16 16:09:15 +02:00
Thomas df438b4cd2 Merge pull request #6571 from homarr-labs/feat/fix-home-assistant-actions 2026-08-16 00:23:14 +02:00
Thomas a1f91eb385 Merge pull request #6567 from homarr-labs/feat/fix-truenas-free-space 2026-08-15 20:20:35 +02:00
Thomas 8d5ffb2dd9 feat(paseo): add worktree install script (#6599)
* feat(paseo): add worktree install script

* feat(paseo): inline worktree setup in paseo.json

* feat(paseo): add scripts and teardown to paseo.json

* feat(paseo): expand workspace scripts catalog

* feat(paseo): reset encryption key in worktree env
2026-08-15 12:29:52 +02:00
Thomas Camlong 56e6b23af6 test(e2e): remove flaky credentials onboarding test 2026-08-15 11:05:06 +02:00
Thomas Camlong 5488bf4d18 fix(truenas): use lowercase in operator for dataset query 2026-08-15 01:33:20 +02:00
homarr-crowdin[bot] 621ea07549 chore(lang): updated translations from crowdin (#6553) 2026-08-14 20:39:02 +02:00
homarr-update-contributors[bot] f64c01456b chore: update contributors (#6588) 2026-08-14 20:38:42 +02:00
homarr-releases[bot] 56198c3f02 chore: update bug report template (#6591) 2026-08-14 20:38:26 +02:00
Thomas ca1bb6f341 Merge pull request #6592 from homarr-labs/docs/update-helm-docs
docs(helm): update chart installation page
2026-08-14 20:38:10 +02:00
github-actions[bot] bf6b6663cd docs(helm): update chart installation page 2026-08-14 18:25:32 +00:00
Releases Homarr 5c2d86767f chore(release): 1.75.0 [skip ci] 2026-08-14 17:45:24 +00:00
homarr-releases[bot] 7c75642d4d chore(release): automatic release v1.75.0 2026-08-14 17:43:56 +00:00
Thomas Camlong 7652eb8c1c preserve smart home toggle response 2026-08-13 17:04:00 +02:00
Thomas Camlong e761c10f32 fix Home Assistant widget actions 2026-08-13 16:21:48 +02:00
ajnart f6137b2dcc fix(truenas): guard empty dataset percentage 2026-08-12 18:24:33 +02:00
ajnart 425dd90871 fix(truenas): report usable dataset space 2026-08-12 18:19:07 +02:00
286 changed files with 40402 additions and 36129 deletions

No files matched your search

@@ -1,6 +1,6 @@
---
description: Comprehensive Homarr codebase architecture and conventions reference
alwaysApply: true
name: codebase-context
description: Comprehensive Homarr codebase architecture and conventions reference. Use when orienting in the monorepo, understanding package dependency layers, the three runtime services and ports, tRPC router structure, multi-database support, widget/integration/cron system architecture, routing, auth providers, env vars, or key patterns before writing code.
---
# Homarr Codebase Context
@@ -27,7 +27,7 @@ Homarr is an open-source, self-hosted dashboard for managing homelab services. I
## Repository Structure
```
```text
homarr/
├── apps/
│ ├── nextjs/ # Main Next.js application (port 3000)
@@ -66,8 +66,6 @@ homarr/
│ ├── analytics/ # Server-side analytics (Umami)
│ ├── server-settings/ # Server setting keys/types
│ ├── settings/ # User-facing settings UI context
│ ├── old-import/ # Legacy Homarr import
│ ├── old-schema/ # Legacy Homarr zod schemas
│ └── cli/ # Node CLI for ops (brocli)
├── tooling/
│ ├── typescript/ # Base tsconfig
@@ -79,11 +77,11 @@ homarr/
## Three Runtime Services
| Service | Port | Role |
|---------|------|------|
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
| Service | Port | Role |
| --------- | ---- | -------------------------------------------- |
| Next.js | 3000 | Main web app (SSR + API routes) |
| WebSocket | 3001 | tRPC subscriptions via `ws` |
| Tasks | 3002 | Cron job runner + management API via Fastify |
In Docker, nginx listens on **7575** and proxies to all three. The tasks service authenticates via `CRON_JOB_API_KEY` header.
@@ -112,6 +110,7 @@ In Docker, nginx listens on **7575** and proxies to all three. The tasks service
### Multi-Database Support
Drizzle schemas exist in three parallel implementations:
- `packages/db/schema/sqlite.ts`
- `packages/db/schema/mysql.ts`
- `packages/db/schema/postgresql.ts`
@@ -121,6 +120,7 @@ Selected at runtime via `DB_DRIVER` env (`better-sqlite3` | `mysql2` | `node-pos
### Widget System
Widgets are registered in `packages/widgets/src/index.tsx` as `widgetImports` (must satisfy `Record<WidgetKind, ...>`). Each widget:
- Has a `definition` created via `createWidgetDefinition(kind, { icon, createOptions, supportedIntegrations? })`
- Uses `optionsBuilder.from(factory => ({ ... }))` for typed options
- Loads dynamically via `next/dynamic`
@@ -133,6 +133,7 @@ Integrations are defined in `packages/definitions/src/integration.ts` (`integrat
### Cron Job System
Jobs defined in `packages/cron-jobs/src/jobs/`. Each job:
- Uses `createCronJob` with a cron expression
- Publishes results to Redis channels
- Widget subscriptions consume those channels via tRPC subscriptions
@@ -160,6 +161,7 @@ Configured via `AUTH_PROVIDERS` env. Supports: `credentials` (local), `ldap`, `o
## Environment Variables
Key env vars (from `.env.example`):
- `DB_DRIVER`: `better-sqlite3` | `mysql2` | `node-postgres`
- `DB_URL`: Database connection string
- `AUTH_SECRET`: NextAuth secret
@@ -191,16 +193,3 @@ Key env vars (from `.env.example`):
- Drag-and-drop via `@dnd-kit/*`
- `typescript.ignoreBuildErrors: true` in next.config (types checked separately via `typecheck`)
- `serverExternalPackages`: `dockerode`, `isomorphic-dompurify`, `jsdom`
## Documentation (`apps/docs`)
Docusaurus 3 site at `apps/docs/`, package name `@homarr/docs`.
- Run with `pnpm dev:docs` from root
- Build with `pnpm turbo build --filter=@homarr/docs`
- Uses Tailwind CSS 3 for custom pages (not Mantine)
- Imports `@homarr/definitions` for type-safe integration/widget metadata
- When adding or modifying widgets, integrations, cron jobs, API routes, env vars, or user-facing features, you MUST also update corresponding docs in `apps/docs/docs/`
- Integration docs: `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts`
- Widget docs: `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts`
- Strict link checking: broken links/anchors cause build failure
@@ -1,3 +1,8 @@
---
name: datatable-migration
description: Playbook for migrating tables from mantine-react-table (MRT) to mantine-datatable. Use when converting any table component from mantine-react-table to mantine-datatable, working on DataTable column/sort/expansion/context-menu code, or implementing responsive column visibility, column persistence, or transparent tables in widgets.
---
# Migrating Tables from mantine-react-table to mantine-datatable
This document captures every pattern, fix, and decision from the downloads widget rework (PR #6430). Use it as a playbook when porting other tables.
@@ -7,10 +12,12 @@ This document captures every pattern, fix, and decision from the downloads widge
Current `mantine-react-table` consumers that could be migrated:
**Widgets (in `packages/widgets/`):**
- `docker/component.tsx` — Docker container table
- `media-server/component.tsx` — Media server sessions
**Admin pages (in `apps/nextjs/src/app/[locale]/manage/`):**
- `tools/docker/docker-table.tsx`
- `tools/tasks/_components/tasks-table.tsx`
- `tools/api/components/api-keys.tsx`
@@ -19,12 +26,12 @@ Current `mantine-react-table` consumers that could be migrated:
- `tools/kubernetes/` — 8 Kubernetes resource tables
**Modals:**
- `packages/modals-collection/src/search-engines/request-media-modal.tsx`
**Shared hook to remove once all migrated:**
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
---
- `packages/ui/src/hooks/use-translated-mantine-react-table.ts`
## 1. Core Migration: mantine-react-table → mantine-datatable
@@ -67,6 +74,7 @@ const columns: DataTableColumn<MyRow>[] = [
```
Key differences:
- `accessorKey` → `accessor`
- `header` → `title`
- `size` → `width`
@@ -118,8 +126,6 @@ const sortedData = useMemo(() => {
<DataTable records={sortedData} sortStatus={sortStatus} onSortStatusChange={setSortStatus} />
```
---
## 2. Making Tables Transparent (Widget Context)
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.
@@ -144,13 +150,21 @@ Create a CSS file alongside the component:
/* Hover: translucent instead of opaque */
.my-table .mantine-datatable-table tbody tr:hover {
background-color: color-mix(in srgb, var(--mantine-color-default-hover) 40%, transparent) !important;
background-color: color-mix(
in srgb,
var(--mantine-color-default-hover) 40%,
transparent
) !important;
}
/* Headers: frosted glass effect */
.my-table th {
white-space: nowrap;
background-color: color-mix(in srgb, var(--mantine-color-body) 60%, transparent) !important;
background-color: color-mix(
in srgb,
var(--mantine-color-body) 60%,
transparent
) !important;
backdrop-filter: blur(8px);
}
@@ -172,14 +186,16 @@ Create a CSS file alongside the component:
/* Row expansion area */
.my-table .mantine-datatable-row-expansion-cell {
background-color: color-mix(in srgb, var(--mantine-color-body) 40%, transparent) !important;
background-color: color-mix(
in srgb,
var(--mantine-color-body) 40%,
transparent
) !important;
}
```
Import it: `import "./styles.css";` and apply via `className="my-table"`.
---
## 3. Responsive Column Visibility
Use a lookup table of width breakpoints instead of media queries. The widget receives `width` as a prop.
@@ -195,11 +211,38 @@ interface SizeConfig {
}
const SIZE_BREAKPOINTS: { maxWidth: number; config: SizeConfig }[] = [
{ maxWidth: 300, config: { fontSize: "xs", iconSize: 12, cellPadding: 2, showSpeedColumns: false, showTimeColumn: false, showStateColumn: false } },
{ maxWidth: 500, config: { fontSize: "xs", iconSize: 14, cellPadding: 4, showSpeedColumns: false, showTimeColumn: true, showStateColumn: false } },
{
maxWidth: 300,
config: {
fontSize: "xs",
iconSize: 12,
cellPadding: 2,
showSpeedColumns: false,
showTimeColumn: false,
showStateColumn: false,
},
},
{
maxWidth: 500,
config: {
fontSize: "xs",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: false,
showTimeColumn: true,
showStateColumn: false,
},
},
];
const DEFAULT_SIZE_CONFIG: SizeConfig = { fontSize: "sm", iconSize: 14, cellPadding: 4, showSpeedColumns: true, showTimeColumn: true, showStateColumn: true };
const DEFAULT_SIZE_CONFIG: SizeConfig = {
fontSize: "sm",
iconSize: 14,
cellPadding: 4,
showSpeedColumns: true,
showTimeColumn: true,
showStateColumn: true,
};
function getSizeConfig(width: number): SizeConfig {
for (const { maxWidth, config } of SIZE_BREAKPOINTS) {
@@ -215,7 +258,9 @@ const size = useMemo(() => getSizeConfig(width), [width]);
Then use a visibility check lookup table for columns:
```typescript
const columnVisibilityChecks: Partial<Record<string, (ctx: ColumnContext) => boolean>> = {
const columnVisibilityChecks: Partial<
Record<string, (ctx: ColumnContext) => boolean>
> = {
upSpeed: (ctx) => ctx.hasTorrents && ctx.size.showSpeedColumns,
downSpeed: (ctx) => ctx.size.showSpeedColumns,
time: (ctx) => ctx.size.showTimeColumn,
@@ -230,8 +275,6 @@ function shouldIncludeColumn(accessor: string, ctx: ColumnContext): boolean {
}
```
---
## 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.
@@ -263,15 +306,6 @@ Even hidden options need translation keys or the translation test fails:
"widget.mywidget.option.columnWidths.label": "Column widths"
```
### Old-import mappings
Add to `packages/old-import/src/widgets/options.ts`:
```typescript
columnOrder: () => undefined,
columnWidths: () => undefined,
```
### Dynamic storeKey
Use a key that includes `itemId` and sorted column names. This prevents cross-widget localStorage bleeding:
@@ -285,8 +319,13 @@ const storeKey = `mytable-${itemId ?? "preview"}-${[...options.columns].toSorted
On mount, seed `useDataTableColumns` with server-persisted values. Use a `hydrated` ref to prevent the persist effect from immediately writing defaults back:
```typescript
const { effectiveColumns, columnsOrder, columnsWidth, setColumnsOrder, setMultipleColumnWidths } =
useDataTableColumns<MyRow>({ key: storeKey, columns });
const {
effectiveColumns,
columnsOrder,
columnsWidth,
setColumnsOrder,
setMultipleColumnWidths,
} = useDataTableColumns<MyRow>({ key: storeKey, columns });
const lastStoreKey = useRef(storeKey);
const hydrated = useRef(false);
@@ -300,11 +339,22 @@ useEffect(() => {
if (savedOrder.length > 0) setColumnsOrder(savedOrder);
if (Object.keys(savedWidths).length > 0) {
setMultipleColumnWidths(
Object.entries(savedWidths).map(([accessor, w]) => ({ accessor, width: w })),
Object.entries(savedWidths).map(([accessor, w]) => ({
accessor,
width: w,
})),
);
}
requestAnimationFrame(() => { hydrated.current = true; });
}, [storeKey, savedOrder, savedWidths, setColumnsOrder, setMultipleColumnWidths]);
requestAnimationFrame(() => {
hydrated.current = true;
});
}, [
storeKey,
savedOrder,
savedWidths,
setColumnsOrder,
setMultipleColumnWidths,
]);
```
### Persist localStorage → server
@@ -317,22 +367,26 @@ const prevWidths = useRef(columnsWidth);
useEffect(() => {
if (!hydrated.current) return;
const orderChanged = JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged = JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
const orderChanged =
JSON.stringify(columnsOrder) !== JSON.stringify(prevOrder.current);
const widthsChanged =
JSON.stringify(columnsWidth) !== JSON.stringify(prevWidths.current);
prevOrder.current = columnsOrder;
prevWidths.current = columnsWidth;
if (!orderChanged && !widthsChanged) return;
if (orderChanged) persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (orderChanged)
persistOption({ columnOrder: JSON.stringify(columnsOrder) });
if (widthsChanged) {
const widthMap: Record<string, number> = {};
for (const entry of columnsWidth) {
const key = Object.keys(entry)[0];
if (!key) continue;
const w = entry[key as keyof typeof entry];
if (typeof w === "number") widthMap[key] = w;
else if (typeof w === "string" && w.endsWith("px")) widthMap[key] = parseInt(w, 10);
const v = entry[key as keyof typeof entry];
if (typeof v === "number") widthMap[key] = v;
else if (typeof v === "string" && v.endsWith("px"))
widthMap[key] = parseInt(v, 10);
}
persistOption({ columnWidths: JSON.stringify(widthMap) });
}
@@ -341,8 +395,6 @@ useEffect(() => {
The `persistOption` helper calls both `setOptions` (local state) and `saveItemOptions` (server mutation).
---
## 5. Context Menu (Right-Click)
Do NOT install `mantine-contextmenu`. Use Mantine's `Menu` + `Portal` directly:
@@ -374,15 +426,41 @@ const handleContextMenu = useCallback(
The menu component uses `Portal` for correct z-index:
```tsx
function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose: () => void }) {
function RowContextMenu({
state,
onClose,
}: {
state: ContextMenuState;
onClose: () => void;
}) {
return (
<Portal>
<Menu opened onClose={onClose} closeOnItemClick={false} position="right-start" offset={0}>
<Menu
opened
onClose={onClose}
closeOnItemClick={false}
position="right-start"
offset={0}
>
<Menu.Target>
<Box style={{ position: "fixed", left: state.x, top: state.y, width: 0, height: 0 }} />
<Box
style={{
position: "fixed",
left: state.x,
top: state.y,
width: 0,
height: 0,
}}
/>
</Menu.Target>
<Menu.Dropdown>
<Menu.Item onClick={() => { /* action */ onClose(); }}>Action</Menu.Item>
<Menu.Item
onClick={() => {
/* action */ onClose();
}}
>
Action
</Menu.Item>
</Menu.Dropdown>
</Menu>
</Portal>
@@ -392,8 +470,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**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(); }}`.
---
## 6. Row Expansion
```typescript
@@ -413,8 +489,6 @@ function RowContextMenu({ state, onClose }: { state: ContextMenuState; onClose:
**Critical:** Do NOT set `onRowClick` when using `rowExpansion` with `trigger: "click"`. They conflict and the expansion won't work.
---
## 7. Common Pitfalls & Fixes
### Status indicator dots
@@ -475,8 +549,11 @@ Always wrap in try/catch:
```typescript
function parseJsonOption<T>(value: string | undefined, fallback: T): T {
if (!value) return fallback;
try { return JSON.parse(value) as T; }
catch { return fallback; }
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
```
@@ -494,20 +571,19 @@ If the widget has configurable default sort, sync it:
```typescript
useEffect(() => {
setSortStatus({ columnAccessor: options.defaultSort, direction: defaultSortDirection });
setSortStatus({
columnAccessor: options.defaultSort,
direction: defaultSortDirection,
});
}, [options.defaultSort, defaultSortDirection]);
```
---
## 8. Accessibility
- All `ActionIcon` components need `aria-label`
- Filter toggles (client badges, status badges) need `aria-pressed={isActive}`
- `textSelectionDisabled` on DataTable prevents accidental text selection during drag/resize
---
## 9. Error Handling
- Every mutation needs an `onError` callback with `showErrorNotification`
@@ -518,12 +594,14 @@ useEffect(() => {
```typescript
const mutationOptions = {
onSettled: () => void utils.myQuery.invalidate(),
onError: () => showErrorNotification({ title: t("errors.actionFailed"), message: t("errors.actionFailedMessage") }),
onError: () =>
showErrorNotification({
title: t("errors.actionFailed"),
message: t("errors.actionFailedMessage"),
}),
};
```
---
## 10. i18n
- No hardcoded strings. Every user-visible string goes through `useScopedI18n`
@@ -534,8 +612,6 @@ const mutationOptions = {
- Errors: `t("errors.{errorKey}")`
- Hidden options still need `.label` translation keys
---
## 11. Edit Mode
Disable pointer events on the table during edit mode to prevent conflicts with the board's drag-and-drop:
@@ -550,8 +626,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
<DataTable style={{ pointerEvents: tablePointerEvents }} />
```
---
## 12. DataTable Props Reference (Commonly Used)
```typescript
@@ -590,8 +664,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
/>
```
---
## 13. Layout Stability
- Don't use `table-layout: fixed` — it fights mantine-datatable's resize logic
@@ -600,8 +672,6 @@ if (isEditMode) rowContextMenuHandler = undefined;
- Names should truncate at the cell boundary, not a character count, so the layout is stable regardless of content length
- `flexShrink: 0` on badges/icons prevents them from being squeezed by long names
---
## 14. Checklist for Each Migration
1. Replace imports (`mantine-react-table` → `mantine-datatable`)
@@ -610,13 +680,12 @@ if (isEditMode) rowContextMenuHandler = undefined;
4. Add CSS file for transparent backgrounds (if widget context)
5. Add responsive column visibility (if widget context)
6. Add hidden options + hydration logic (if column persistence needed)
7. Add old-import mappings for new options
8. Add translation keys (including hidden option labels)
9. Add context menu if row actions exist
10. Add row expansion if detail view exists
11. Add error handling on all mutations
12. Add accessibility attributes
13. Test at multiple widget widths
14. Update docs in `apps/docs/docs/widgets/` if applicable
15. Run `pnpm turbo typecheck` — catches old-import mismatches
16. Run translation spec — catches missing keys
7. Add translation keys (including hidden option labels)
8. Add context menu if row actions exist
9. Add row expansion if detail view exists
10. Add error handling on all mutations
11. Add accessibility attributes
12. Test at multiple widget widths
13. Update docs in `apps/docs/docs/widgets/` if applicable
14. Run `pnpm turbo typecheck`
15. Run translation spec — catches missing keys
@@ -0,0 +1,44 @@
---
name: documentation-sync
description: Keep Homarr documentation in sync with code changes. Use when modifying code that affects user-facing behavior, or when adding/modifying widgets, integrations, cron jobs, API routes, env vars, permissions, auth providers, or UI in apps/docs. Covers the docs/ mapping table, integration doc pattern, and widget doc pattern.
---
# Documentation Sync
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
| ---------------------- | ----------------------------------------------------------- |
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
@@ -1,13 +1,14 @@
---
description: How to expose tRPC procedures as MCP tools for AI clients
globs: packages/api/src/router/**/*.ts
alwaysApply: false
name: mcp-integration
description: Expose tRPC procedures as MCP tools for AI clients. Use when modifying procedures in packages/api/src/router, adding a new tRPC procedure or router, or deciding what to expose via Homarr's MCP interface. Covers .meta({ mcp }), router registration in packages/api/src/mcp.ts, input schema rules, and what to expose vs skip.
---
# MCP Integration Guide
# MCP Integration
Every new tRPC procedure that provides useful functionality should be exposed as an MCP tool so AI assistants (Claude, Cursor, etc.) can use it. This is a first-class feature of Homarr.
Exposing a procedure makes it callable by any AI client with an API key. Before adding `.meta({ mcp })`, review what the tool exposes: authorization requirements, sensitive data, tenant isolation, auditability, and whether the action is destructive. Prefer exposing read-only queries over mutations unless the mutation is safe and permission-scoped. When in doubt, ask for a security review before shipping a new MCP tool.
## Adding MCP to a procedure
Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure chain:
@@ -16,7 +17,7 @@ Add `.meta({ mcp: { enabled: true, description: "..." } })` to the procedure cha
// Read-only tool (query)
myProcedure: protectedProcedure
.meta({ mcp: { enabled: true, description: "Get all items with their status and metadata" } })
.input(z.object({ limit: z.number().default(50) }))
.input(z.object({ limit: z.number().default(10) }))
.query(async ({ ctx, input }) => { ... })
// Write tool (mutation)
@@ -38,23 +39,27 @@ The `.meta()` call must come **before** `.input()` and `.query()`/`.mutation()`
The description is the **only thing** an AI sees to decide when and how to use the tool. Write it like you're explaining to a colleague who has never used Homarr.
**Must include:**
- What the tool returns or does
- Which integrations/services it works with (if applicable)
- How to get required IDs (e.g., "Use integration_all to get the integrationId")
- Permission requirements (if non-obvious)
**Good:**
```
```text
"Get calendar events for upcoming and recent media releases. Fetches from all connected Sonarr (TV), Radarr (movies), Lidarr (music), and Readarr (books) integrations. Requires integrationIds from integration_all"
```
**Bad:**
```
```text
"Get calendar events"
```
**For tools that return permission fields**, explain what they mean:
```
```text
"List all integrations. Returns permissions.hasUseAccess (read) and permissions.hasInteractAccess (actions) — false means the API key lacks that permission, not an error"
```
@@ -77,11 +82,13 @@ export const mcpRouter = createTRPCRouter({
## What to expose vs. what to skip
**Expose:**
- Data queries (list, search, get by ID)
- Actions users would ask an AI to do (toggle, create, delete, approve)
- Status checks (health, stats, summaries)
**Skip:**
- Subscriptions (WebSocket-only, not supported by MCP)
- Internal procedures (session management, onboarding steps)
- File upload/download procedures
+1
View File
@@ -0,0 +1 @@
../.agents/skills
-50
View File
@@ -1,50 +0,0 @@
---
description: Keep documentation in sync with code changes
globs:
- packages/definitions/src/**
- packages/widgets/src/**
- packages/integrations/src/**
- packages/api/src/**
- apps/nextjs/src/**
- apps/docs/**
---
# Documentation Sync Rule
When modifying code that affects user-facing behavior, you MUST also update the documentation in `apps/docs/`.
## What triggers a doc update
| Code change | Doc location |
|---|---|
| New integration | `apps/docs/docs/integrations/<slug>/index.mdx` + `index.ts` |
| New widget | `apps/docs/docs/widgets/<slug>/index.mdx` + `index.ts` |
| Changed API surface | `apps/docs/docs/management/api.mdx` |
| New/changed env vars | `apps/docs/docs/advanced/` |
| New CLI commands | `apps/docs/docs/advanced/command-line/` |
| Changed auth providers | `apps/docs/docs/advanced/` SSO pages |
| UI/UX changes | Relevant getting-started or management pages |
| New cron job | `apps/docs/docs/management/tasks.mdx` |
| Changed permissions | `apps/docs/docs/management/users.mdx` |
## Integration doc pattern
Each integration has a folder under `apps/docs/docs/integrations/<slug>/`:
- `index.ts` — exports a typed `IntegrationDefinition` with name, description, iconUrl, path
- `index.mdx` — uses shared components: `IntegrationHeader`, `IntegrationCapabilites`, `IntegrationSecrets`
- Import metadata from `@homarr/definitions` where possible
## Widget doc pattern
Each widget has a folder under `apps/docs/docs/widgets/<slug>/`:
- `index.ts` — exports a typed `WidgetDefinition` with icon, name, description, path, configuration
- `index.mdx` — uses shared components: `WidgetHeader`, `WidgetConfig`, `WidgetAdding`
## Development
- Run docs locally: `pnpm dev:docs` from root
- Build docs: `pnpm turbo build --filter=@homarr/docs`
- The docs app can import from `@homarr/definitions` for type-safe integration/widget metadata
- Docusaurus strict mode throws on broken links — fix any broken references before committing
-4
View File
@@ -55,10 +55,6 @@ TURBO_TELEMETRY_DISABLED=1
# Example: DOCKER_SOCKET_PATHS=/var/run/docker.sock,/run/user/1000/podman/podman.sock
# DOCKER_SOCKET_PATHS=
# Redis DB index for the Next.js cache handler (default: 1).
# Must differ from REDIS_DATABASE_INDEX (default: 0) to avoid cache ops affecting app data.
# REDIS_CACHE_DB=1
# Enable kubernetes tool
# ENABLE_KUBERNETES=true
+5
View File
@@ -33,6 +33,11 @@ body:
options:
# The below comment is used to insert a new version with on-release.yml
#NEXT_VERSION#
- 1.77.0
- 1.76.2
- 1.76.1
- 1.76.0
- 1.75.0
- 1.74.0
- 1.73.0
- 1.72.0
+131
View File
@@ -0,0 +1,131 @@
name: Homarr v2 beta feedback
description: Report a specific bug, unexpected behavior, or improvement for the Homarr v2 beta
title: "feedback(v2): "
labels: ["needs triage"]
body:
- type: markdown
attributes:
value: |
Thanks for testing Homarr v2. It will become the default when it is ready, and every detailed report helps us get there.
We cannot guess what you wanted from “this looks wrong.” Tell us exactly which feature is affected, what happened, why it is wrong for your use case, and what you expected instead. Add screenshots or a short recording whenever possible.
**Not useful:** “The board looks wrong.”
**Useful:** “The container changes from four columns to three after I move a widget at 1440 × 900 and 100% browser zoom. I expected it to keep four columns.”
- type: dropdown
id: feedback-type
attributes:
label: Feedback type
options:
- Bug
- Upgrade or migration
- Usability or design
- Performance
- Other
validations:
required: true
- type: input
id: version
attributes:
label: Homarr v2 version or image tag
description: Copy the version from the About page or provide the exact Docker image tag you tested.
placeholder: ghcr.io/homarr-labs/homarr-test:v2
validations:
required: true
- type: input
id: affected-area
attributes:
label: Affected page, feature, or widget
description: Name the exact page, widget, integration, container, sidebar, or action involved.
placeholder: "Board editor: moving a widget into a container"
validations:
required: true
- type: textarea
id: behavior
attributes:
label: What happened, why is it wrong, and what should happen instead?
description: Describe the actual result, why it does not work for you, and the exact result you expected.
placeholder: |
What happened:
Why this is wrong for my use case:
What I expected instead:
validations:
required: true
- type: textarea
id: reproduction
attributes:
label: Exact steps to reproduce
description: Start from the page you opened and list every click, keypress, drag, setting, and value needed to reproduce it.
placeholder: |
1. Open...
2. Set...
3. Drag or click...
4. Observe...
validations:
required: true
- type: textarea
id: visual-evidence
attributes:
label: Screenshots or screen recordings
description: Drag screenshots or a recording into this field. Include the full page for context and a close-up of the problem. Blur secrets and personal data. If you cannot add visuals, explain why.
placeholder: Drag screenshots or a recording here, or explain why none are available.
validations:
required: true
- type: textarea
id: environment
attributes:
label: Device and Homarr environment
description: Provide every detail that could affect the result.
placeholder: |
Device type and model:
Operating system and version:
Browser and version:
Installation method (Docker, Unraid, Kubernetes, other):
Database (SQLite, MySQL, PostgreSQL):
Reverse proxy and version, if used:
validations:
required: true
- type: textarea
id: board-context
attributes:
label: Board and display context
description: Include the layout details even when you are not sure they matter.
placeholder: |
Display resolution:
Browser zoom:
Desktop or mobile layout:
Board column count:
Approximate number of items:
Container or sidebar nesting:
Relevant board or widget settings:
validations:
required: true
- type: textarea
id: additional-information
attributes:
label: Logs and additional information
description: Add relevant browser console errors, Homarr logs, integration details, or anything else that may help. Remove secrets and personal information first.
- type: checkboxes
id: checks
attributes:
label: Before submitting
options:
- label: I added screenshots or a recording where possible, or explained why I could not.
required: true
- label: I described both what happened and the exact result I expected.
required: true
- label: I searched the existing issues for the same feedback.
required: true
- label: I removed passwords, API keys, tokens, and other private information.
required: true
@@ -68,8 +68,6 @@ jobs:
with:
node-version: 24.18.0
package-manager-cache: false
- run: npm i -g pnpm
if: env.SKIP_RELEASE == 'false'
- name: Install dependencies
if: env.SKIP_RELEASE == 'false'
run: |
+2 -9
View File
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config
.opencode
.agents/
.agents/*
!.agents/skills/
skills-lock.json
# docusaurus
@@ -79,11 +80,3 @@ apps/docs/.docusaurus
apps/docs/build
.pnpm-store
benchmark-results
.bench/
# Homarr backups used by scripts/benchmarks/stress-restore.mts. These contain
# real integration secrets and an encryption key — never commit one.
homarr-backup*.zip
*.sqlite
*.sqlite-wal
*.sqlite-shm
+8
View File
@@ -0,0 +1,8 @@
{
"mcpServers": {
"mantine": {
"command": "npx",
"args": ["-y", "@mantine/mcp-server"]
}
}
}
+10
View File
@@ -84,6 +84,16 @@ When modifying user-facing code, you MUST also update the corresponding document
- Lint: oxlint (not ESLint)
- Format: oxfmt (not Prettier)
- UI: Mantine (not Tailwind) — Tailwind is only used in docs app
- Mantine: use the `mantine` MCP server (configured in `.mcp.json`; tools `get_item_doc` / `get_item_props` / `search_docs`) for current v9 APIs before writing component code. Prefer built-in primitives (`Combobox`/`useCombobox` for selects, the polymorphic `component` prop, `@mantine/hooks`) over hand-rolled equivalents. Check `packages/ui/` for existing conventions first.
## MCP servers
`.mcp.json` at the repo root declares project-scoped MCP servers (the standard Claude Code layout; other tools can import the same `mcpServers` block). See the `mantine` entry there for the Mantine v9 server, and the upstream docs at https://mantine.dev/guides/llms/ for per-tool setup (OpenCode: `.opencode/opencode.json`, Codex: `.codex/config.toml`). Only keyless server definitions belong in `.mcp.json`; anything needing secrets stays in local dotfiles.
## Agent Skills
Portable agent skills live in `.agents/skills/` (the Agent Skills open standard). Cursor, Codex, and OpenCode read `.agents/skills/` natively. Claude Code does not scan it, so `.claude/skills/` is a symlink back to `.agents/skills/`. Install skills at `.agents/skills/<name>/SKILL.md` — that single copy is used everywhere. See `.agents/skills/` for available skills (documentation-sync, mcp-integration, codebase-context, datatable-migration).
- Icons: @tabler/icons-react
- Docs app can import from `@homarr/definitions` for shared types
- Run `pnpm dev:cli -- dev` to browse local `homarr:*` images and remote PR images.
+10
View File
@@ -1,3 +1,13 @@
## [1.77.0](https://github.com/homarr-labs/homarr/compare/v1.76.2...v1.77.0) (2026-09-06)
## [1.76.2](https://github.com/homarr-labs/homarr/compare/v1.76.1...v1.76.2) (2026-08-27)
## [1.76.1](https://github.com/homarr-labs/homarr/compare/v1.76.0...v1.76.1) (2026-08-26)
## [1.76.0](https://github.com/homarr-labs/homarr/compare/v1.75.0...v1.76.0) (2026-08-21)
## [1.75.0](https://github.com/homarr-labs/homarr/compare/v1.74.0...v1.75.0) (2026-08-14)
## [1.74.0](https://github.com/homarr-labs/homarr/compare/v1.73.0...v1.74.0) (2026-08-07)
## [1.73.0](https://github.com/homarr-labs/homarr/compare/v1.72.0...v1.73.0) (2026-07-31)
-19
View File
@@ -40,25 +40,6 @@ ARG SKIP_ENV_VALIDATION='true'
ARG CI='true'
ARG DISABLE_REDIS_LOGS='true'
ARG TARGETPLATFORM
# Opt-in React profiling build + browser source maps, for React DevTools' Profiler
# and for readable names in heap snapshots. See next.config.ts.
ARG HOMARR_PROFILING='false'
ENV HOMARR_PROFILING=${HOMARR_PROFILING}
# React ships a separate profiling renderer; the production one omits the hooks
# DevTools needs, which is what makes it report "Profiling not supported". Next only
# swaps it via a webpack alias (create-compiler-aliases.js), so under Turbopack both
# `next build --profile` and `reactProductionProfiling` silently produce a normal
# build. Point the vendored client entry at the profiling bundle instead — the same
# swap webpack's alias performs, done where Turbopack cannot miss it.
RUN if [ "$HOMARR_PROFILING" = "true" ]; then \
set -e; \
entry=node_modules/next/dist/compiled/react-dom/client.js; \
grep -q "react-dom-client.production.js" "$entry" || { echo "profiling patch: unexpected $entry"; exit 1; }; \
sed -i "s#./cjs/react-dom-client.production.js#./cjs/react-dom-profiling.profiling.js#" "$entry"; \
grep -q "react-dom-profiling.profiling.js" "$entry" || { echo "profiling patch failed"; exit 1; }; \
echo "profiling patch applied to $entry"; \
fi
RUN --mount=type=secret,id=TURBO_API,env=TURBO_API \
--mount=type=secret,id=TURBO_TEAM,env=TURBO_TEAM \
-28
View File
@@ -1,28 +0,0 @@
# Performance findings
This file grew past 1,200 lines covering server memory, client rendering, methodology and a long
list of rejected ideas at once. It has been split by subject under [`docs/perf/`](./docs/perf/):
| document | subject |
| --- | --- |
| [docs/perf/README.md](./docs/perf/README.md) | Headline results and the change-by-change ratio table |
| [docs/perf/SERVER-MEMORY.md](./docs/perf/SERVER-MEMORY.md) | What the container's RAM is made of, layer by layer, and what reduced it |
| [docs/perf/CLIENT-PERFORMANCE.md](./docs/perf/CLIENT-PERFORMANCE.md) | Dashboard cost in Chrome: DOM, fibers, images, paint |
| [docs/perf/REJECTED.md](./docs/perf/REJECTED.md) | Ideas implemented, measured, and abandoned — with their numbers |
| [docs/perf/METHODOLOGY.md](./docs/perf/METHODOLOGY.md) | The tools, and the measurement traps that produced wrong answers |
## Result in one table
Server RAM, merge-base `06724cfeb` vs this branch, two alternated runs per side, cgroup **anonymous**
memory — the figure that counts against a container limit:
| stage | before | after | change |
| --- | --- | --- | --- |
| **peak** | 376.6 MiB | **279.3 MiB** | **−26%** |
| boot idle | 130.5 MiB | **62.4 MiB** | **−52%** |
| after sign-in | 247.4 MiB | **142.9 MiB** | **−42%** |
| dashboard loaded | 293.4 MiB | **170.4 MiB** | **−42%** |
| 4 tabs, 2 boards | 366.5 MiB | **258.2 MiB** | **−30%** |
| after 2 min idle soak | 323.7 MiB | **200.8 MiB** | **−38%** |
No before/after range overlaps at any stage. Cost: **+18% CPU**, solid and non-overlapping.
@@ -13,7 +13,7 @@ You should also create a backup of the database, in case you break something.
## Execute CLI with Docker
1. Execute ``docker ps | grep homarr`` on the root system with Docker to find the container ID of your Homarr container.
2. Run ``docker exec -it <container-id> homarr <your-command-goes-here>``. \
2. Run ``docker exec -it <container-id> /bin/bash homarr <your-command-goes-here>``. \
Replace ``<container-id>`` with your container ID from step 1. \
Replace ``<your-command-goes-here>`` with the command you want to execute (see sub-pages).
+6
View File
@@ -11,6 +11,12 @@ tags:
import Link from '@docusaurus/Link';
import { IconArrowRight } from '@tabler/icons-react';
:::info Homarr v2 beta is available
We've been working on Homarr's v2 update for a while now. There is no fixed release date yet, it will be ready when it's ready. We **need your help** and feedback to help us become confident enough to release it for everyone. Read [what is new](https://v2.preview.homarr.dev/blog/2026/09/03/homarr-2.0), browse the [v2 documentation](https://v2.preview.homarr.dev/docs/getting-started/) and [Workshop](https://v2.preview.homarr.dev/workshop), or try the [hosted preview](https://app-v2.preview.homarr.dev). If anything breaks or does not feel right, [tell us on Discord](https://discord.com/invite/aCsmEV5RgA) in the dedicated v2 channel for the fastest response, or [open a dedicated GitHub issue](https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml). SQLite administrators can download a full backup directly from the beta announcement before changing images.
:::
Before you try to install Homarr, please review if your device meets the requirements.
Installing on an unsupported device may lead to unstable behaviour and no support will be provided.
@@ -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 -f homarr`` to stop and remove the container.
1. Run ``docker rm homarr`` to 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 -a | grep homarr``.
2. Delete the container using ``docker rm -f <your-container-id>``
1. Obtain the container ID of your Homarr container using ``docker ps | grep homarr``.
2. Delete the container using ``docker rm <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.
@@ -2,9 +2,9 @@
<img src="https://raw.githubusercontent.com/homarr-labs/charts/refs/heads/main/charts/homarr/icon.svg" align="right" width="92" alt="homarr logo">
![Version: 8.26.0](https://img.shields.io/badge/Version-8.26.0-informational?style=flat)
![Version: 8.28.1](https://img.shields.io/badge/Version-8.28.1-informational?style=flat)
![Type: application](https://img.shields.io/badge/Type-application-informational?style=flat)
![AppVersion: v1.74.0](https://img.shields.io/badge/AppVersion-v1.74.0-informational?style=flat)
![AppVersion: v1.76.1](https://img.shields.io/badge/AppVersion-v1.76.1-informational?style=flat)
A Helm chart to deploy homarr for Kubernetes
@@ -429,7 +429,7 @@ All available values are listed on the [artifacthub](https://artifacthub.io/pack
| httproute.rules[0].matches[0].path.value | string | `"/"` | Path value to match |
| image.pullPolicy | string | `"IfNotPresent"` | Image pull policy |
| image.repository | string | `"ghcr.io/homarr-labs/homarr"` | Image repository |
| image.tag | string | `"v1.74.0"` | Overrides the image tag whose default is the chart appVersion |
| image.tag | string | `"v1.76.1"` | Overrides the image tag whose default is the chart appVersion |
| imagePullSecrets | list | `[]` | Secrets for Docker registry |
| ingress.annotations | object | `{}` | Ingress annotations |
| ingress.enabled | bool | `false` | Enable ingress |
@@ -27,6 +27,8 @@ import Alert from '@theme/Admonition';
]}
/>
The storage values shown by these widgets come from each pool's root ZFS dataset, so they already account for RAIDZ parity overhead and dataset limits and match what `zfs list` and the TrueNAS web interface report. When a pool cannot report its dataset space, Homarr falls back to the pool's physical values, which read higher on RAIDZ.
### Adding the integration
<AddingIntegration />
@@ -52,14 +52,21 @@ The certificates are stored in the `/appdata/trusted-certificates` directory whi
This means you can automate the update of the certificates by simply replacing or adding a new file in the directory.
## Obtaining certificates
Every integration has its own way of obtaining certificates.
Generally it is recommended to search for the documentation of the integration you are using.
Most of the time you can find the certificate in the file system of the integration.
The certificate file needs to be uploaded to Homarr. To first get the file on your computer you can either:
- use `scp user@host:/path/to/file ./certificate.pem`
- use `cat /path/to/file` to print the certificate. Then recreate the file on your machine.
Below you can find instructions for some of the integrations.
### Pi-hole (v6)
Since version 6 of Pi-hole, the web interface can be accessed through HTTPS.
The certificate can be found in the path `/etc/pihole/tls_ca.crt`. To add the certificate, this file needs to be copied and uploaded to Homarr.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
The certificate can be found in the path `/etc/pihole/tls_ca.crt`.
You can find more details regarding SSL / TLS for Pi-hole [here](https://docs.pi-hole.net/api/tls/).
### Proxmox
By default, Proxmox VE uses a node certificate signed by the cluster. You only need to configure Homarr with the certificate of a single node.
The certificate can be found in any node on the path `/etc/pve/pve-root-ca.pem`.
You can learn more about certificate management in Proxmox [here](https://pve.proxmox.com/wiki/Certificate_Management).
+13 -15
View File
@@ -1,30 +1,27 @@
---
title: 'App'
description: 'Embeds an app into the board.'
title: "App"
description: "Embeds an app into the board."
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { appWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { appWidget } from ".";
<WidgetHeader
widget={appWidget}
categories={['Links', 'Apps']}
/>
<WidgetHeader widget={appWidget} categories={["Links", "Apps"]} />
The app widget allows you to add a link to an application or a website that you want to access quickly from your Homarr board.
It also supports a status check to indicate whether Homarr can reach the application or website.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots
import githubApp from './img/github.png';
import jellyseerrApp from './img/jellyseerr-description-tooltip.png';
import piHoleApp from './img/pi-hole-ping-icon.png';
import githubApp from "./img/github.png";
import jellyseerrApp from "./img/jellyseerr-description-tooltip.png";
import piHoleApp from "./img/pi-hole-ping-icon.png";
<div className='flex gap-1'>
<div className="flex gap-1">
<img src={githubApp} alt="app github with-status-check" width={128 * 1.5} height={128 * 1.5} />
<img src={piHoleApp} alt="app pi-hole with-status-check status-check-icon" width={128 * 1.5} height={128 * 1.5} />
<img src={jellyseerrApp} alt="app jellyseerr with-description-tooltip" height={128 * 1.5} />
@@ -34,4 +31,5 @@ import piHoleApp from './img/pi-hole-ping-icon.png';
<AddingWidget />
### Configuration
<WidgetConfig configuration={appWidget.configuration} />
+14 -16
View File
@@ -1,29 +1,26 @@
---
title: 'Bookmarks'
description: 'Displays multiple app links'
title: "Bookmarks"
description: "Displays multiple app links"
hide_title: true
---
import { AddingWidget } from '@site/src/components/widgets/adding';
import { WidgetConfig } from '@site/src/components/widgets/configuration';
import { WidgetHeader } from '@site/src/components/widgets/header';
import { bookmarksWidget } from '.';
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetHeader } from "@site/src/components/widgets/header";
import { bookmarksWidget } from ".";
<WidgetHeader
widget={bookmarksWidget}
categories={['Links', 'Apps']}
/>
<WidgetHeader widget={bookmarksWidget} categories={["Links", "Apps"]} />
The bookmarks widget allows you to display multiple app links in a single widget.
You can add links to your favorite applications, websites, or any other resources you want quick access to.
In view mode, right-clicking uses the browser's link menu. Homarr's widget menu remains available in edit mode.
### Screenshots
import gridIconsOnly from './img/grid-icons-only.png';
import gridWithTitle from './img/grid-with-title.png';
import verticalListAll from './img/vertical-list-all.png';
import vericalListSimple from './img/vertical-list-simple.png';
import gridIconsOnly from "./img/grid-icons-only.png";
import gridWithTitle from "./img/grid-with-title.png";
import verticalListAll from "./img/vertical-list-all.png";
import vericalListSimple from "./img/vertical-list-simple.png";
<div className='flex gap-1'>
<div className='flex flex-col gap-1'>
@@ -33,12 +30,13 @@ import vericalListSimple from './img/vertical-list-simple.png';
<img src={gridWithTitle} alt="bookmarks grid with-title" width={256 + 4} height={256 + 4} />
<img src={verticalListAll} alt="bookmarks vertical-list all-elements" width={128} height={256 + 4} />
</div>
</div>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={bookmarksWidget.configuration} />
+3 -1
View File
@@ -45,18 +45,20 @@ Some proxies disallow iframes by default for this reason.
You can use https://iframetest.com to check, if the site is allowing iframes.
If you manage all of your own applications, you can usually change that by configuring
the appropriate headers (such as `Content-Security-Policy`) to allow iframes.
the appropriate headers (such as `X-Frame-Options` or `Content-Security-Policy`) to allow iframes.
For example, to allow your site to be embedded in iframes from specific domains:
**Apache (.htaccess or httpd.conf):**
```apache
Header set Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com"
Header set X-Frame-Options "ALLOW-FROM https://trusted-domain.com"
```
**Nginx:**
```nginx
add_header Content-Security-Policy "frame-ancestors 'self' https://trusted-domain.com";
add_header X-Frame-Options "ALLOW-FROM https://trusted-domain.com";
```
### Example URLs
@@ -43,6 +43,7 @@ import temperature from './img/temperature.png';
### Interactions
- Toggle entity state by clicking on the entity (only when clickable enabled).
- After a successful toggle, the displayed state refreshes immediately.
### Adding the widget
@@ -64,4 +65,4 @@ import retrieveEntityId from './img/retrieve-entity-id.png';
<div>
<img src={retrieveEntityId} alt="homeassistant retrieve-entity-id" width={256 * 2} />
</div>
</div>
@@ -47,4 +47,7 @@ import withDisplayname from './img/with-displayname.png';
<AddingWidget />
### Configuration
Use the Home Assistant entity ID (for example, `automation.turn_on_lights`) in the **Automation ID** field, not the automation's internal ID.
<WidgetConfig configuration={smartHomeExecuteAutomationWidget.configuration} />
+1 -6
View File
@@ -26,10 +26,5 @@
"**/*.md",
"**/*.mdx",
],
// `include` sweeps up **/*.js and allowJs is on, so anything the build emits into this package is
// typechecked as source. The turbo `typecheck` task only dependsOn ["^topo"], not on its own
// package's `build`, so `docusaurus build` and `tsc --noEmit` run concurrently - and tsc can observe
// Docusaurus's transient `build/__server/` bundles and fail with TS1473 before the build removes
// them. Excluding generated output makes typecheck independent of that scheduling.
"exclude": ["node_modules", "build", ".docusaurus"],
"exclude": ["node_modules"],
}
+15 -78
View File
@@ -15,27 +15,18 @@ const withNextIntl = createNextIntlPlugin({
requestConfig: "../../packages/translation/src/request.ts",
});
/**
* Profiling build. React's production bundle strips the hooks React DevTools needs,
* which is why the Profiler tab reports "Profiling not supported"; the profiling
* variant keeps them. Source maps are emitted alongside so heap snapshots and flame
* charts show real component names instead of minified ones like `y`.
*
* Build with:
* docker build --build-arg HOMARR_PROFILING=true . -t homarr:performance
*
* `reactProductionProfiling` in this config is only read by the webpack path — under
* Turbopack it comes from `next build --profile`, which apps/nextjs/package.json
* passes when HOMARR_PROFILING=true. Both are set so either bundler works.
*
* Off by default: the profiling variant is slower and source maps are large, so this
* must never be what ships to users.
*/
const isProfilingBuild = process.env.HOMARR_PROFILING === "true";
const getDevelopmentServiceAliases = () => {
if (process.env.NODE_ENV !== "development") {
return undefined;
}
return {
"@homarr/tasks": "./src/instrumentation-noop.ts",
"@homarr/websocket": "./src/instrumentation-noop.ts",
};
};
const nextConfig: NextConfig = {
reactProductionProfiling: isProfilingBuild,
productionBrowserSourceMaps: isProfilingBuild,
// Next previews otherwise create agent instruction files in the application
// directory during development.
agentRules: false,
@@ -50,73 +41,19 @@ const nextConfig: NextConfig = {
typescript: { ignoreBuildErrors: true },
/**
* dockerode is required in the external server packages because of https://github.com/homarr-labs/homarr/issues/612
*
* Everything else here is a server-only dependency that is cheaper left as a
* runtime require than inlined into the server bundle. Bundling pulls a package's
* whole transitive graph into a chunk that is loaded and compiled at boot even
* when the code path never runs — measurably so for the DB drivers: only
* better-sqlite3 is ever used, yet inlining mysql2 and pg cost ~21 MiB of
* resident memory at idle.
* isomorphic-dompurify and jsdom are required, see https://github.com/kkomelin/isomorphic-dompurify/issues/356
*/
serverExternalPackages: [
"dockerode",
"better-sqlite3",
"mysql2",
"pg",
"winston",
"drizzle-orm",
"ical.js",
"jszip",
"ldapts",
"node-unifi",
"@kubernetes/client-node",
"linkedom",
// Only reachable through /api/mcp/[transport].
"@modelcontextprotocol/sdk",
// Bundling inlines a copy per chunk: a server heap snapshot found this package's
// command table in 10 chunks and resident 6 times over (2.1 MiB of duplication).
// Safe to externalise because ioredis is already external and owns this as a
// direct dependency, so it is always traced alongside it. Bundle hygiene — it did
// not move measured memory (idle 89.7 -> 89.4 MiB, inside run-to-run noise).
// got and @octokit/request-error duplicate similarly but are only transitive
// deps here, so externalising them risks a runtime require that was never traced,
// for no measured gain.
"@ioredis/commands",
],
serverExternalPackages: ["dockerode", "isomorphic-dompurify", "jsdom", "better-sqlite3"],
experimental: {
/**
* Dead config under Turbopack, which this app builds with: "Turbopack automatically analyzes and
* optimizes imports without requiring this configuration."
*
* Measured rather than assumed - adding the seven Mantine entry points that are missing here
* (charts, dates, dropzone, form, notifications, spotlight, tiptap) produced a client bundle that
* was byte-for-byte identical, 482 files and 18050.7 KiB in both arms, down to the chunk hashes.
* Kept only as a fallback for a webpack build; do not add to it expecting an effect.
*/
optimizePackageImports: ["@mantine/core", "@mantine/hooks", "@tabler/icons-react"],
turbopackFileSystemCacheForBuild: true,
useTypeScriptCli: true,
/**
* Next otherwise loads the module graph for every route at startup. Measured here: 74 route
* bundles and 1,114 files, 24 MiB of source, resident before the first request — and one
* shared 16 MiB subtree behind the API routes that reaches every integration. A dashboard
* serves a handful of routes per session, so preloading the rest is pure cost.
*
* The trade is that the first request to a route pays its module load instead of the boot
* paying for all of them. That suits a long-lived self-hosted server, where boot happens
* once and routes warm up as they are used.
*/
preloadEntriesOnStart: false,
},
turbopack: {
root: path.resolve(import.meta.dirname, "../.."),
resolveAlias:
process.env.NODE_ENV === "development"
? {
"@homarr/tasks": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
"@homarr/websocket": path.resolve(import.meta.dirname, "src/instrumentation-noop.ts"),
}
: {},
// Development runs tasks and WebSocket as separate processes. These aliases
// keep their production-only instrumentation imports out of the dev graph.
resolveAlias: getDevelopmentServiceAliases(),
},
transpilePackages: ["@homarr/ui", "@homarr/notifications", "@homarr/modals", "@homarr/spotlight", "@homarr/widgets"],
images: {
+2 -3
View File
@@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build $([ \"$HOMARR_PROFILING\" = \"true\" ] && echo --profile)",
"build": "HOMARR_VERSION=$(node -p \"require('../../package.json').version\") pnpm with-env next build",
"clean": "git clean -xdf .next .turbo node_modules",
"dev": "pnpm with-env next dev",
"format": "oxfmt .",
@@ -76,14 +76,13 @@
"chroma-js": "catalog:",
"clsx": "catalog:",
"dayjs": "catalog:",
"dompurify": "catalog:",
"dotenv": "catalog:",
"drizzle-orm": "catalog:",
"flag-icons": "catalog:",
"glob": "catalog:",
"isomorphic-dompurify": "catalog:",
"jotai": "catalog:",
"jszip": "catalog:",
"linkedom": "catalog:",
"lucide-react": "catalog:",
"mantine-datatable": "catalog:",
"mantine-react-table": "catalog:",
@@ -4,7 +4,6 @@ import { Box, Stack } from "@mantine/core";
import { useCurrentLayout, useRequiredBoard } from "@homarr/boards/context";
import { WidgetContextMenuProvider } from "~/components/board/items/widget-context-menu-provider";
import { BoardCategorySection } from "~/components/board/sections/category-section";
import { BoardEmptySection } from "~/components/board/sections/empty-section";
import { BoardBackgroundVideo } from "~/components/layout/background";
@@ -20,17 +19,15 @@ export const ClientBoard = () => {
return (
<Box h="100%" pos="relative" data-homarr-dev-benchmark-board>
<BoardBackgroundVideo />
<WidgetContextMenuProvider>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</WidgetContextMenuProvider>
<Stack h="100%">
{fullWidthSortedSections.map((section) =>
section.kind === "empty" ? (
<BoardEmptySection key={`${currentLayoutId}-${section.id}`} section={section} />
) : (
<BoardCategorySection key={`${currentLayoutId}-${section.id}`} section={section} />
),
)}
</Stack>
</Box>
);
};
@@ -1,17 +1,21 @@
import { createHash } from "node:crypto";
import type { JSX, PropsWithChildren } from "react";
import { notFound, redirect } from "next/navigation";
import { AppShellMain } from "@mantine/core";
import { cookies } from "next/headers";
import { TRPCError } from "@trpc/server";
import { getRscUserSettingsAsync } from "@homarr/api/user-server";
import { auth } from "@homarr/auth/next";
import { BoardProvider } from "@homarr/boards/context";
import { EditModeProvider } from "@homarr/boards/edit-mode";
import { dbEnv } from "@homarr/core/infrastructure/db/env";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { v2BetaAnnouncementCookieKey } from "@homarr/definitions";
import { MainHeader } from "~/components/layout/header";
import { MainHeaderContent } from "~/components/layout/header";
import { BoardLogoWithTitle } from "~/components/layout/logo/board-logo";
import { ClientShell } from "~/components/layout/shell";
import { V2BetaDashboardShell } from "~/components/layout/v2-beta-announcement/v2-beta-dashboard-shell";
import { BoardTourGate } from "~/components/onboarding/board-tour-gate";
import { env } from "~/env";
import { getCurrentColorSchemeAsync } from "~/theme/color-scheme";
@@ -42,6 +46,7 @@ export const createBoardLayout = <TParams extends Params>({
}>) => {
const resolvedParams = await params;
const sessionPromise = auth();
const cookiesPromise = cookies();
const initialBoardPromise = getInitialBoard(resolvedParams).then(
(board) => ({ status: "fulfilled", board }) as const,
(error: unknown) => ({ status: "rejected", error }) as const,
@@ -58,11 +63,12 @@ export const createBoardLayout = <TParams extends Params>({
return false;
}
});
const [session, initialBoardResult, colorScheme, shouldRunBoardTour] = await Promise.all([
const [session, initialBoardResult, colorScheme, shouldRunBoardTour, cookieStore] = await Promise.all([
sessionPromise,
initialBoardPromise,
colorSchemePromise,
shouldRunBoardTourPromise,
cookiesPromise,
]);
if (initialBoardResult.status === "rejected") {
const { error } = initialBoardResult;
@@ -83,6 +89,11 @@ export const createBoardLayout = <TParams extends Params>({
throw error;
}
const initialBoard = initialBoardResult.board;
const viewerIdentity = session?.user.id ?? "anonymous";
const viewerHash = createHash("sha256").update(viewerIdentity).digest("hex").slice(0, 16);
const dismissalCookieName = `${v2BetaAnnouncementCookieKey}.${viewerHash}`;
const isAnnouncementDismissed = cookieStore.get(dismissalCookieName)?.value === "dismissed";
const canExportBackup = dbEnv.DRIVER === "better-sqlite3" && Boolean(session?.user.permissions.includes("admin"));
return (
<BoardProvider initialBoard={initialBoard}>
@@ -91,14 +102,20 @@ export const createBoardLayout = <TParams extends Params>({
<BoardMantineProvider defaultColorScheme={colorScheme}>
<CustomCss />
<BoardTourGate enabled={shouldRunBoardTour}>
<ClientShell hasNavigation={false}>
<MainHeader
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
<V2BetaDashboardShell
canExportBackup={canExportBackup}
dismissalCookieName={dismissalCookieName}
initiallyDismissed={isAnnouncementDismissed}
header={
<MainHeaderContent
logo={<BoardLogoWithTitle size="md" hideTitleOnMobile />}
actions={headerActions}
hasNavigation={false}
/>
}
>
{children}
</V2BetaDashboardShell>
</BoardTourGate>
</BoardMantineProvider>
</EditModeProvider>
@@ -40,9 +40,8 @@ import { getScopedI18n } from "@homarr/translation/server";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { createMetaTitle } from "~/metadata";
import { getPackageVersion } from "~/versions/package-reader";
import { getDependenciesAsync, getPackageVersion } from "~/versions/package-reader";
import githubContributors from "@static-data/contributors.json";
import dependencies from "@static-data/dependencies.json";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
import crowdinContributors from "@static-data/translators.json";
import classes from "./about.module.css";
@@ -58,6 +57,7 @@ export async function generateMetadata() {
export default async function AboutPage() {
const t = await getScopedI18n("management.page.about");
const version = getPackageVersion();
const dependencies = await getDependenciesAsync();
return (
<div>
@@ -159,26 +159,34 @@ export default async function AboutPage() {
</AccordionControl>
<AccordionPanel>
<SimpleGrid cols={{ xs: 1, sm: 2, md: 3, lg: 4, xl: 5 }} spacing="md">
{Object.entries(dependencies).map(([name, version]) => (
<UnstyledButton key={name} component="a" href={`https://www.npmjs.com/package/${name}`} target="_blank">
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
{Object.entries(dependencies)
.filter(([, value]) => !value.includes("workspace:"))
.toSorted(([key1], [key2]) => key1.localeCompare(key2))
.map(([name, version]) => (
<UnstyledButton
key={name}
component="a"
href={`https://www.npmjs.com/package/${name}`}
target="_blank"
>
<Card radius="md" p="xs" className={classes.dependencyCard}>
<Group gap="sm" wrap="nowrap">
<ThemeIcon variant="light" size="lg">
<IconPackage size="1.5rem" stroke={1.5} />
</ThemeIcon>
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
<Stack gap={0}>
<Text size="sm" fw="bold" lineClamp={1} title={name}>
{name}
</Text>
<Text size="sm" c="dimmed">
v{version.replace("^", "").replace("~", "")}
</Text>
</Stack>
</Group>
</Card>
</UnstyledButton>
))}
</SimpleGrid>
</AccordionPanel>
</AccordionItem>
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
siteManagerApiKey: IconKey,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -1,18 +1,11 @@
"use client";
import dynamic from "next/dynamic";
import { useComputedColorScheme } from "@mantine/core";
import { ApiReferenceReact } from "@scalar/api-reference-react";
import "@scalar/api-reference-react/style.css";
import "./scalar-theme.css";
// The API reference is a whole documentation SPA reachable only from this one admin
// tab, and it renders nothing useful on the server. Loading it lazily keeps it out
// of the server graph instead of compiling it for an SSR pass that is discarded.
const ApiReferenceReact = dynamic(() => import("@scalar/api-reference-react").then((mod) => mod.ApiReferenceReact), {
ssr: false,
});
interface ScalarApiReferenceProps {
document: object;
}
@@ -1,49 +1,14 @@
"use client";
import { useState } from "react";
import { Alert, Button, Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconAlertTriangle, IconDatabaseExport, IconDownload } from "@tabler/icons-react";
import { Card, Group, Stack, Text, Title } from "@mantine/core";
import { IconDatabaseExport } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "~/components/backup/dangerous-action-confirmation";
import { BackupExportButton } from "~/components/backup";
export const BackupExportCard = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename =
response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (err) {
setError(err instanceof Error ? err.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Card withBorder>
@@ -55,33 +20,7 @@ export const BackupExportCard = () => {
<Text size="sm" c="dimmed">
{t("description")}
</Text>
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
<BackupExportButton />
</Stack>
</Card>
);
@@ -11,10 +11,6 @@ interface Props {
params: Promise<{ kind: string }>;
}
export function generateStaticParams() {
return widgetKinds.map((kind) => ({ kind }));
}
export default async function WidgetPreview(props: Props) {
const { kind } = await props.params;
if (!widgetKinds.includes(kind as WidgetKind)) {
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import crowdinContributors from "@static-data/translators.json";
export const GET = () => {
return NextResponse.json(crowdinContributors);
};
export const dynamic = "force-static";
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import githubContributors from "@static-data/contributors.json";
export const GET = () => {
return NextResponse.json(githubContributors);
};
export const dynamic = "force-static";
@@ -0,0 +1,9 @@
import { NextResponse } from "next/server";
import openCollectiveContributors from "@static-data/opencollective-contributors.json";
export const GET = () => {
return NextResponse.json(openCollectiveContributors);
};
export const dynamic = "force-static";
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { getDependenciesAsync } from "../../../../versions/package-reader";
export const GET = async () => {
const dependencies = await getDependenciesAsync();
return NextResponse.json(dependencies);
};
export const dynamic = "force-static";
@@ -16,13 +16,10 @@ export const GET = async (_request: Request, props: { params: Promise<{ id: stri
});
}
// undici and the DOM lib describe the same web stream with incompatible types, so the value is
// passed straight through.
return new Response(result.image as BodyInit | null, {
return new Response(result.image, {
headers: {
"Cache-Control": "public, max-age=3600, immutable", // Cache for 1 hour
...(result.contentType ? { "Content-Type": result.contentType } : {}),
...(result.contentLength ? { "Content-Length": result.contentLength } : {}),
},
});
};
@@ -14,6 +14,7 @@ import { db } from "@homarr/db";
import { getPackageVersion } from "~/versions/package-reader";
import { extractMcpTools } from "../_extract-tools";
import { serializeMcpToolResult } from "../_serialize-result";
const logger = createLogger({ module: "mcpRoute" });
@@ -203,7 +204,7 @@ const mcpHandler = createMcpHandler(
const input = args && Object.keys(args).length > 0 ? args : undefined;
const result = await procedure(input);
return {
content: [{ type: "text" as const, text: JSON.stringify(result) }],
content: [{ type: "text" as const, text: serializeMcpToolResult(result) }],
};
} catch (error) {
const message = sanitizeErrorMessage(error);
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { serializeMcpToolResult } from "./_serialize-result";
describe("serializeMcpToolResult", () => {
it("serializes objects as JSON strings", () => {
expect(serializeMcpToolResult({ boardId: "1" })).toBe('{"boardId":"1"}');
});
it("serializes primitive values", () => {
expect(serializeMcpToolResult("hello")).toBe('"hello"');
expect(serializeMcpToolResult(42)).toBe("42");
expect(serializeMcpToolResult(true)).toBe("true");
});
it("serializes null as the string null", () => {
expect(serializeMcpToolResult(null)).toBe("null");
});
it("serializes void results as the string null so MCP content text is always a string", () => {
expect(serializeMcpToolResult(undefined)).toBe("null");
});
});
@@ -0,0 +1,3 @@
export function serializeMcpToolResult(result: unknown): string {
return JSON.stringify(result) ?? "null";
}
@@ -1,12 +1,11 @@
import { notFound } from "next/navigation";
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
import DOMPurify from "isomorphic-dompurify";
import { db, eq } from "@homarr/db";
import { medias } from "@homarr/db/schema";
import { looksLikeSvg, sanitizeSvg } from "../svg-purify";
export async function GET(_req: NextRequest, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const image = await db.query.medias.findFirst({
@@ -23,16 +22,17 @@ export async function GET(_req: NextRequest, props: { params: Promise<{ id: stri
let content = new Uint8Array(image.content);
const isSvgMime = image.contentType === "image/svg+xml" || image.contentType === "image/svg";
const isSvgContent = !isSvgMime && looksLikeSvg(content);
if (isSvgMime || isSvgContent) {
// Sanitize SVG content to prevent XSS attacks
if (image.contentType === "image/svg+xml" || image.contentType === "image/svg") {
const svgText = new TextDecoder().decode(content);
const sanitized = sanitizeSvg(svgText);
const sanitized = DOMPurify.sanitize(svgText, {
USE_PROFILES: { svg: true, svgFilters: true },
});
content = new TextEncoder().encode(sanitized);
}
const headers = new Headers();
headers.set("Content-Type", isSvgContent ? "image/svg+xml" : image.contentType);
headers.set("Content-Type", image.contentType);
headers.set("Content-Length", content.length.toString());
headers.set("Content-Security-Policy", "default-src 'none'; style-src 'unsafe-inline'; sandbox");
headers.set("X-Content-Type-Options", "nosniff");
@@ -1,95 +0,0 @@
import { describe, expect, it } from "vitest";
import { looksLikeSvg, purify, sanitizeSvg, svgSanitizeOptions } from "./svg-purify";
describe("SVG sanitization", () => {
it("strips script tags from SVG", () => {
const dirty = '<svg><script>alert("xss")</script><circle r="10"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("circle");
});
it("strips onload event handler", () => {
const dirty = '<svg onload="alert(1)"><rect width="100" height="100"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onload");
});
it("strips data URI in use href", () => {
const dirty = '<svg><use href="data:text/html,<script>alert(1)</script>"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("data:");
});
it("strips foreignObject", () => {
const dirty =
'<svg><foreignObject><body xmlns="http://www.w3.org/1999/xhtml"><script>alert(1)</script></body></foreignObject></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("foreignObject");
});
it("preserves safe SVG content", () => {
const safe = '<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="red"/></svg>';
const clean = sanitizeSvg(safe);
expect(clean).toContain("circle");
expect(clean).toContain('fill="red"');
});
it("uses the same DOMPurify options as production", () => {
const dirty = '<svg><script>alert("xss")</script></svg>';
const wrapped = `<html><body>${dirty}</body></html>`;
expect(sanitizeSvg(dirty)).toBe(purify.sanitize(wrapped, svgSanitizeOptions));
});
it("strips javascript: protocol in href", () => {
const dirty = '<svg><a href="javascript:alert(1)"><text>click</text></a></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips xlink:href with javascript:", () => {
const dirty = '<svg><a xlink:href="javascript:alert(1)"><text>click</text></a></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("javascript:");
});
it("strips style with url() import", () => {
const dirty =
'<svg><style>@import url("https://evil.com/steal.css");</style><rect width="100" height="100"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("@import");
});
it("strips animate with onbegin handler", () => {
const dirty = '<svg><animate onbegin="alert(1)"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toContain("onbegin");
});
it("DOMPurify actually strips dirty payload (guard against no-op shim)", () => {
const dirty = '<svg><script>alert("xss")</script><rect width="1" height="1"/></svg>';
const clean = sanitizeSvg(dirty);
expect(clean).not.toBe(dirty);
expect(clean).not.toContain("<script");
expect(clean).toContain("<rect");
});
it("looksLikeSvg detects SVG content regardless of MIME", () => {
const svg = new TextEncoder().encode('<svg viewBox="0 0 24 24"><path d="M0 0"/></svg>');
const png = new Uint8Array([0x89, 0x50, 0x4e, 0x47]);
expect(looksLikeSvg(svg)).toBe(true);
expect(looksLikeSvg(png)).toBe(false);
});
it("preserves well-formed SVG with self-closing children", () => {
const icon =
'<svg viewBox="0 0 24 24"><path d="M12 2L2 22h20Z" fill="currentColor"/><circle cx="12" cy="8" r="2"/></svg>';
const clean = sanitizeSvg(icon);
expect(clean).toContain("<path");
expect(clean).toContain("<circle");
expect(clean).toContain("</svg>");
expect(clean).not.toContain("<html");
expect(clean).not.toContain("<body");
});
});
@@ -1,99 +0,0 @@
import createDOMPurify from "dompurify";
import { parseHTML } from "linkedom";
const svgSanitizeOptions = {
USE_PROFILES: { svg: true, svgFilters: true },
FORBID_TAGS: ["style"],
};
const LINKEDOM_NODE_PROPS = [
"ownerDocument",
"parentNode",
"nextSibling",
"previousSibling",
"nodeName",
"nodeType",
"childNodes",
] as const;
// ponytail: linkedom stores DOM tree pointers as own value properties; DOMPurify
// resolves them via prototype getters (lookupGetter). Without this shim, sanitize
// is a no-op and XSS payloads pass through unchanged.
const patchLinkedomNodePrototype = (Node: typeof window.Node) => {
for (const prop of LINKEDOM_NODE_PROPS) {
const descriptor = Object.getOwnPropertyDescriptor(Node.prototype, prop);
if (descriptor?.get) continue;
if (descriptor?.value !== undefined) {
delete Node.prototype[prop as keyof Node];
}
Object.defineProperty(Node.prototype, prop, {
configurable: true,
enumerable: true,
get() {
return Object.getOwnPropertyDescriptor(this, prop)?.value ?? null;
},
set(value: unknown) {
Object.defineProperty(this, prop, {
value,
writable: true,
configurable: true,
enumerable: true,
});
},
});
}
};
const createDocument = () => parseHTML("<!doctype html><html><head></head><body></body></html>");
const { window, document, Node } = createDocument();
patchLinkedomNodePrototype(Node);
if (!document.implementation?.createHTMLDocument) {
Object.defineProperty(document, "implementation", {
value: {
createHTMLDocument() {
return createDocument().document;
},
createDocument() {
return createDocument().document;
},
},
});
}
window.NodeFilter ??= {
FILTER_ACCEPT: 1,
FILTER_REJECT: 2,
FILTER_SKIP: 3,
SHOW_ALL: 0xffffffff,
SHOW_ELEMENT: 1,
SHOW_ATTRIBUTE: 2,
SHOW_TEXT: 4,
SHOW_CDATA_SECTION: 8,
SHOW_PROCESSING_INSTRUCTION: 64,
SHOW_COMMENT: 128,
SHOW_DOCUMENT: 0x100,
SHOW_DOCUMENT_TYPE: 0x200,
SHOW_DOCUMENT_FRAGMENT: 0x400,
SHOW_NOTATION: 0x800,
} as typeof window.NodeFilter;
const purify = createDOMPurify(window as unknown as Parameters<typeof createDOMPurify>[0]);
export const sanitizeSvg = (svgText: string) => {
// linkedom's DOMParser omits html/body for fragment-only SVG; DOMPurify needs a body root.
const wrapped = `<html><body>${svgText}</body></html>`;
const sanitized = purify.sanitize(wrapped, svgSanitizeOptions);
const svgMatch = sanitized.match(/<svg[^>]*\/>|<svg[\s\S]*?<\/svg>/i);
return svgMatch ? svgMatch[0] : "";
};
export function looksLikeSvg(content: Uint8Array): boolean {
const head = new TextDecoder().decode(content.subarray(0, 512));
return /<svg[\s>]/i.test(head);
}
export { svgSanitizeOptions, purify };
@@ -0,0 +1,79 @@
"use client";
import { useState } from "react";
import { Alert, Button, Group, Stack } from "@mantine/core";
import { IconAlertTriangle, IconDownload } from "@tabler/icons-react";
import { useScopedI18n } from "@homarr/translation/client";
import { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export const BackupExportButton = () => {
const t = useScopedI18n("management.page.tool.backup.export");
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showConfirm, setShowConfirm] = useState(false);
const handleExport = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch("/api/backup/export");
if (!response.ok) {
const errorMessage = await response.json().then(
(data) => data.error,
() => `Server returned ${response.status}`,
);
throw new Error(errorMessage ?? t("error"));
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const filename =
response.headers.get("Content-Disposition")?.match(/filename="(.+)"/)?.[1] ?? "homarr-backup.zip";
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
setShowConfirm(false);
} catch (cause) {
setError(cause instanceof Error ? cause.message : t("error"));
} finally {
setLoading(false);
}
};
return (
<Stack gap="sm">
{error && (
<Alert color="red" icon={<IconAlertTriangle size={16} />}>
{error}
</Alert>
)}
{showConfirm ? (
<DangerousActionConfirmation
title={t("confirm.title")}
warningTitle={t("confirm.warningTitle")}
warningBody={t("confirm.warningBody")}
typePrompt={t("confirm.typePrompt", { phrase: "I understand" })}
submitLabel={t("confirm.submit")}
submitIcon={<IconDownload size={16} />}
cancelLabel={t("confirm.cancel")}
onConfirm={handleExport}
onCancel={() => setShowConfirm(false)}
disabled={loading}
color="orange"
/>
) : (
<Group>
<Button leftSection={<IconDownload size={16} />} loading={loading} onClick={() => setShowConfirm(true)}>
{t("button")}
</Button>
</Group>
)}
</Stack>
);
};
@@ -1,3 +1,4 @@
export { BackupExportButton } from "./backup-export-button";
export { DatabaseRestoreFlow } from "./database-restore-flow";
export { DangerousActionConfirmation } from "./dangerous-action-confirmation";
export type { BackupAnalysis, RestoreStep } from "./types";
@@ -1,94 +0,0 @@
"use client";
import type { MutableRefObject, PropsWithChildren } from "react";
import { createContext, useCallback, useContext, useMemo, useState } from "react";
import { Menu } from "@mantine/core";
import { useSession } from "@homarr/auth/client";
import { useSettings } from "@homarr/settings";
import type { WidgetDefinition } from "@homarr/widgets/definition";
import type { UseGridstackRefs } from "../sections/gridstack/use-gridstack";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import { WidgetContextMenuDropdown } from "./widget-context-menu";
interface ActiveContextMenu {
item: SectionItem;
definition: WidgetDefinition;
widgetStateRef: MutableRefObject<Record<string, unknown> | null>;
/** Carried from the item's section, which the board-level menu is outside of. */
gridstack: UseGridstackRefs["gridstack"];
x: number;
y: number;
}
interface WidgetContextMenuApi {
/** Null when right-click menus are disabled, which lets items skip their handler. */
open: ((menu: ActiveContextMenu) => void) | null;
}
const WidgetContextMenuContext = createContext<WidgetContextMenuApi>({ open: null });
export const useWidgetContextMenu = () => useContext(WidgetContextMenuContext);
/**
* One context menu for the whole board instead of one per widget.
*
* Mantine's `Menu` mounts `Popover` + `PopoverDropdown` + several context providers
* even while closed, so a menu per item cost ~13 fibers each for something almost
* never opened — measured at 364 of 3,918 fibers (9%) on a 28-widget board, growing
* with item count. Items now only carry a `contextmenu` listener and this single menu
* is anchored to the cursor.
*/
export const WidgetContextMenuProvider = ({ children }: PropsWithChildren) => {
const { data: session } = useSession();
const settings = useSettings();
const [active, setActive] = useState<ActiveContextMenu | null>(null);
const isEnabled = Boolean(session) && settings.enableRightClickOnWidgets;
const open = useCallback((menu: ActiveContextMenu) => setActive(menu), []);
const api = useMemo<WidgetContextMenuApi>(() => ({ open: isEnabled ? open : null }), [isEnabled, open]);
return (
<WidgetContextMenuContext value={api}>
{children}
{active && (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened
onChange={(opened) => {
if (!opened) setActive(null);
}}
>
{/* Zero-size anchor at the cursor so Mantine positions the dropdown there. */}
<Menu.Target>
<div
style={{
position: "fixed",
left: active.x,
top: active.y,
width: 1,
height: 1,
pointerEvents: "none",
}}
/>
</Menu.Target>
<Menu.Dropdown>
<WidgetContextMenuDropdown
item={active.item}
definition={active.definition}
widgetStateRef={active.widgetStateRef}
settings={settings}
gridstack={active.gridstack}
/>
</Menu.Dropdown>
</Menu>
)}
</WidgetContextMenuContext>
);
};
@@ -1,7 +1,7 @@
"use client";
import type { MutableRefObject, ReactNode } from "react";
import { useCallback, useMemo, useRef } from "react";
import { useCallback, useMemo, useState } from "react";
import { Group, Loader, Menu, Switch, Text, Tooltip } from "@mantine/core";
import {
IconAlertTriangle,
@@ -16,12 +16,12 @@ import type { QueryClient, QueryKey } from "@tanstack/react-query";
import { partialMatchKey, useIsFetching, useQueryClient } from "@tanstack/react-query";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { useRequiredBoard } from "@homarr/boards/context";
import { useEditMode } from "@homarr/boards/edit-mode";
import { useTimeAgo } from "@homarr/common";
import { useConfirmModal, useModalAction } from "@homarr/modals";
import type { useSettings } from "@homarr/settings";
import { useSettings } from "@homarr/settings";
import { translateIfNecessary } from "@homarr/translation";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { TranslationFunction } from "@homarr/translation";
@@ -30,9 +30,7 @@ import { getWidgetQueryKeys } from "@homarr/widgets/definition";
import { reduceWidgetOptionsWithDefinition } from "@homarr/widgets/manifest";
import type { SectionItem } from "~/app/[locale]/boards/_types";
import type { UseGridstackRefs } from "../sections/gridstack/use-gridstack";
import { useSectionContext } from "../sections/section-context";
import { useWidgetContextMenu } from "./widget-context-menu-provider";
import { useItemActions } from "./item-actions";
import { LazyItemMoveModal, preloadItemMoveModal } from "./lazy-item-move-modal";
import { LazyWidgetEditModal, preloadWidgetEditModal } from "./lazy-widget-edit-modal";
@@ -44,68 +42,62 @@ interface WidgetContextMenuProps {
children: ReactNode;
}
const LONG_PRESS_DELAY_MS = 500;
/**
* Per-item trigger only. The menu itself lives once at board level — see
* WidgetContextMenuProvider for why.
*/
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { open } = useWidgetContextMenu();
const { gridstack } = useSectionContext().refs;
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
const cancelLongPress = useCallback(() => {
if (longPressTimer.current) {
clearTimeout(longPressTimer.current);
longPressTimer.current = null;
}
}, []);
if (!open) return <>{children}</>;
if (!session || !settings.enableRightClickOnWidgets) return <>{children}</>;
const isLinkWidget = item.kind === "app" || item.kind === "bookmarks";
// Keep the Homarr menu in edit mode so these widgets can still be edited or removed.
if (isLinkWidget && !isEditMode) return <>{children}</>;
return (
// display:contents keeps GridStack's layout maths seeing the original child box.
<div
style={{ display: "contents" }}
onContextMenu={(event) => {
event.preventDefault();
open({ item, definition, widgetStateRef, gridstack, x: event.clientX, y: event.clientY });
}}
onTouchStart={(event) => {
const touch = event.touches[0];
if (!touch) return;
const { clientX, clientY } = touch;
longPressTimer.current = setTimeout(
() => open({ item, definition, widgetStateRef, gridstack, x: clientX, y: clientY }),
LONG_PRESS_DELAY_MS,
);
}}
onTouchEnd={cancelLongPress}
onTouchMove={cancelLongPress}
onTouchCancel={cancelLongPress}
>
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
{children}
</div>
</WidgetContextMenuInner>
);
};
const WidgetContextMenuInner = ({
item,
definition,
widgetStateRef,
children,
settings,
}: WidgetContextMenuProps & { settings: ReturnType<typeof useSettings> }) => {
const [isMenuOpen, setIsMenuOpen] = useState(false);
return (
<Menu
shadow="md"
width={300}
closeOnItemClick={false}
position="right-start"
offset={4}
opened={isMenuOpen}
onChange={setIsMenuOpen}
>
<Menu.ContextMenu>{children}</Menu.ContextMenu>
<Menu.Dropdown>
{isMenuOpen && (
<WidgetContextMenuDropdown
item={item}
definition={definition}
widgetStateRef={widgetStateRef}
settings={settings}
/>
)}
</Menu.Dropdown>
</Menu>
);
};
type WidgetContextMenuDropdownProps = Omit<WidgetContextMenuProps, "children"> & {
settings: ReturnType<typeof useSettings>;
/**
* Passed in rather than read from SectionContext: the menu is mounted once at board
* level, outside any section, while the item that opened it is inside one.
*/
gridstack: UseGridstackRefs["gridstack"];
};
export const WidgetContextMenuDropdown = ({
item,
definition,
widgetStateRef,
settings,
gridstack,
}: WidgetContextMenuDropdownProps) => {
const WidgetContextMenuDropdown = ({ item, definition, widgetStateRef, settings }: WidgetContextMenuDropdownProps) => {
const [isEditMode] = useEditMode();
const board = useRequiredBoard();
const tItem = useScopedI18n("item");
@@ -122,6 +114,7 @@ export const WidgetContextMenuDropdown = ({
enabled: hasSupportedIntegrations,
});
const { mutate: saveBoard } = clientApi.board.saveBoard.useMutation();
const { gridstack } = useSectionContext().refs;
const queryClient = useQueryClient();
const widgetQueryKeys = useMemo(() => getWidgetQueryKeys(definition, item.kind), [definition, item.kind]);
@@ -13,8 +13,10 @@
transform 150ms ease;
}
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover) .wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within) .wrapper {
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):hover)
.wrapper,
:global(.grid-stack-item[data-type="item"]:not([data-kind="app"]):focus-within)
.wrapper {
opacity: 1;
transform: translateY(0);
}
@@ -301,6 +301,8 @@ interface UseCssVariableConfiguration {
isDynamic: boolean;
}
const minimumResizeWidthDelta = 20;
/**
* This hook is used to configure the css variables for the gridstack
* Those css variables are used to define the size of the gridstack items
@@ -319,9 +321,30 @@ const useCssVariableConfiguration = ({
columnCount,
isDynamic,
}: UseCssVariableConfiguration) => {
const prevSizeRef = useRef<{ width: number; height: number } | null>(null);
const onResize = useCallback(() => {
if (!wrapperRef.current) return;
if (!gridRef.current) return;
const currentWidth = wrapperRef.current.clientWidth;
const currentHeight = wrapperRef.current.clientHeight;
const prevSize = prevSizeRef.current;
// Only skip recalculation when the width change is insignificant
// and, for dynamic sections, the height is unchanged. This prevents infinite
// feedback loops when the vertical scrollbar appears/disappears, causing
// clientWidth to oscillate by ~15px, while still reacting to height changes.
if (
prevSize !== null &&
Math.abs(currentWidth - prevSize.width) < minimumResizeWidthDelta &&
(!isDynamic || currentHeight === prevSize.height)
) {
return;
}
prevSizeRef.current = { width: currentWidth, height: currentHeight };
handleResizeChange(
wrapperRef.current,
gridRef.current,
@@ -36,6 +36,8 @@ export const categoryTranslationKeys: Record<string, string> = {
documents: "integration.category.documents",
mediaLibrary: "integration.category.mediaLibrary",
uptimeMonitoring: "integration.category.uptimeMonitoring",
unifiOs: "integration.category.unifiOs",
unifiSiteManager: "integration.category.unifiSiteManager",
};
export interface IntegrationGridItem {
+25 -19
View File
@@ -19,25 +19,31 @@ interface Props {
export const MainHeader = ({ logo, actions, hasNavigation = true }: Props) => {
return (
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
<Group h="100%" gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
<MainHeaderContent logo={logo} actions={actions} hasNavigation={hasNavigation} />
</AppShellHeader>
);
};
export const MainHeaderContent = ({ logo, actions, hasNavigation = true }: Props) => (
<>
<Group h={60} gap="xl" px="md" justify="apart" wrap="nowrap">
<Group h="100%" align="center" style={{ flex: 1 }} wrap="nowrap">
{hasNavigation && <ClientBurger />}
<UnstyledButton component={Link} href="/">
{logo ?? <HomarrLogoWithTitle size="md" />}
</UnstyledButton>
</Group>
<TourTarget id="board-search">
<DesktopSearchInput />
</TourTarget>
<Group h="100%" align="center" justify="end" style={{ flex: 1 }} wrap="nowrap">
{actions}
<MobileSearchButton />
<TourTarget id="board-user-menu">
<UserButton />
</TourTarget>
</Group>
</Group>
<LazySpotlight />
</>
);
+3 -1
View File
@@ -10,11 +10,13 @@ import { navigationCollapsedAtom } from "./header/burger";
interface ClientShellProps {
hasHeader?: boolean;
hasNavigation?: boolean;
headerHeight?: number;
}
export const ClientShell = ({
hasHeader = true,
hasNavigation = true,
headerHeight = 60,
children,
}: PropsWithChildren<ClientShellProps>) => {
const collapsed = useAtomValue(navigationCollapsedAtom);
@@ -23,7 +25,7 @@ export const ClientShell = ({
return (
<AppShell
{...backgroundProps}
header={hasHeader ? { height: 60 } : undefined}
header={hasHeader ? { height: headerHeight } : undefined}
navbar={
hasNavigation
? {
@@ -0,0 +1,7 @@
export const v2BetaBlogUrl = "https://v2.preview.homarr.dev/blog/2026/09/03/homarr-2.0";
export const v2BetaDocsUrl = "https://v2.preview.homarr.dev/docs/getting-started/";
export const v2BetaPreviewUrl = "https://app-v2.preview.homarr.dev";
export const v2BetaWorkshopUrl = "https://v2.preview.homarr.dev/workshop";
export const v2BetaFeedbackUrl = "https://github.com/homarr-labs/homarr/issues/new?template=v2_beta_feedback.yml";
export const v2BetaDiscussionUrl = "https://github.com/homarr-labs/homarr/issues/6600";
export const discordInviteUrl = "https://discord.com/invite/aCsmEV5RgA";
@@ -0,0 +1,94 @@
.banner {
position: relative;
isolation: isolate;
height: 52px;
overflow: hidden;
color: var(--mantine-color-white);
background: var(--mantine-color-red-9);
border-bottom: 1px solid rgb(255 255 255 / 18%);
}
.banner::after {
position: absolute;
z-index: -1;
right: -0.35rem;
bottom: -1.75rem;
color: rgb(255 255 255 / 9%);
font-size: 5.5rem;
font-weight: 900;
line-height: 1;
content: "v2";
}
.copy {
min-width: 0;
}
.subtitle {
opacity: 0.86;
}
.modalHero {
position: relative;
isolation: isolate;
overflow: hidden;
color: var(--mantine-color-white);
background: var(--mantine-color-red-9);
}
.modalHero::after {
position: absolute;
z-index: -1;
right: -0.75rem;
bottom: -1.5rem;
color: rgb(255 255 255 / 8%);
font-size: 12rem;
font-weight: 900;
line-height: 1;
content: "v2";
}
.featureCard {
height: 100%;
background: light-dark(var(--mantine-color-white), var(--mantine-color-dark-7));
}
.diffViewer {
overflow: hidden;
}
.diffHeader {
background: light-dark(var(--mantine-color-gray-1), var(--mantine-color-dark-7));
border-bottom: 1px solid var(--mantine-color-default-border);
}
.diffCode {
margin: 0;
padding-block: var(--mantine-spacing-sm);
overflow-x: auto;
color: light-dark(var(--mantine-color-dark-8), var(--mantine-color-gray-2));
background: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-9));
font-family: var(--mantine-font-family-monospace);
font-size: var(--mantine-font-size-sm);
line-height: 1.6;
}
.diffLine_context,
.diffLine_added,
.diffLine_removed {
display: block;
width: max-content;
min-width: 100%;
padding-inline: var(--mantine-spacing-md);
white-space: pre;
}
.diffLine_added {
color: light-dark(var(--mantine-color-green-9), var(--mantine-color-green-3));
background: light-dark(var(--mantine-color-green-0), rgb(47 158 68 / 15%));
}
.diffLine_removed {
color: light-dark(var(--mantine-color-red-9), var(--mantine-color-red-3));
background: light-dark(var(--mantine-color-red-0), rgb(250 82 82 / 15%));
}
@@ -0,0 +1,377 @@
"use client";
import type { ReactNode } from "react";
import {
ActionIcon,
Accordion,
Alert,
Anchor,
Box,
Button,
getDefaultZIndex,
Group,
Paper,
SimpleGrid,
Stack,
Text,
ThemeIcon,
Title,
} from "@mantine/core";
import {
IconArrowsMove,
IconBook2,
IconBrandCss3,
IconBrandDiscord,
IconBrandGithub,
IconDatabaseExport,
IconExternalLink,
IconHeartHandshake,
IconRobot,
IconRocket,
IconTerminal2,
IconWand,
IconX,
} from "@tabler/icons-react";
import { setClientCookie } from "@homarr/common";
import { createModal, useModalAction } from "@homarr/modals";
import { BackupExportButton } from "~/components/backup";
import {
discordInviteUrl,
v2BetaBlogUrl,
v2BetaDiscussionUrl,
v2BetaDocsUrl,
v2BetaFeedbackUrl,
v2BetaPreviewUrl,
v2BetaWorkshopUrl,
} from "./constants";
import classes from "./v2-beta-announcement.module.css";
const diffLineClassNames = {
added: classes.diffLine_added,
context: classes.diffLine_context,
removed: classes.diffLine_removed,
} as const;
const composeDiffLines = [
{ kind: "context", value: " services:" },
{ kind: "context", value: " homarr:" },
{ kind: "added", value: "+ image: ghcr.io/homarr-labs/homarr-test:v2" },
{ kind: "removed", value: "- image: ghcr.io/homarr-labs/homarr:latest" },
{ kind: "context", value: " ports:" },
{ kind: "context", value: ' - "7575:7575"' },
{ kind: "context", value: " volumes:" },
{ kind: "context", value: " - homarr-v2:/appdata" },
{ kind: "context", value: " environment:" },
{ kind: "context", value: " SECRET_ENCRYPTION_KEY: <SECRET_KEY>" },
{ kind: "added", value: "+ WORKSHOP_API_URL: https://v2.preview.homarr.dev/ # Only during the beta" },
{ kind: "context", value: "" },
{ kind: "context", value: " volumes:" },
{ kind: "context", value: " homarr-v2:" },
] as const;
interface V2BetaAnnouncementProps {
canExportBackup: boolean;
dismissalCookieName: string;
onDismiss: () => void;
}
export const V2BetaAnnouncement = ({ canExportBackup, dismissalCookieName, onDismiss }: V2BetaAnnouncementProps) => {
const { openModal } = useModalAction(V2BetaAnnouncementModal);
const dismiss = () => {
setClientCookie(dismissalCookieName, "dismissed", {
maxAge: 60 * 60 * 24 * 7,
path: "/",
sameSite: "lax",
secure: window.location.protocol === "https:",
});
onDismiss();
};
return (
<Box component="aside" className={classes.banner} aria-label="Homarr v2 beta announcement">
<Group h="100%" justify="space-between" gap="sm" wrap="nowrap" px="md">
<Box className={classes.copy}>
<Text fw={750} size="sm" truncate>
We&apos;ve been cooking, Homarr v2 beta is here
</Text>
<Text className={classes.subtitle} visibleFrom="sm" size="xs" truncate>
Custom Widgets &amp; CSS workshop, new drag-and-drop, new container system, side-bars are back, new widgets
and so much more, come check it out and help us make v2 ready for everyone
</Text>
</Box>
<Group gap={6} wrap="nowrap">
<Button variant="white" color="red" size="compact-sm" onClick={() => openModal({ canExportBackup })}>
Explore v2
</Button>
<ActionIcon variant="subtle" color="white" onClick={dismiss} aria-label="Dismiss the v2 beta announcement">
<IconX size={18} />
</ActionIcon>
</Group>
</Group>
</Box>
);
};
interface V2BetaAnnouncementModalProps {
canExportBackup: boolean;
}
const V2BetaAnnouncementModal = createModal<V2BetaAnnouncementModalProps>(({ innerProps }) => {
return (
<Stack gap="lg">
<Paper className={classes.modalHero} p={{ base: "lg", sm: "xl" }} radius="md">
<Stack gap="sm" maw={760}>
<Title order={2}>Help us get Homarr v2 ready</Title>
<Text>
We&apos;ve been working on Homarr&apos;s v2 update for a while now. There is no fixed release date yet, it
will be ready when it&apos;s ready. We{" "}
<Text span inherit fw={700}>
need your help
</Text>{" "}
and feedback to help us become confident enough to release it for everyone.
</Text>
</Stack>
</Paper>
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
<FeatureCard icon={<IconBrandCss3 size={21} />} title="Workshop">
Share and discover Custom Widgets and CSS made by the Homarr community.
</FeatureCard>
<FeatureCard icon={<IconArrowsMove size={21} />} title="Complete drag-and-drop rewrite">
Move, resize, multi-select, and arrange your dashboard with a board editor rebuilt from the ground up.
</FeatureCard>
<FeatureCard icon={<IconWand size={21} />} title="Custom Widgets v2">
We think you can make almost any widget with live previews, API requests, options, actions, and validation.
</FeatureCard>
<FeatureCard icon={<IconRobot size={21} />} title="Assistant">
Assistant can directly make a Custom Widget for you. Try giving it the API docs for your latest selfhosted
tool and asking it to build a custom widget for it.
</FeatureCard>
</SimpleGrid>
<Accordion variant="separated" radius="md">
<Accordion.Item value="installation">
<Accordion.Control
icon={
<ThemeIcon variant="light" color="red" radius="md" size="lg">
<IconTerminal2 size={20} />
</ThemeIcon>
}
aria-label="Test Homarr v2 on your server"
>
<Box>
<Text fw={750}>Test Homarr v2 on your server</Text>
<Text c="dimmed" size="xs">
Back up this instance, then update your Docker Compose file.
</Text>
</Box>
</Accordion.Control>
<Accordion.Panel>
<V2BetaInstallInstructions canExportBackup={innerProps.canExportBackup} />
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Alert variant="light" color="red" icon={<IconHeartHandshake size={22} />} title="Your testing is essential">
<Text size="sm">
Everyone uses Homarr in a different way. If anything breaks or does not feel right, the easiest way to get it
fixed as soon as possible is to{" "}
<Anchor
href={discordInviteUrl}
target="_blank"
rel="noopener noreferrer"
c="inherit"
fz="sm"
fw={700}
underline="always"
>
tell us about it on Discord
</Anchor>{" "}
in our dedicated v2 channel. You can also{" "}
<Anchor
href={v2BetaFeedbackUrl}
target="_blank"
rel="noopener noreferrer"
c="inherit"
fz="sm"
fw={700}
underline="always"
>
open a dedicated GitHub issue
</Anchor>
{"."} Every report matters.
</Text>
</Alert>
<SimpleGrid cols={{ base: 1, sm: 3 }} spacing="sm">
<Button
component="a"
href={v2BetaPreviewUrl}
target="_blank"
rel="noopener noreferrer"
size="md"
leftSection={<IconRocket size={18} />}
>
Try the hosted preview
</Button>
<Button
component="a"
href={v2BetaWorkshopUrl}
target="_blank"
rel="noopener noreferrer"
variant="light"
size="md"
leftSection={<IconBrandCss3 size={18} />}
>
Open Workshop
</Button>
<Button
component="a"
href={v2BetaDocsUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
leftSection={<IconBook2 size={18} />}
>
Read the v2 docs
</Button>
<Button
component="a"
href={v2BetaBlogUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
rightSection={<IconExternalLink size={16} />}
>
See everything new
</Button>
<Button
component="a"
href={discordInviteUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
leftSection={<IconBrandDiscord size={18} />}
>
Join us on Discord
</Button>
<Button
component="a"
href={v2BetaDiscussionUrl}
target="_blank"
rel="noopener noreferrer"
variant="default"
size="md"
rightSection={<IconExternalLink size={15} />}
>
Follow v2 development
</Button>
</SimpleGrid>
<Button
component="a"
href={v2BetaFeedbackUrl}
target="_blank"
rel="noopener noreferrer"
variant="light"
color="red"
size="md"
leftSection={<IconBrandGithub size={18} />}
>
Report anything that breaks or feels wrong
</Button>
</Stack>
);
}).withOptions({
defaultTitle: "Homarr v2 beta",
size: 1000,
yOffset: 128,
zIndex: getDefaultZIndex("modal") + 2,
});
interface V2BetaInstallInstructionsProps {
canExportBackup: boolean;
}
const V2BetaInstallInstructions = ({ canExportBackup }: V2BetaInstallInstructionsProps) => (
<Stack gap="lg">
{canExportBackup ? (
<Paper withBorder p="md" radius="md">
<Stack gap="sm">
<Group gap="sm">
<IconDatabaseExport size={22} />
<Title order={4}>Back up this Homarr instance</Title>
</Group>
<Text c="dimmed" size="sm">
Download a full backup before testing v2. The archive contains sensitive data, so keep it somewhere safe.
</Text>
<BackupExportButton />
</Stack>
</Paper>
) : (
<Alert color="blue" title="Back up your database before continuing">
Direct backup downloads are available here to Homarr administrators using SQLite. If you use MySQL or
PostgreSQL, back up the database with its own tools before testing v2.
</Alert>
)}
<Stack gap="xs">
<Title order={3}>Update your Docker Compose file</Title>
<Text c="dimmed" size="sm">
Replace the current image and add the beta-only Workshop URL. Use the example below as a starting point.
</Text>
</Stack>
<Paper withBorder radius="md" className={classes.diffViewer}>
<Group gap="xs" px="md" py="sm" className={classes.diffHeader}>
<IconTerminal2 size={17} />
<Text fw={700} size="sm">
docker-compose.yml
</Text>
</Group>
<Box component="pre" className={classes.diffCode} aria-label="Docker Compose changes for Homarr v2 beta">
{composeDiffLines.map((line, index) => (
<Box component="span" className={diffLineClassNames[line.kind]} key={`${line.kind}-${index}`}>
{line.value || " "}
</Box>
))}
</Box>
</Paper>
<Text c="dimmed" size="xs">
Replace <code>&lt;SECRET_KEY&gt;</code> with your existing encryption key. The Workshop URL is only required while
the beta uses the preview Workshop.
</Text>
</Stack>
);
interface FeatureCardProps {
icon: ReactNode;
title: string;
children: ReactNode;
}
const FeatureCard = ({ icon, title, children }: FeatureCardProps) => (
<Paper className={classes.featureCard} withBorder p="md" radius="md">
<Group gap="sm" align="flex-start" wrap="nowrap">
<ThemeIcon variant="light" color="red" radius="md" size="lg">
{icon}
</ThemeIcon>
<Box>
<Text fw={750} size="sm">
{title}
</Text>
<Text c="dimmed" size="sm">
{children}
</Text>
</Box>
</Group>
</Paper>
);
@@ -0,0 +1,42 @@
"use client";
import type { PropsWithChildren, ReactNode } from "react";
import { useState } from "react";
import { AppShellHeader, AppShellMain } from "@mantine/core";
import { ClientShell } from "../shell";
import { V2BetaAnnouncement } from "./v2-beta-announcement";
interface V2BetaDashboardShellProps {
canExportBackup: boolean;
dismissalCookieName: string;
initiallyDismissed: boolean;
header: ReactNode;
}
export const V2BetaDashboardShell = ({
canExportBackup,
dismissalCookieName,
initiallyDismissed,
header,
children,
}: PropsWithChildren<V2BetaDashboardShellProps>) => {
const [isAnnouncementVisible, setIsAnnouncementVisible] = useState(!initiallyDismissed);
const headerHeight = isAnnouncementVisible ? 112 : 60;
return (
<ClientShell hasNavigation={false} headerHeight={headerHeight}>
<AppShellHeader maw="100vw" zIndex={201} style={{ overflowX: "hidden" }}>
{isAnnouncementVisible && (
<V2BetaAnnouncement
canExportBackup={canExportBackup}
dismissalCookieName={dismissalCookieName}
onDismiss={() => setIsAnnouncementVisible(false)}
/>
)}
{header}
</AppShellHeader>
<AppShellMain>{children}</AppShellMain>
</ClientShell>
);
};
@@ -1,3 +1,63 @@
import fsPromises from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
import { glob } from "glob";
import { parse as parseYaml } from "yaml";
import z from "zod";
import packageJson from "../../../../package.json";
export const getPackageVersion = () => packageJson.version;
export const getDependenciesAsync = async (): Promise<PackageJsonDependencies> => {
const pathNames = await glob("**/package.json", {
ignore: "**/node_modules/**",
cwd: "../../",
absolute: true,
});
const packageContents = await Promise.all(pathNames.map(async (path) => await fsPromises.readFile(path, "utf-8")));
const packageDependencies = packageContents
.map((packageContent) => (JSON.parse(packageContent) as PackageJson).dependencies)
.filter((dependencies) => dependencies !== undefined);
const catalog = await parseDependencyCatalogsAsync();
let dependencies = {};
for (const dependenciesOfPackage of packageDependencies) {
const resolvedDependencies = Object.entries(dependenciesOfPackage).map(([name, version]) => {
const catalogVersion = catalog.get(name);
return [name, catalogVersion ?? version] as const;
});
dependencies = { ...dependencies, ...Object.fromEntries(resolvedDependencies) };
}
return dependencies;
};
type DependencyCatalog = Map<string, string>;
const parseDependencyCatalogsAsync = async (): Promise<DependencyCatalog> => {
const currentDir = path.dirname(fileURLToPath(import.meta.url));
const workspaceConfigPath = path.join(currentDir, "..", "..", "..", "..", "pnpm-workspace.yaml");
try {
const workspaceConfigContent = await fsPromises.readFile(workspaceConfigPath, "utf-8");
const workspaceConfig: unknown = parseYaml(workspaceConfigContent);
const parseResult = workspaceConfigCatalogSchema.parse(workspaceConfig);
return new Map(Object.entries(parseResult.catalog ?? {}));
} catch (error) {
// The workspace config is absent in Docker (only the runner stage is shipped),
// so a missing file is expected. Surface any other failure (e.g. malformed YAML).
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
return new Map();
}
throw error;
}
};
const workspaceConfigCatalogSchema = z.object({
catalog: z.record(z.string(), z.string()).optional(),
});
export type PackageJsonDependencies = Record<string, string>;
interface PackageJson {
dependencies: PackageJsonDependencies | undefined;
}
-136
View File
@@ -1,136 +0,0 @@
# Dashboard performance in Chrome
Measured on the busiest board of a restored real backup: 28 items, 16 widget kinds.
## A systemic Mantine cost, found in five places
**Mantine overlay components mount their full machinery regardless of `disabled`.** A `Tooltip`,
`HoverCard` or `Popover` that can never open still creates a `Popover`, a `PopoverTarget` and a
`PopoverDropdown`. `disabled` suppresses *display*, not *cost* — so avoiding it means deciding before
rendering, not passing a prop.
That single fact accounts for five separate findings, three of them measured:
| widget | before | after |
| --- | --- | --- |
| **mediaReleases** | 340 components / 8,387 fibers | **170 / 8,057** (−50% components) |
| **calendar** | 171 components / 1,195 fibers | **75 / 907** (−56% components, −24% fibers) |
| **beszelSystemTable** | 36 components / 1,286 fibers | **18 / 1,230** (−50% components) |
| downloads, mediaRequests, app | unchanged | unchanged — their overlays are functional |
- **calendar** mounted a `HoverCard` per day cell. A month grid is ~42 cells and `disabled` is
`isEditMode || eventsForDate.length === 0`, so most days — and in edit mode *every* day — mounted
three components for a card that can never open. 126 of its 171 components, in a widget with only
251 DOM nodes.
- **OverflowBadge** (shared UI in `packages/ui`) mounted a `Popover` and `Popover.Dropdown` even when
nothing overflowed, which is the common case. Used by media-releases, releases, the docker table and
the widget inputs, so it repeated per row wherever a list renders it. This is where the entire
media-releases win came from.
- **beszel disk-usage** mounted a `HoverCard` per table row for systems reporting no filesystems —
it already fell back to a plain bar inside its own target, so the overlay existed to render nothing.
### Two remount bugs
A component declared inside a render body is a **new type every render**, so React unmounts and
remounts its subtree instead of updating it.
- `beszelSystemTable` declared `PercentCell` inline and used it for three columns, while the widget
re-renders ~1×/second from its live subscription — three cell subtrees per system rebuilt every
second. Hoisted and memoised.
- A drag handle was passed to a child as `handle={Handle}` while its parent re-renders every frame of
a drag, so the handle was rebuilt continuously mid-drag. Stabilised with `useMemo`.
### The audit that found them
`scripts/benchmarks/audit-widget-render.mjs` scans every widget and ranks findings by whether they sit
inside a `.map()`, since those multiply by row count. All 54 findings were triaged:
| count | pattern | outcome |
| --- | --- | --- |
| 12 | overlay-per-row | **5 fixed**; the other 7 are functional tooltips with real labels — nothing is disabled to skip, and swapping them for native `title` would change UX, not just cost |
| 11 | index-key | **all safe.** Either static text splits (`description.split("\n")`) or stateless read-only rows from user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts |
| 2 | nested-component | **both fixed** |
| 29 | inline-style-per-row | **left alone.** A fresh object per row only defeats memoisation if the child is memoised, and these are not. 29 edits without a measurement is churn |
### Off-screen rendering
`mediaReleases` rendered **80 cards into a 539 px tile whose content is 7,206 px** — 7 visible, 73
off-screen, each still fetching and decoding a poster and a full-tile backdrop. Decoded bitmaps are
far larger in memory than their files, which is what made this expensive.
Fixed with `content-visibility: auto` + `contain-intrinsic-size`, plus `loading="lazy"`. Three repeats
per side:
| metric | before | after |
| --- | --- | --- |
| image requests | 135, 135, 135 | **55, 55, 56** (−59%) |
| image bytes | 12.27 MiB | **8.41 MiB** (−31%) |
| layout objects | 8346, 7815, 7815 | **5117, 4696, 4636** (−41%) |
Extracted to `offscreenRowStyle()` and applied to the other data-driven lists (rssFeed, notifications,
calendar events, media-requests). Applied to 5 of 26 `ScrollArea` widgets deliberately:
`content-visibility` costs containment work and can be wrong on self-measuring content like charts.
### Two metrics that cannot be used, and one claim withdrawn
**Chrome's board-level `JSEventListeners` is unusable here.** Across six runs of identical code it read
7905, 8000, 8166, 8179, 10360 and 17114. Its `Nodes` counter has the same problem — 23,072 on one run
against ~8,850 on the others — because it counts detached nodes awaiting collection, while
"elements under body" stayed at 6,691–6,726 throughout. Only per-widget component and fiber counts are
stable enough to compare, so those are what is reported above.
**The media-releases tooltip fix measured zero**, and is reported as such. Every release on the test
board has a description and the option is on, so all 80 tooltips are legitimately enabled and none are
skipped. The mechanism is correct; this board is not the case it helps. The −50% for that widget came
entirely from OverflowBadge.
### Idle churn is legitimate, and an earlier claim here was wrong
This description previously said ~80% of idle React commits entered at Next's `HeadManagerContext` and
that the App Router re-rendered once a second. **Both were false.** A 3-item board commits **0.0**
times per minute against 76.0 for the 28-item board — if the router re-rendered on a timer, every board
would churn. The original attribution was a tooling artefact: `fiber.actualDuration` is only populated
by a React *profiling* build, so on production every fiber fails the "did it re-render" test and the
tables come back empty, which reads as "nothing" rather than "this build cannot tell you".
Attributed properly, three Beszel widgets are 82% of idle churn — but the real rate is **1.22 commits
per second** for the whole board at **13.3 DOM mutations per commit**. That is three live-monitoring
widgets refreshing about once a second, which is what they are for. A coalescing fix measured *exactly
zero* (55 commits both sides) and was reverted.
## Verification of the off-screen changes, and one instrument that lied
The off-screen work was committed before it was measured. Verified afterwards on two containers
restored from identical data, `maxopt` against `client3`:
| metric | before | after |
| --- | --- | --- |
| image requests | 138, 135 | **22, 22** (−84%) |
| image bytes (declared) | 12.27 MiB | **0.03 MiB** |
| layout objects | 11611, 7836 | **3694, 3705** (−53%) |
**A −99.8% drop in image bytes is not believable, so it was checked rather than reported.** Three
checks, and the first two were bad instruments:
1. Counting decoded images with `getBoundingClientRect()` reported **80/80 loaded in both arms** — but
reading geometry forces layout, which forces `content-visibility` subtrees to render, which triggers
exactly the image loads the check was looking for. The instrument caused the result.
2. Screenshotting the widgets produced **byte-identical images in both arms** (3,372 bytes for
media-releases, 1,580 for media-requests), because this environment shows an onboarding overlay and
media-requests renders blank with unreachable integrations. Useful only as evidence of *no visual
difference*, not as evidence images render.
3. Counting `img.complete && img.naturalWidth > 0`, which reads no geometry, gave the answer:
| arm | images decoded | image responses |
| --- | --- | --- |
| before | **80 / 80** | 138 |
| after | **80 / 80** | **25** |
**Everything still displays.** The 80 `<img>` in media-releases are integration icons that share URLs,
so the browser deduplicates them to ~25 unique fetches in both arms; the ~113 requests avoided are
other widgets' off-screen artwork, chiefly media-requests' full-size per-card backdrops. That also
explains the byte figure: those backdrops are where the 12.27 MiB came from.
One limit worth stating: media-requests renders blank here because its integrations are unreachable, so
its *visible-card* behaviour after the change is not verified. Media-releases is, at 80/80 decoded.
-122
View File
@@ -1,122 +0,0 @@
# Methodology, and the traps that produced wrong answers
Every wrong number in this investigation came from an instrument, not from the application. They are
recorded here because each one looked plausible at the time, and several survived long enough to be
published before being caught.
## The harness
`scripts/benchmarks/stress-restore.mts` boots a fresh container, restores a supplied backup
**through the onboarding restore UI** (including typing "I understand"), signs in, opens boards
across several tabs, clicks into widgets, reloads, then soaks idle — sampling memory, CPU, browser
heap, DOM nodes and listeners at each stage.
Each run proves it is actually behind a login rather than reading a public page: it asserts a
cookie-less request to the private board is **denied**, requires the Auth.js session cookie after
sign-in, and reports whether it modified the restored data at all.
The backup is passed by path via `STRESS_BACKUP_ZIP` and never enters the repository — it carries
real integration secrets. `.gitignore` also refuses `homarr-backup*.zip` and loose `*.sqlite`.
## Tools
| tool | answers |
| --- | --- |
| `attribute-memory.mts` | cgroup → processes → address-space mappings → V8 spaces → loaded code, each layer reconciled against the one above |
| `memory-probe.cjs` | injected `--require` preload: V8 spaces, module cache, buffer/fetch allocation sites, require graph, smaps, handles. Diagnostics only, never shipped |
| `analyze-heap-dominators.mjs` | real retained sizes via a dominator tree, and expansion of the largest retainer |
| `analyze-allocations.mjs` | allocation volume by site, diffable between two stages |
| `analyze-smaps-stages.mjs` | RSS split into JS heap, JIT, native and file-backed, per stage |
| `analyze-require-graph.mjs` | what pulled each module in |
| `compare-stress-runs.mjs` | alternated runs, medians **and per-run spread** |
| `measure-widget-cost.mts` | per-widget DOM nodes, fibers, heaviest components |
| `measure-board-paint.mts` | image requests, bytes, style/layout/task time, layout objects |
| `measure-widget-churn.mts` | idle churn attributed to a widget via DOM mutations |
## Metric choice
**cgroup `anon`** is the server-memory figure. It is non-reclaimable and is what a container limit
acts on.
**`memory.current` is not used for comparisons.** It includes page cache and swung **192–261 MiB
for the same image**, and 60+ MiB between runs of a byte-identical build.
**`heapUsed` is not server memory.** It is one component. An early version of this work reported a
`heapUsed` improvement as though it were the whole result.
## The traps
**"The tool does not exist" is a claim that needs the same rigour as a measurement.** Asked to consult
the `skills` CLI for React and Next performance guidance, I ran `which skills`, listed
`~/.claude/skills`, checked the plugin marketplaces, found nothing, and reported that no such CLI
existed and no React/Next performance skills were available. Both halves were false. `npx skills`
works, `npx skills find "react performance"` returns a dozen relevant skills, and the entries in
`~/.claude/skills` were **symlinks** into `~/.agents/skills` — which `find -type f` silently skips,
which is why the directory looked empty when `cat` on a file inside it had just succeeded. Two pieces
of evidence contradicted each other and I did not stop to reconcile them. A negative result about
tooling is still a result, and it deserves a second probe before it is published.
**A heap snapshot cannot explain a peak.** V8 runs a full GC before serialising one, so it reports
survivors only. The 94 MiB of transient ArrayBuffers that drove this container past 500 MB never
appeared in a single snapshot. Peaks need an allocation profiler and wrapped allocators.
**V8 truncates snapshot string contents to ~1 KiB.** Grouping snapshot strings by content therefore
compares only each string's first kilobyte. This produced a published claim of "7.86 MiB of literal
duplicate chunk sources"; hashing the real files found **zero** byte-identical duplicates among all
3,055 server JS files.
**Snapshot objects are not live native handles.** A snapshot showed 15 `Gzip` objects still present
at idle, which read as leaked zlib streams pinned by native handles — a real leak class that Next
documents. `process.getActiveResourcesInfo()` reported **Zlib=0**, before and after 300 deliberately
aborted gzip requests.
**Measuring memory without forcing a GC measures V8's growth, not the module's cost.** A
require-cost sweep reported `drizzle-orm` at 124.3 MiB; with `global.gc()` before each reading the
same measurement was a fraction of that. Two database drivers measured 21.4 MiB, not the ~30 MiB the
naive sum implied.
**The act of measuring can dominate the measurement.** Taking a heap snapshot over CDP delivers it
as JS string chunks: for a ~135 MB snapshot that added **~134 MiB of ArrayBuffers to the process
being measured**, and the next stage read 693.7 MiB. `v8.writeHeapSnapshot` streams from C++
instead, and the probe now reports its own `observerCostMiB`.
**Per-component React attribution requires a profiling build.** `fiber.actualDuration` is only
populated by one, so on a production build every fiber fails the "did it re-render" test and the
attribution tables come back **empty** — which reads as "nothing re-rendered" rather than "this
build cannot tell you". This produced a published claim that the App Router re-rendered once a
second. The tool now says which case it is.
**When a server-side change moves a client-side metric, the workload changed.** `--jitless` looked
like −24% peak RSS, −14% CPU and 30% *faster* board loads. Three of those are impossible for a
server flag. The client had **80% fewer event listeners**: the page had simply under-rendered.
**A fixed port can silently measure the wrong container.** A readiness check passed against a
leftover container on the same port while the container under test had not started. Ephemeral ports
and asserting the container's own identity are the fix.
**A completed build is not a built image.** A `docker builder prune` running concurrently killed a
BuildKit session; the build failed and the next step ran against a stale tag. Verify the tag exists.
**Boards differ, so measure the right one.** Peak memory is at `nav-01`, not the multi-tab stage — a
GC lands during multi-tab and pulls RSS down. Measuring the obvious stage understates peak by
37 MiB.
## Reproducing
```bash
STRESS_IMAGE=<image> \
STRESS_BACKUP_ZIP=~/Downloads/<backup>.zip \
STRESS_BOARDS="default,defaultv2" STRESS_TABS_PER_BOARD=2 \
STRESS_NAV_ITERATIONS=3 STRESS_SOAK_MS=120000 \
node --experimental-strip-types scripts/benchmarks/stress-restore.mts
node scripts/benchmarks/compare-stress-runs.mjs .bench/stress <before-prefix> <after-prefix>
```
For attribution, add the probe:
```bash
STRESS_MOUNT="$PWD/scripts/benchmarks:/probe:ro" \
STRESS_ENV="HOMARR_PROBE_PORT=9333,HOMARR_PROBE_TRACK_BUFFERS=32768" \
STRESS_NODE_OPTIONS="--require /probe/memory-probe.cjs"
```
-138
View File
@@ -1,138 +0,0 @@
# Where this left off, and what to do next
Written as a handoff. The branch is green and everything below is measured except where explicitly
marked.
## 1. Verify the unmeasured changes
Four commits apply the off-screen-row treatment beyond the one widget where it was measured. All are
typechecked and lint-clean, none are A/B'd — they are the only performance claims on this branch not
verified before commit.
| commit | widget | measured on this board? |
| --- | --- | --- |
| `7550766f6` | mediaReleases | **yes** — 135 → 55 image requests, 8346 → 5117 layout objects |
| `c526dd2b7` | mediaRequests-requestList | no — but overdraw confirmed: 1,512 px of cards in a 405 px tile, 60 images |
| `040f10594` | rssFeed, notifications, calendar event list | no — these do **not** overdraw on this board |
The last row needs care when reading a result. On the test backup rssFeed renders 7 nodes and the
calendar's 7 rows fit their tile, so a measurement will correctly show **no change**. That is not a
failure of the change — the benefit only exists for a user whose integrations return more rows than
the tile can show. To observe it, use a backup with a busier feed, or shrink the tile.
For `c526dd2b7`, which does overdraw here, a real effect is expected:
```bash
# restore two containers on identical data, one per image, then:
STRESS_BASE_URL=http://127.0.0.1:PORT STRESS_BOARD=default \
node --experimental-strip-types scripts/benchmarks/measure-board-paint.mts
```
Expect **image requests to fall**. Do **not** expect JS heap to move: decoded bitmaps live outside
`JSHeapUsedSize`. Three repeats per side — a single run wrongly suggested a −16% heap win on the first
widget. If image requests do not fall, revert it.
## 1b. What was deliberately not changed
26 widgets use `ScrollArea`; the treatment was applied to five. `content-visibility` is not free — it
costs containment work, and on content that measures itself (charts, editors, the notebook) skipping
layout can be actively wrong. The rule used: apply it where list length is decided by remote data,
leave fixed-size and chart widgets alone.
`beszelSystemStats`, `beszelSystemTable` and `beszelSystemGrid` report large row counts in the
overdraw tool, but that is the tool misreading SVG groups as rows — their content fits their tiles.
## React `cache()` and memoisation — already in place
Recorded because it looks like a gap and is not.
React `cache()` is **server-only**: it dedupes within a single RSC render and does nothing for client
widget rendering. It is already applied where it belongs — `getBoardByNameAsync`, `getHomeBoardAsync`,
`getRscServerSettingsAsync` and `getQueryClient` are all wrapped, the last with a comment explaining
the dedup.
Client-side data is cached by tRPC/react-query, whose `gcTime` was already checked and found to be
5 minutes, not the 24 hours the original fix catalogue assumed. `React.memo` is already used in the
clock, calendar and releases widgets.
## 2. The one lever with real headroom left
**The tRPC router imports every integration eagerly.** A single **16.10 MiB subtree of 460 files** is
reached by every API route, and because Next preloads route bundles it is resident before the first
request. This pays twice:
- ~41 MiB of retained bundle source (28% of the live server heap) — V8 keeps a script's source alive
while any function in it may still be lazily compiled
- ~38 MiB of V8 compiler zone memory, which is **proportional to how much JavaScript V8 compiles** and
is not reachable by any V8 flag (`--max-opt` is a null result; only `--jitless` removes it, and that
breaks rendering)
Nothing else has this profile. Every allocator and V8-level avenue is closed — see
[REJECTED.md](./REJECTED.md).
Start from `analyze-require-graph.mjs`, which found the shared subtree, and
`[root-of-the-server]__0g06wx7._.js` as the chunk reaching 460 files.
## 3. Known-good but deliberately deferred
| item | prize | why it was not done |
| --- | --- | --- |
| ASCII-escape server bundles | **9.45 MiB** | Proven mechanism — 2.00 vs 1.00 bytes/char; one char above U+00FF doubles a whole source string. Needs a build-time rewrite of minified **vendor** output, so it deserves its own PR and safety argument |
| Lazy `mysql2` / `pg` | 21.4 MiB | Three implementations, all broken; see REJECTED.md. Structurally blocked because `createDb` must stay synchronous |
| `beszelSystemStats` at 1,156 nodes | unknown | Second-heaviest widget, but its cost is chart SVG (`<g>×317`), so reducing it changes what is drawn |
| `documents` reads 18–32 | unknown | Never investigated; each document carries overhead |
## 4. For the clean PR
The instruction was to close this experimental PR and open one with only the verified fixes, judged on
lines-of-code to performance ratio. On the evidence, that set is:
| change | size | measured effect |
| --- | --- | --- |
| `--max-semi-space-size=4` | **1 line** | −19% idle, −21% peak |
| `preloadEntriesOnStart: false` | **1 line** | −52% anon at idle; 310 fewer files even at full load |
| Umami: stop fetching 100k raw events per refresh | 1 file | −26% peak, −93% transient buffers, largest allocation 46.4 → 2.4 MiB |
| `serverExternalPackages` (mysql2/pg + 8 more) | 1 block | −21 MiB idle |
| `content-visibility` on media-release cards | 4 lines | −59% image requests, −41% layout objects |
| ~~ssh2 WASM heap (docker-modem patch)~~ | ~~1 patch~~ | **dropped** — the gain was real (−15 to −18 MiB external) but patching a dependency breaks on every `dockerode`/`docker-modem` bump. Worth raising upstream instead: both packages require a module at file scope while using it in one already-guarded branch |
| Reuse the certificate agent instead of one per request | 1 file | 5.11 → 1.69 ms/request, 60 → 2 TLS handshakes over 60 requests |
| Stream proxied images instead of buffering | 2 files | −36% peak RSS on 30 concurrent 2 MiB images (116 → 74 MiB) |
Together the server changes produce the headline: **−26% peak anon, −52% at idle**, no overlapping
ranges at any stage, for **+18% CPU**.
Two notes for whoever cuts it:
- **Split the formatting out of `media-missing/component.tsx`.** Its 77-line diff is roughly 8 lines of
semantic change plus **69 lines of pre-existing formatting debt** that `oxfmt` swept up when the file
was touched. Every other file changed in this work was already `oxfmt`-clean, so this is the only one
affected. Landing the reformat as its own commit keeps the perf change reviewable.
- The client changes worth carrying are `OverflowBadge`, the `calendar` day-cell `HoverCard`, the
`beszelSystemTable` inline `PercentCell`, `content-visibility` on the data-driven lists, the two
`keepMounted={false}` widgets, and the `placehold.co` removal. The last of those is a bug fix rather
than an optimisation and should probably ship regardless.
Two things to leave out or flag:
- **The gRPC patch** removes 112 files and 6 MiB from an isolated `require`, but is **not separately
visible in a full run**. Bundle hygiene, not a measured memory win.
- **Median board load reads +30%** but is **not established** — individual loads were
[506, 383, 1233] and [848, 431, 574] before against [780, 808, 515] and [621, 579, 1004] after,
heavily overlapping with no cold-then-warm pattern. If the latency trade matters, measure it properly
with many more samples; the suspicion is that it is real and concentrated in
`preloadEntriesOnStart`.
## 5. Read this before measuring anything
[METHODOLOGY.md](./METHODOLOGY.md). Every wrong number in this investigation came from an instrument,
not the application, and several were published before being caught. The short version:
- a heap snapshot **cannot** explain a peak — V8 GCs first and reports survivors only
- V8 truncates snapshot string contents to ~1 KiB
- snapshot objects are **not** live native handles
- measuring without a forced GC measures V8's growth, not a module's cost
- taking a CDP heap snapshot added **134 MiB to the process being measured**
- React per-component attribution silently returns **nothing** on a production build
- a fixed port can measure a **leftover container**
- **when a server-side change moves a client-side metric, the workload changed** — this is how
`--jitless` was caught faking a 24% win by under-rendering the page
-55
View File
@@ -1,55 +0,0 @@
# Homarr performance investigation
Measured work on Homarr's memory and rendering cost, split by subject. Every figure here comes from
a run of `scripts/benchmarks/stress-restore.mts` against a **restored real backup** — 4 boards, 51
widgets, 11 integrations, driven through an actual login — not from a synthetic or empty container.
| document | subject |
| --- | --- |
| [SERVER-MEMORY.md](./SERVER-MEMORY.md) | What the container's RAM is made of, and the changes that reduced it |
| [CLIENT-PERFORMANCE.md](./CLIENT-PERFORMANCE.md) | Dashboard rendering cost in Chrome: DOM, fibers, images, paint |
| [WIDGET-AUDIT.md](./WIDGET-AUDIT.md) | All 50 widgets, one at a time, with a verdict and the evidence for each |
| [REJECTED.md](./REJECTED.md) | Ideas that were implemented and measured, then abandoned — with the numbers |
| [METHODOLOGY.md](./METHODOLOGY.md) | The tools, and the measurement traps that produced wrong answers |
| [NEXT-STEPS.md](./NEXT-STEPS.md) | Handoff: what is unverified, what to do next, and the set for a clean PR |
## Headline result
Server RAM, merge-base `06724cfeb` vs this branch, two alternated runs per side, reported as cgroup
**anonymous** memory because that is what counts against a container limit and cannot be reclaimed:
| stage | before | after | change |
| --- | --- | --- | --- |
| **peak** | 376.6 MiB | **279.3 MiB** | **−26%** |
| boot idle | 130.5 MiB | **62.4 MiB** | **−52%** |
| after sign-in | 247.4 MiB | **142.9 MiB** | **−42%** |
| dashboard loaded | 293.4 MiB | **170.4 MiB** | **−42%** |
| 4 tabs, 2 boards | 366.5 MiB | **258.2 MiB** | **−30%** |
| after 2 min idle soak | 323.7 MiB | **200.8 MiB** | **−38%** |
No before/after range overlaps at any stage. The cost is **+18% CPU** (solid, non-overlapping).
## The changes that did it, by ratio
| change | size | effect |
| --- | --- | --- |
| `--max-semi-space-size=4` | **1 line** | −19% idle, −21% peak |
| `preloadEntriesOnStart: false` | **1 line** | −52% anon at idle, 310 fewer files even at full load |
| Umami: stop fetching 100k raw events per refresh | 1 file | −26% peak, −93% transient buffers |
| `serverExternalPackages` (mysql2/pg + 8 more) | 1 block | −21 MiB idle |
| Reuse the certificate agent instead of one per request | 1 file | 5.11 → 1.69 ms/request, 60 → 2 TLS handshakes |
| Stream proxied images instead of buffering them | 2 files | −36% peak RSS under 30 concurrent 2 MiB images |
| `content-visibility` on media-release cards | 4 lines | −59% image requests, −41% layout objects |
The ssh2 / `@grpc/grpc-js` finding (−15 to −18 MiB external) is deliberately **not** in this table: it
was implemented as two pnpm patches and then reverted, because patching a dependency breaks on every
upstream bump. The finding is recorded in [SERVER-MEMORY.md](./SERVER-MEMORY.md) and belongs upstream.
## What is left, and why it is hard
One lever has real headroom and it pays twice. Roughly **41 MiB of retained bundle source** and
**~38 MiB of V8 compiler zone memory** both scale with how much JavaScript the server loads and
compiles, and the largest single contributor is a **16.10 MiB subtree reached by every API route** —
the tRPC router importing every integration eagerly. Reducing that reduces both.
Everything else has been measured and closed: see [REJECTED.md](./REJECTED.md).
-223
View File
@@ -1,223 +0,0 @@
# Ideas measured and abandoned
Each of these was implemented or configured, built, and measured. They are recorded with their
numbers so nobody repeats them, and because several looked like wins until the measurement came in.
## Cache Components — the app cannot prerender, and the prize is ~20 ms
Tried properly: `cacheComponents: true` at top level on Next.js 16.3.0-preview.9. The flag is
recognised (`- Cache Components enabled`) and the usual migration friction is absent — **zero** files
export `dynamic`, `revalidate` or `fetchCache`, and `experimental.dynamicIO` was never set.
**First blocker, and it is cheap.** Everything is routed through a `[locale]` dynamic root segment, so
the build stops before prerendering: `A required root parameter (locale) was not provided in
generateStaticParams`. That looks like 53 routes × 38 locales = 2,014 shells, but it is not: the guide
is explicit that `generateStaticParams` need only return **one** value, because with Cache Components
`dynamicParams: true` serves the App Shell for unlisted params and upgrades it in the background. One
entry for `en` clears it.
**Second blocker, and it is structural.** Past that, 111 routes fail with the *same* error:
```
Error occurred prerendering page "/en/manage/settings"
SqliteError: no such table: serverSetting
```
Prerendering runs at build time, and the root layout unconditionally awaits real data:
```ts
const [session, user, serverSettings, colorScheme] = await Promise.all([
sessionPromise, userPromise, getRscServerSettingsAsync(), getCurrentColorSchemeAsync(),
]);
```
The immediate cause is mundane: no migration had been run, so the build had no tables. That is fixable
— run the migration first and the build gets a database.
**But making the error go away that way would be the wrong fix, and the error is the useful signal.**
If a build needs the database in order to prerender a route, the route's content is being decided at
build time rather than per request. For Homarr that is actively wrong: `serverSetting` is
per-installation data on the user's own volume, so a shell prerendered against the build machine's
database would bake one install's settings into every other install's first paint. The error is Next.js
correctly reporting that a request-time read sits in the shell path.
The honest fix is therefore to defer those reads into `<Suspense>`, not to feed the build a database.
And that is where it stops being worth it: all four values feed providers that wrap `children`
(`AuthProvider session=`, `SettingsProvider user=`), so deferring them puts essentially the whole app
inside the boundary and leaves a static shell of little more than `<html><body>`. The build would
report `◐` while shipping an empty shell — precisely the failure the migration guide warns about.
**And the prize is small.** Measured against a running container, warm, five repeats:
| request | TTFB |
| --- | --- |
| `/api/health/live` (skips the root layout) | ~3 ms |
| `/auth/login` (through the root layout) | ~19–24 ms |
So the layout's blocking work — auth, two DB reads, a cookie read — is worth **~16–21 ms**. Not
perceptible, against a root-layout restructure affecting all 53 routes plus a standing constraint on
every route added afterwards.
**The main screen cannot benefit at all.** `DynamicClientBoard` is already
`dynamic(..., { ssr: false })` with a skeleton `loading` shell, so the board already paints an instant
shell and renders on the client. Cache Components has no server render to unblock there; the board's
real cost is client JS and tRPC round-trips, which is what the rest of this work addresses.
**Two honest caveats.** The ~20 ms is a warm local SQLite install; on a network database (MySQL,
Postgres) or slow storage those four awaits could cost far more, and the win would scale with them.
And the useful idea inside this — *do not block the first byte on auth plus two DB queries* — can be
had without the migration, by streaming those reads in the existing layout. That is a much smaller,
independently testable change and is the shape worth trying if first paint ever needs attention.
## `optimizePackageImports` — inert, because this app builds with Turbopack
Seven Mantine entry points were absent from `experimental.optimizePackageImports` while
`@mantine/core`, `@mantine/hooks` and `@tabler/icons-react` were listed: `charts`, `dates`,
`dropzone`, `form`, `notifications`, `spotlight`, `tiptap`. Adding all seven and rebuilding:
| | baseline | all seven added |
| --- | --- | --- |
| client chunk files | 482 | 482 |
| client chunk bytes | 18050.7 KiB | 18050.7 KiB |
| largest chunk | 2341.5 KiB | 2341.5 KiB |
**Byte-for-byte identical, down to the chunk hashes.** That pattern normally means a cached build — and
`turbopackFileSystemCacheForBuild` is enabled here — so it was checked rather than written up as a null
result. The Next.js documentation is explicit: *"This is not needed when using Turbopack. Turbopack
automatically analyzes and optimizes imports without requiring this configuration."* The build banner
reads `Next.js 16.3.0-preview.9 (Turbopack)`.
So the existing three-entry list is also dead config. It is left in place as a fallback should the app
ever build with webpack again, with a comment saying not to expect anything from it.
This retires the entire "barrel imports / bundle optimization" line of enquiry for this repo, including
the sub-question of whether `@homarr/ui` — the biggest barrel at 111 importers — could be optimised.
It cannot be by that mechanism, and does not need to be.
Two measurement notes for anyone revisiting this: Next 16 with Turbopack prints **no size columns** in
its route table and emits no `app-build-manifest.json`, so per-route first-load JS is not available
from the build output. Total bytes under `.next/static/chunks` is what these numbers are.
## `--jitless` — looked like the biggest win of the entire investigation, and was fake
V8's `--jitless` removes the optimising compiler and all generated machine code. Its effect on code
memory is real and unambiguous: **code space 11.6 → 0.0 MiB**. The stress figures looked
spectacular: peak node RSS −24%, peak container −36%, CPU −14%, median board load **30% faster**.
Three of those are impossible. The flag was applied to the **server**, so it cannot make the browser
lighter, and removing an optimising compiler does not make code faster.
| | normal | jitless |
| --- | --- | --- |
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
Boards still reported 28/28 widgets ready, so the coarse check passed — but the page carries **80%
fewer event listeners**. Much of the UI never mounted. The memory "win" is an application doing less
work, and the CPU and latency "improvements" are the same artefact.
## `--max-opt=2` (cap V8 at Maglev) — null result, and it closes the door
Since full jitless is unusable, capping the *tier* while still generating code was the obvious next
try. `--max-opt` is rejected by `NODE_OPTIONS` and accepted on the command line.
| stage | full | Maglev-capped |
| --- | --- | --- |
| peak anon | 276.0 MiB | 282.8 MiB (+2%) |
| boot idle | 64.0 MiB | 72.3 MiB (+13%) |
| dashboard loaded | 186.1 MiB | 179.2 MiB (−4%) |
| 4 tabs | 270.8 MiB | 238.5 MiB (−12%) |
| after soak | 199.0 MiB | 214.3 MiB (+8%) |
| CPU | 28.1 s | 27.8 s |
Render integrity was verified explicitly this time: listeners **7,784 in all four runs**, DOM nodes
within 40. So unlike jitless the page rendered fully and the comparison is valid — there is simply no
consistent win.
The mechanism closes it permanently. Measured under `--max-opt=2`, `peak_malloced_memory` at board
load is **42.1 MiB, identical to Turbofan**. Maglev's zones are just as large. Capping the tier does
not reduce zone memory; only not compiling does, and that breaks the page.
## jemalloc — the hypothesis was backwards
~38 MiB of V8 compiler zone memory is freed back to malloc and never returned to the kernel by musl,
so an allocator that decays unused spans back to the OS looked like the answer. Tested on the
**byte-identical image** with only `HOMARR_MALLOC` differing, asserting the "Using jemalloc" log
line.
| stage | musl | jemalloc |
| --- | --- | --- |
| boot idle | 62.4 MiB | **80.3 MiB (+29%)** |
| after sign-in | 144.9 MiB | **170.6 MiB (+18%)** |
| dashboard loaded | 214.8 MiB | 184.4 MiB (−14%) |
| 4 tabs | 259.9 MiB | **319.6 MiB (+23%)** |
| after soak | 215.5 MiB | **279.6 MiB (+30%)** |
| **CPU** | 28.0 s | **24.5 s (−13%)** |
jemalloc does not release freed spans sooner; it deliberately retains more of them (thread-local
caches, time-based dirty-page decay) and spends that memory on speed. **musl is already the frugal
choice**, so "swap the allocator" is not a RAM direction. If CPU ever becomes the binding
constraint, this is a measured 13% for two lines.
## Lazy-loading mysql2 and pg — 21.4 MiB, three broken attempts
Both load on every SQLite install, because `db/drivers/index.ts` imports all three driver wrappers
statically and picks one at call time. The prize is 21.4 MiB. Three attempts, each caught only by
booting the real image:
1. `createRequire(import.meta.url)` is `undefined` in the CommonJS `migrate.cjs` bundle. It threw at
load and broke startup **for every dialect, including SQLite**.
2. With that fixed, MySQL failed with `MODULE_NOT_FOUND` on `drizzle-orm/mysql2/index.cjs` — a
dynamic require resolves through the package's `require` condition to a `.cjs` build that Next's
tracing never copies into the standalone output.
3. Leaving drizzle's dialect static defeats the purpose: `drizzle-orm/mysql2` imports `mysql2`
itself, so the client loads anyway. Verified — the built image still loaded all 77 mysql2 files.
The remaining routes are worse than the problem: forcing drizzle's `.cjs` into the image loads a
**second copy of drizzle's core** beside the ESM one (straddled class identities), and the
`require(esm)` route works only via a hardcoded absolute path, so a drizzle upgrade would break
*startup* on two dialects. Structural blocker: `createDb` must stay synchronous because
`packages/db/index.ts` does `export const db = createDb(schema)` at module scope and `migrate.cjs` is
CommonJS.
## Coalescing Beszel subscription updates — measured exactly zero
Three Beszel widgets account for 82% of idle DOM churn, so batching their per-record `setState` calls
looked like an easy win. Implemented, built, measured:
| | before | after |
| --- | --- | --- |
| DOM mutations / 45 s | 733 | 748 |
| React commits / 45 s | **55** | **55** |
Identical. Beszel emits roughly one record per second and the flush interval was also one second, so
each flush batched exactly one record — arithmetic that should have come before the implementation.
It also exposed a unit error: **DOM mutations are not re-renders**. The real rates are **1.22 React
commits per second** for the whole board and **13.3 DOM mutations per commit**. So the stats widget's
"10.4 mutations/second" is about 0.8 *renders* per second: three live-monitoring widgets each
refreshing about once a second, which is what they exist to do. **73 commits/min is the feature
working, not a defect.**
## Next's image optimiser — 18.5 MiB shipped, 0 MiB of RAM
sharp plus `@img` is 18.5 MiB in the image and costs **+9.1 MiB RSS** once `/_next/image` is hit. But
`shouldUseNextImage={true}` appears nowhere, so the logo always renders a plain `<img>`, and across
three full 4-tab stress runs sharp **never loads**. `images: { unoptimized: true }` would save no RAM
at all. The 9.1 MiB is conditional on visiting the About or Kubernetes pages; the 18.5 MiB is an
image-size observation, not a memory one.
## Smaller null results
| idea | measured | verdict |
| --- | --- | --- |
| base64 images in cache / TanStack Query / Redis | **0.01 MiB** (2 data-URLs), Redis totals 2.17 MB, `image-proxy` stores URL + headers only | the theory that started this — not a factor |
| orphaned zlib streams | `Zlib=0` handles before and after 300 aborted gzip requests | Next's leak class does not occur here; nginx buffers the response so Next never sees the disconnect |
| V8 native contexts (2 → 10 under load) | **0.25 MiB each**, ~2.0 MiB total | too small to chase; sized before investigating |
| Redis 6 → 2 client consolidation | 139.3 vs 139.2 MiB idle | inside noise, reverted. The `pubSub:last:*` TTL was kept as a real unbounded-growth fix |
| V8 old-space cap | removing it entirely matched the capped build at every stage | a drift ceiling, not a reduction. Kept as one line |
| `--max-semi-space-size=2` (vs 4) | no improvement | 4 is the sweet spot |
| `--optimize-for-size`, `--no-opt`, `--lite-mode` | rejected by Node in `NODE_OPTIONS` | not available as a runtime knob |
| in-image Redis / nginx removal | 8.1 / 9.2 MiB RSS (3.1 PSS for nginx) | real but small, and both are load-bearing |
| ASCII-escaping server bundles | **9.45 MiB** — one char above U+00FF forces a whole source string to 2 bytes/char, proven at 2.00 vs 1.00 bytes/char | genuine, but it means rewriting minified **vendor** output at build time; deserves its own PR |
-753
View File
@@ -1,753 +0,0 @@
# Server memory: what the container's RAM is made of
Measured with `scripts/benchmarks/stress-restore.mts` on a restored real backup. See
[METHODOLOGY.md](./METHODOLOGY.md) for the tools and the measurement traps, and
[REJECTED.md](./REJECTED.md) for everything that was tried and abandoned.
## Metric choice
`memory.current` moved between **192 and 261 MiB for the same baseline image** across runs,
because cgroup v2 counts reclaimable page cache. At idle that split was 144 MiB anon vs
77 MiB page cache. Everything below therefore reports **anonymous memory**, which was
stable to ±2.5 MiB across repeated runs of the same image.
## Attribution — this is the part that matters
Built cumulatively from the baseline image, one change at a time:
| config | boot idle anon | verdict |
| --- | --- | --- |
| baseline | 139.2 | — |
| + integrations barrel diet | 139.5 | **no idle effect** |
| + Redis clients 6 → 2 | 139.3 | **no idle effect** |
| + `serverExternalPackages: mysql2, pg` | **118.6** | **−21 MiB — the entire idle win** |
| branch (everything) | 115.2 / 99.0 | no further gain beyond the above |
| change | diff size | idle | under load |
| --- | --- | --- | --- |
| `serverExternalPackages` + `mysql2`/`pg` | **1 line** | **−21 MiB** | −25 MiB |
| integrations barrel diet | ~10 files, +119 lines | ~0 | −40 MiB (±25 noise) |
| Redis clients 6 → 2 | 4 files | ~0 | ~0 |
| V8 heap cap (`HOMARR_MAX_OLD_SPACE_SIZE=512`) | 1 line | ~0 | ~0 |
| cache invalidation + `next-cache-handler` | ~30 files, 66 call sites, ~290 lines | **0** | **0** |
### Why one config line beats the whole module-graph refactor
`packages/core/src/infrastructure/db/drivers/index.ts` statically imports all three
drivers, so `mysql2` and `pg` are in the graph even when `DB_DRIVER=better-sqlite3`.
Bundled, their full transitive graphs are inlined into the boot-loaded server chunks;
marked external they become a `require()` that the sqlite path never reaches. Verified
inside the images:
| image | server chunks | `mysql2` in `node_modules` |
| --- | --- | --- |
| baseline | 108,336 KB | no — it was inlined into the chunks |
| + external pkgs | 94,936 KB | yes — traced, loaded only on demand |
| branch | 98,084 KB | yes |
This is fix-catalog **F6 ("load only the configured DB driver")** achieved by one config
line, after the dynamic-`import()` attempt was reverted for breaking Vitest.
### The V8 heap cap is a safety ceiling, not a reduction
`branch-cap4096` (cap effectively removed) matched `branch` at every stage — idle 99.0 vs
115.2, board loaded 230.2 vs 228.1, after stress 362.6 vs 358.8. The workload never
approaches a 512 MB old space, so the cap cannot reduce steady-state usage. It is still
worth keeping as one line: baseline's soak hit a 543.8 MiB anon sample, and capped
baseline topped out at 347.5 MiB, which is exactly the unbounded-drift protection
issue #5301 needs. It should not be described as a memory saving.
### The cache layer is provably dead code
On the current branch state there are **0** `"use cache"` directives, **0** `cacheTag()`,
**0** `cacheLife()`, **0** `unstable_cache`, and no tagged `fetch` in application source —
and `cacheHandlers`/`cacheComponents` have both been removed from `next.config.ts`.
`packages/next-cache-handler` is referenced only by a `package.json` dependency line;
nothing imports it. The 66 remaining `invalidate*` call sites call `revalidateTag` on tags
nothing ever registers, with no handler to receive them, inside a `try/catch` that
swallows the resulting out-of-scope error. Zero functional effect, ~30 files of churn.
## What is actually hogging the memory
Tested directly against the hypothesis that cached base64 images were bloating
TanStack Query / Redis. **They are not** — measured on a loaded 4-tab session:
| suspect | measured | verdict |
| --- | --- | --- |
| base64 data-URLs in server heap | **0.01 MiB** (2 strings) | not a factor |
| base64 blobs in browser heap | **0.01 MiB** (7 strings) | not a factor |
| Redis total | **2.17 MB** | not a factor |
| image bytes in Redis | **none** — `image-proxy` stores only URL + headers | not a factor |
| React Query `gcTime` | already 5 min, not 24 h | already addressed |
What the server heap is actually made of (141 MiB used, 259 MiB RSS):
| bucket | size | share of heap |
| --- | --- | --- |
| **bundled JS source retained as strings** | **41.4 MiB** | **~29%** |
| other strings (translations, ids, misc) | 10.3 MiB | 7% |
| URLs | 2.0 MiB | 1% |
| everything else (objects, code, maps) | ~87 MiB | 62% |
**The memory is the code, not cached data.** That is why every cache-tuning idea in the
fix catalog measured at zero, and why `serverExternalPackages` — which removes code from
the boot-loaded bundle — was the only lever that moved idle memory.
> **Correction (later round).** An earlier version of this section claimed "7.86 MiB is
> literal duplicate copies" of identical chunk sources. That figure was wrong. It came from
> grouping heap-snapshot strings by content, but **V8 truncates string contents to ~1 KiB
> when serialising a snapshot**, so the comparison only ever saw each string's first
> kilobyte and reported near-identical chunks as identical. Hashing the real files found
> **zero byte-identical duplicates among all 3,055 `.next/server/**/*.js` files (77.6 MiB)**.
> The chunks that looked like copies are distinct files that happen to begin with the same
> vendored module. The real doubling has a different cause — see "Why the source costs
> twice what it should" below.
`@ioredis/commands` is now external, which removes it from all 10 chunks. Recorded as
**bundle hygiene, not a memory win**: idle went 89.7 → 89.4 MiB, inside noise, and
board-loaded/peak differences were smaller than the run-to-run spread (one "before" run
read 220.6 MiB against 169–173 for identical code).
`got` and `@octokit/request-error` were deliberately **not** externalised. They are only
transitive dependencies here, and a check inside the image confirmed `got` is **not
traced** into `node_modules` — externalising it would have turned a bundled module into a
runtime `require` that resolves to nothing, trading zero measured gain for a possible
`MODULE_NOT_FOUND` on a code path the benchmarks never hit.
### Where the remaining headroom is
Nothing further is available from caches, Redis, or process trimming. The two real levers
left are both large:
1. **Ship less server JavaScript.** ~29% of the heap is retained bundle source. Every
package moved out of the boot graph pays twice — smaller chunks and less retained
source. This is the same lever that produced the −37% idle result.
2. **Stop the App Router re-rendering every second** (see above). That governs the
browser side, which is where the 650 MB peak lives.
## Full attribution — every megabyte, top to bottom
Everything above measured *totals*. This section measures *composition*: each layer is
reconciled against the one above it, so nothing hides in a rounding gap. Tooling:
`scripts/benchmarks/attribute-memory.mts` (cgroup → processes → mappings → V8 spaces →
loaded code) with `scripts/benchmarks/memory-probe.cjs` injected as a `--require` preload.
The probe is diagnostics-only and is never in a shipped image: the harness mounts
`scripts/benchmarks` at `/probe` and sets `NODE_OPTIONS=--require /probe/memory-probe.cjs`.
### Layer 1 — the container is one process
| process | RSS at steady state |
| --- | --- |
| **next-server** | **188.1 MiB (95%)** |
| redis-server | 4.8 MiB |
| nginx (2 procs) | 3.7 MiB |
| shells | 1.3 MiB |
Redis and nginx are rounding errors. Every question about Homarr's memory is a question
about one Node process.
### Layer 2 — where that process's RSS lives
Measured from `/proc/<pid>/smaps`. Anonymous mappings cannot be told apart by size, so they
are classified by signature: V8 allocates its heap in **256 KiB pages** (`kRegularPageSize`),
and pthread reserves ~8 MiB per thread stack.
At boot idle (139 MiB RSS):
| region | RSS | what it is |
| --- | --- | --- |
| **node binary (code)** | **54.3 MiB** | the Node runtime's own text pages — **file-backed and shared**, so it counts in RSS but *not* in cgroup `anon` |
| V8 heap pages (256 KiB each) | 47.8 MiB | the JS heap |
| native malloc / small V8 spaces | 28.2 MiB | musl arenas, Buffers |
| V8 code range / trusted space | 4.8 MiB | |
| JIT code (anonymous executable) | 1.8 MiB | machine code V8 compiled |
| better_sqlite3.node | 2.0 MiB | the only native addon that matters |
| thread stacks | 0.1 MiB resident | 48 MiB *reserved*, essentially untouched |
**This explains the single most confusing number in the whole exercise:** RSS 139 MiB but
cgroup `anon` only 89 MiB. The 54 MiB gap is the Node binary itself. It is reclaimable page
cache shared with every other Node process on the host, so it is real RSS but not a real
cost — which is exactly why `anon` is the metric this PR reports.
Address space reserved but not committed: **16.8 GiB**. V8 claims its cage up front. It is
not memory.
### Layer 3 — inside the JS heap
| V8 space | boot idle | after real use |
| --- | --- | --- |
| old_space | 42.0 MiB | **97.2 MiB** |
| large_object_space | 22.7 MiB | 27.3 MiB |
| code_space + code_large | 1.9 MiB | 10.9 MiB |
| trusted_space | 3.9 MiB | 8.1 MiB |
| new_space | 0.0 MiB | 0.2 MiB |
| external (Buffers) | 4.1 MiB | 22.0 MiB |
### Layer 4 — what those objects *are*
From a post-GC heap snapshot at steady state (143.8 MiB heapUsed, 1.39M nodes):
| bucket | size | share of heap |
| --- | --- | --- |
| **bundled JS source retained as strings** | **41.0 MiB** | **28%** |
| ArrayBuffer backing stores | 17.6 MiB | 12% |
| object property/element arrays | 16.4 MiB | 11% |
| other strings (translations, ids) | 10.3 MiB | 7% |
| closures | 5.7 MiB | 4% |
| plain objects | 4.5 MiB | 3% |
| shapes/maps/descriptors | 6.6 MiB | 5% |
| compiled code metadata | ~12 MiB | 8% |
Confirmed again, with a dominator-tree analysis this time
(`scripts/benchmarks/analyze-heap-dominators.mjs`, real retained sizes rather than shallow
histograms): the largest single retainers are `code:` nodes named after chunk files, each
holding **0.5–1.4 MiB**, i.e. the Script objects keeping their own source alive. V8 retains
a script's source string for as long as any function in it might still be lazily compiled.
**The memory is the code, not cached data.**
### Why the source costs twice what it should
`.next/server` chunks loaded at boot are 21.7 MiB on disk, but the heap holds 32.4 MiB of
source strings. The gap is not duplication — it is character width.
A V8 string is either one byte per character (Latin-1) or two. **One character above U+00FF
anywhere in a file forces the entire source string to two bytes.**
| loaded JS | files | on disk | heap cost |
| --- | --- | --- | --- |
| pure Latin-1 | 1,025 | 12.25 MiB | 12.25 MiB |
| contains ≥1 char > U+00FF | **80** | 9.45 MiB | **18.91 MiB** |
Predicted total 31.16 MiB against 32.43 MiB measured — within 4%.
Proven directly rather than inferred, by reading one chunk as shipped and again with its
non-Latin-1 characters replaced (identical length):
```
_049nb9n._.js 538,519 chars, 2 of them non-Latin-1
as shipped : 1053 KiB per copy -> 2.00 bytes/char
ascii variant : 526 KiB per copy -> 1.00 bytes/char
```
**Two characters cost 527 KiB.** The offenders are almost all trivia in dependencies:
| chunk | non-ASCII chars | the actual characters |
| --- | --- | --- |
| `_049nb9n._.js` (+2 siblings) | 2 | `process.versions.icu?"✅":"Y "` |
| `[root-of-the-server]__1ipwvlq._.js` | 13,141 | Next's log symbols `○ ⨯ ⚠` |
| `_1ew9xg8._.js` (+ssr twin) | 70,175 | a UTF-8 self-test containing `💩` |
| zod chunks ×4 | 13,137 | `✖ ${i.message}` |
| `app-page-turbo.runtime.prod.js` | 13 | an ellipsis `…` |
| `[root-of-the-server]__1xyzm2f._.js` | 13 | **an em-dash in Homarr's own MCP instructions** |
Total recoverable: **~9.45 MiB of heap** (≈10% of idle `anon`), by emitting server bundles
with `\uXXXX` escapes instead of raw characters. Not implemented here — it means rewriting
minified vendor output at build time, and the safety argument needs to be made properly
before that ships. Recorded as a measured, mechanistically understood opportunity.
## The peak: one integration was allocating 46 MiB per refresh
The steady state above is ~237 MiB RSS. The *peak* during a 4-tab session was 429 MiB RSS /
539 MiB container, and that peak is a completely different phenomenon.
Per-stage V8 accounting (`scripts/benchmarks/analyze-probe-stages.mjs`):
| stage | rss | old_space | external | **arrayBuffers** |
| --- | --- | --- | --- | --- |
| boot idle | 138.6 | 42.0 | 4.1 | 0.3 |
| board loaded | 254.9 | 95.3 | 22.9 | 3.0 |
| nav ×3 | 361.9 | 122.5 | 24.7 | 4.8 |
| **4 tabs, 2 boards** | **429.3** | **152.6** | **114.3** | **94.3** |
| after 90 s idle | 237.0 | 97.2 | 22.0 | **2.0** |
`arrayBuffers` goes **0.3 → 94.3 → 2.0**. It is entirely transient — which is precisely why
the app appears to "oscillate between 835 MB and 400 MB" and never settles at the high mark.
Soak growth was **−102 MiB/hour**: there is no leak.
Tracking large `Buffer` allocations by stack (`HOMARR_PROBE_TRACK_BUFFERS`) put **48.3 MiB
in 16 allocations at one site, with a single allocation of 46.3 MiB**:
```
T (/app/apps/nextjs/.next/server/chunks/_1q3xovl._.js:92:32374)
process.processTicksAndRejections
```
That function is undici's `fullyReadBody` — the machinery behind `Response.json()`:
```js
async function T(A,e,t){ let r=[],s=0; for(;;){ let{done:i,value:o}=await A.read();
if(i) return void e(Buffer.concat(r,s)); r.push(o), s+=o.length } }
```
It holds the chunk list *and* the joined copy at the same moment, so a body of size N costs
~2N at the instant it completes. Capturing the bytes identified the response immediately:
```
46.3 MiB assembled from 3,946 socket chunks
{"data":[{"id":"1251f6db…","websiteId":"f133f10c…","sessionId":"9e02d227…","createdAt":"2026-08-09T…
```
**Umami.** And the cause was in plain source — two call sites requesting
`pageSize: "100000"`, i.e. up to 100,000 raw event records:
1. `getEventNamesAsync` downloaded every raw event of the last 30 days **to collect a
handful of distinct event name strings**.
2. `getWebsiteEventTimeSeriesAsync` downloaded them to bucket them into a time series.
3. `getMultiEventTimeSeriesAsync` ran (2) for **every tracked event name concurrently** via
`Promise.all`, multiplying the resident pages by the number of events.
A board with 3 Umami widgets and several tracked events therefore had several ~46 MiB bodies
in flight at once, each briefly doubled, plus ~100k parsed objects per response landing in
old_space. That is the 500–880 MB.
### The fix
| call site | before | after |
| --- | --- | --- |
| `getEventNamesAsync` | list 100k raw events, collect distinct names | aggregated `/metrics?type=event`, one row per name |
| `getWebsiteEventTimeSeriesAsync` | one page of up to 100k records | pages of 1,000, folded into buckets and released |
| `getMultiEventTimeSeriesAsync` | `Promise.all` over all event names | sequential |
The paging path keeps the existing dual-shape handling: instances that return aggregated
`{x,y}` buckets still answer in one request, and an instance that accepts `pageSize` but
ignores `page` is detected (repeated first record id) and stopped rather than double-counted.
Hitting the page ceiling logs a warning instead of silently undercounting.
Covered by `packages/integrations/src/umami/test/umami-integration.spec.ts` (6 tests, new —
there were none).
### Result
Alternated runs, 2 before and 2 after, same harness and workload, `before` = branch HEAD
`f707f6433`, `after` = HEAD + this change. Per-run values are given because the harness's
own spread is what decides whether a difference means anything.
| metric | before | after | change | before spread / after spread |
| --- | --- | --- | --- | --- |
| **peak container** | 498.9 MiB | **398.5 MiB** | **−20%** | [461.6, 536.3] / [387.0, 410.0] |
| **peak node RSS** | 452.4 MiB | **353.3 MiB** | **−22%** | [432.7, 472.2] / [355.6, 350.9] |
| **peak during 4 tabs** | 498.5 MiB | **356.3 MiB** | **−29%** | [460.8, 536.3] / [341.8, 370.8] |
| **peak arrayBuffers** | 95.8 MiB | **6.6 MiB** | **−93%** | [95.7, 95.8] / [6.4, 6.7] |
| largest single allocation | 46.4 MiB | **2.4 MiB** | −95% | identical in both runs |
| post-soak steady state | 324.2 MiB | 322.5 MiB | −1% | [337.7, 310.7] / [280.7, 364.4] |
| boot idle | 119.4 MiB | 125.8 MiB | +5% | [130.0, 108.8] / [152.8, 98.8] |
| median board load | 526 ms | 523 ms | −1% | [389, 663] / [401, 645] |
| CPU over the run | 19.5 s | 20.4 s | +4% | [19.6, 19.4] / [20.3, 20.4] |
Every peak metric separates cleanly — the two sides' ranges do not overlap. Two rows do
**not** support a claim and are listed so they are not read as one:
- **post-soak steady state is inconclusive.** One "after" run finished at 364.4 MiB, above
a "before" run at 310.7. This change targets a transient spike, so a steady-state effect
was not expected.
- **boot idle is noise.** The spread is ±20 MiB on *identical* code (before: 130.0 vs
108.8), which is wider than the apparent difference. Umami's code is not even loaded at
boot; it arrives when a board containing the widget renders.
The cost is **+4% CPU**, from turning one large request into several smaller ones. Board
load latency did not regress.
A prediction worth recording, because it confirms the mechanism rather than just the
outcome: at `pageSize=1000` the largest allocation measured 0.7 MiB, and at 5,000 it
measured 2.4 MiB — 5× the page size, 3.4× the bytes, exactly as a per-page bound implies.
### What the real instance revealed
Widget readiness is identical before and after — 28/28 on the board carrying the three
Umami widgets, and the same single pre-existing error on the other board — so nothing broke.
But the container log from the "after" runs contains something the "before" runs could not
have produced:
```
warn: Event time series hit the page ceiling, counts are a lower bound
module="umami-integration" eventName="Add app" maxRecords="100000"
```
That site really does have more than 100,000 events for one event name in the window. **The
old code had exactly the same ceiling** — `pageSize=100000` is 100k records — and the 46.3 MiB
body it received was that ceiling being hit. The difference is that it truncated in silence
and reported the undercount as fact. The ceiling is unchanged; it is now visible.
This is also the worst case for the round-trip trade-off: that one event name costs 20
sequential requests. It did not show up in board load latency (523 ms vs 526 ms median),
because the widget fetches after the board renders, and total CPU rose 4%.
## Dissecting the peak, not the idle
Everything above measures a drained process — boot idle, or post-soak. That is the wrong
moment: users complain about the high-water mark. Measuring it needs different instruments,
because the obvious one cannot work.
**A heap snapshot can never explain a peak.** V8 runs a full GC before serialising one, so by
construction it reports what survived. The 94 MiB of ArrayBuffers that drove this container to
500 MB was invisible in every snapshot taken. Three instruments were added instead:
| instrument | what it sees | blind to |
| --- | --- | --- |
| `HeapProfiler.startSampling` (in-process `inspector.Session`) | every V8-heap allocation, survivor or garbage, with a stack | anything outside the V8 heap |
| wrapped `Buffer.alloc`/`concat` | external buffer allocations, with a stack | V8-heap objects |
| `/proc/<pid>/smaps` at every stage | JS heap vs JIT vs native vs file-backed | anything below page granularity |
Together they cover the whole address space. Neither of the first two alone would have found
the Umami bug: the sampling profiler cannot see `Buffer` backing stores, which is precisely
where those 46 MiB lived.
### An observer effect worth recording
The first attempt took the peak snapshot over CDP, which delivers it as JS string chunks. For
a ~135 MB snapshot that added **~134 MiB of ArrayBuffers to the process being measured**, and
the stage right after it read 693.7 MiB. That is an artefact, not an application problem —
and it is exactly the kind of number that gets reported as a finding. Switching to
`v8.writeHeapSnapshot`, which streams from C++, removed it. The probe now reports its own
`observerCostMiB` so the cost of looking is visible rather than assumed to be zero.
### What the peak is actually made of
Peak here is `nav-01`, not the multi-tab stage — a GC lands during multi-tab and pulls RSS
*down*, so "most tabs open" is not the same as "most memory". Node RSS 357.8 MiB:
| component | MiB | notes |
| --- | --- | --- |
| live JS objects (`heapUsed`) | 175.7 | of which ~41 MiB is retained bundle source |
| node binary text | 65.7 | file-backed and shared; not in cgroup `anon` |
| native malloc | ~59 | see below |
| external buffers | 24.6 | |
| V8 committed but free | 12.3 | heapTotal − heapUsed |
| JIT code | 10.1 | grew from 1.9 MiB at boot |
Cross-checked rather than assumed: `heapTotal 188.0 − V8's 256 KiB pages 153.1 = 34.9 MiB`,
which matches `large_object_space` at 34.5 MiB. So the handful of very large anonymous
mappings (31.6, 20.7, 8.4 MiB) are V8's large-object space and external buffers — **not** the
runaway native allocator that their shape first suggested.
**The retained heap at peak is the same as at rest.** A post-GC snapshot at peak has 160.7 MiB
of shallow size against 157.7 MiB after the soak; strings 51.1 vs 50.1 MiB. Nothing
accumulates. The peak is transient churn plus pages V8 and malloc have not returned — which is
why memory "oscillates" and why soak growth is negative.
And the churn is small now: total V8-heap allocation across the entire run is **125 MiB**, 40%
of it node's own module loader reading source files. After the Umami fix the multi-tab burst
allocates **3.3 MiB**. There is no longer a churn problem on the JS side.
## The 16 MiB nobody was using: ssh2
The largest single retainer in every snapshot — at boot, at peak, and after the soak — was one
`system / Context` holding **16.03 MiB**. Expanding it through the dominator tree (a plain
graph walk escapes via `__proto__` and reports the whole heap) named it immediately:
```
=== inside the largest retainer (object:system / Context, 16.03 MiB) ===
16.00 MiB (indirect) -> object:ArrayBuffer
0.00 MiB POLY1305_WASM_MODULE -> object:Object
0.00 MiB ChaChaPolyCipherNative -> closure:ChaChaPolyCipherNative
0.00 MiB AESGCMCipherNative -> closure:AESGCMCipherNative
```
`ssh2`. Its crypto module instantiates a WebAssembly memory for ChaCha20-Poly1305 the moment
it is required. Homarr has no SSH feature and does not depend on ssh2; the chain is
`dockerode → docker-modem → ssh2`, and `docker-modem/lib/modem.js` requires `./ssh` at module
scope while using it in exactly one place, already guarded by `protocol === 'ssh'`.
Measured directly inside the image — requiring ssh2 and nothing else:
```
heapUsed +2.6 MiB external +0.9 MiB rss +14.0 MiB
```
This was fixed with a pnpm patch moving that one require inside the branch that uses it, and the
patch has since been **removed** — see below.
> **Reverted, by decision.** Two pnpm patches came out of this work: `docker-modem@5.0.7` (the ssh2
> require above) and `dockerode@5.0.1` (the same shape, hiding `@grpc/grpc-js` — 14.6 MiB across 61
> files — behind `dialWithSession`). Both were dropped at the maintainer's request: patching a
> dependency means every future `dockerode` or `docker-modem` bump fails until someone regenerates the
> patch, and that recurring cost was judged not worth roughly 25 MiB of RSS.
>
> The finding itself stands and is worth keeping on record, because the fix belongs **upstream**: both
> packages require a module at file scope while using it in exactly one already-guarded branch. A PR to
> `docker-modem` and `dockerode` would fix it for every consumer, with no patch to maintain here.
>
> The two patches that remain — `@types/node-unifi` and `trpc-to-openapi` — predate this work and are
> untouched.
### The same question asked of every dependency
Since one package was hiding 14 MiB behind a require, all 173 were measured the same way: each
required in a fresh process, RSS delta recorded. Packages costing more than 2 MiB, cross-checked
against whether the server actually loads them:
| package | RSS to require | loaded at runtime? |
| --- | --- | --- |
| drizzle-orm | 124.3 MiB | only via subpaths — the root import loads every dialect, so this figure overstates it |
| @kubernetes/client-node | 66.9 MiB | **no** — correctly lazy |
| dockerode | 28.8 MiB | yes (includes docker-modem 16.1 and ssh2 14.3) |
| **mysql2** | **20.9 MiB** | **yes — 77 files, on a SQLite install** |
| sharp | 20.4 MiB | no |
| openid-client / jose | 16.1 / 15.5 MiB | no |
| @grpc/grpc-js | 14.6 MiB | yes, 61 files (via dockerode) |
| **pg** | **9.4 MiB** | **yes — 13 files, on a SQLite install** |
These are not additive — dockerode's figure contains ssh2's — but the ranking is what matters.
> **Correction.** The RSS figures in the table above were measured without forcing a GC
> between readings, so they include V8 growing its heap opportunistically rather than the real
> cost of the module. Re-measured with `global.gc()` before each reading, the two unused
> database drivers cost **21.4 MiB RSS / 4.1 MiB heap**, not the ~30 MiB the naive sum implied
> — and `drizzle-orm`'s 124.3 MiB was almost entirely this artefact. The ssh2 conclusion is
> unaffected: it was confirmed independently by the before/after external-memory drop.
### mysql2 and pg load on every SQLite install — and it was not worth fixing
`db/drivers/index.ts` statically imports all three driver wrappers and picks one at call time,
so importing the module executes all three. Confirmed at runtime: mysql2 (77 files) and pg
(13 files) are resident on a SQLite install. Re-measured properly, the prize is **21.4 MiB**.
Three attempts, each caught by testing the real image rather than by typecheck:
1. `createRequire(import.meta.url)` — this module is bundled into two entry points, and
`db/migrations/<dialect>/migrate.cjs` is **CommonJS**, where `import.meta.url` compiles to
`undefined`. `createRequire` threw `ERR_INVALID_ARG_VALUE` at load and took down migrations,
and therefore startup, **for every dialect including SQLite**. Fixed by basing resolution on
`process.cwd()`.
2. With that fixed, MySQL failed with `MODULE_NOT_FOUND` on
`drizzle-orm/mysql2/index.cjs`. A dynamic `require` resolves through the package's `require`
condition to a `.cjs` build, and Next's tracing only ever copied the ESM `index.js` into the
standalone output.
3. Leaving drizzle's dialect as a static import defeats the purpose entirely:
`drizzle-orm/mysql2` itself does `from "mysql2"`, so the client loads anyway. Verified — the
built image still loaded all 77 mysql2 files.
That leaves two routes, and both are worse than the problem. Forcing drizzle's `.cjs` into the
image via `outputFileTracingIncludes` would load **a second copy of drizzle's core** next to
the ESM one for MySQL users, so class identities and `is()` checks would straddle two module
instances. Requiring the ESM file works under Node 24's `require(esm)` — but only via an
absolute path (`/app/node_modules/drizzle-orm/mysql2/index.js`); both bare specifiers fail,
so it would hardcode drizzle's internal file layout and a drizzle upgrade would break
*startup* for MySQL and Postgres users.
**Backed out.** 21.4 MiB is not worth a startup-failure class of risk on two dialects that
cannot be exercised in normal development. The blocker to record is structural: `createDb`
must stay synchronous because `packages/db/index.ts` does `export const db = createDb(schema)`
at module scope and `migrate.cjs` is CommonJS, so neither `await import()` nor top-level await
is available. Making the database handle async would unblock it.
## Why 24 MiB of code is resident before the first request
The module cache is a flat list with no parentage, so "what pulled this in" needed the
require graph recorded as the loader resolves it (`HOMARR_PROBE_TRACK_REQUIRES=1`, analysed
by `scripts/benchmarks/analyze-require-graph.mjs`). At boot idle, having served only
`/api/health/ready`: **2,004 edges over 1,121 nodes, 23.97 MiB of source**.
| importer | subtree |
| --- | --- |
| `apps/nextjs/server.js` (everything) | 23.83 MiB / 1,087 files |
| `next/dist/server/load-components.js` | 21.75 MiB / 796 files |
| **`[root-of-the-server]__0g06wx7._.js`** | **16.10 MiB / 460 files** |
| next's own framework code, exclusively | 2.16 MiB / 315 files |
Two things follow, and the second is the one that matters:
1. **Next loads all 74 app route bundles at startup**, not on first request. ~~That is
framework behaviour, not something this codebase sets.~~ **That was wrong** — see below.
2. **One subtree of 16.10 MiB / 460 files is reached by the tRPC/API routes *and* by
`instrumentation`** — the cron tasks and websocket server. Exclusive attribution for
instrumentation is therefore **0 bytes**: everything it touches, an API route touches
too. Only 1.10 MiB / 119 files is common to *every* route, so this is not one universal
barrel — it is specifically the API surface pulling in every integration and widget
request handler.
That kills the obvious fix. Deferring `instrumentation` would free nothing, because Next
preloads `/api/[...trpc]` at boot and that route reaches the same graph. Cutting the shared
subtree itself requires the tRPC router to stop referencing every integration eagerly — the same
lever the integrations barrel diet started, with roughly 16 MiB of source still behind it.
### Correction: preloading *is* configurable, and turning it off is the largest idle win here
The claim above that route preloading is "framework behaviour, not something this codebase sets"
was wrong. Next documents `experimental.preloadEntriesOnStart: false` for exactly this, and the
guide describes it as preventing the server from preloading page modules into memory at startup.
Found by reading Next's own memory-usage guide rather than by inference — which is the lesson.
Deterministic per-stage measurements, identical across both runs on each side:
| stage | source loaded | files | heapUsed |
| --- | --- | --- | --- |
| boot idle | 24.0 → **10.6 MiB** | 1114 → **706** | 70.7 → **44.8 MiB** (−37%) |
| post-login | 27.9 → **19.3 MiB** | 1215 → **884** | 123.8 → **109.8 MiB** |
| board loaded | 29.0 → **21.7 MiB** | 1323 → **1006** | run-to-run noise |
| multi-tab | 29.2 → **22.9 MiB** | 1325 → **1015** | 139.7 → **127.8 MiB** |
| post-soak | 29.2 → **22.9 MiB** | 1325 → **1015** | 142.5 → **127.4 MiB** |
The last two rows are the important ones: **310 files and 6.3 MiB of source stay unloaded even
after a full multi-tab session**, so this is not merely deferred cost — routes a session never
visits are never paid for at all. Live heap is lower at every stage.
Peak node RSS does not move (350.3 → 346.1, spreads overlap), for the same reason the ssh2 chop
did not move it: V8 sizes its heap against available headroom, so a smaller live set does not
shrink peak RSS. Median board load is +3% with overlapping spreads, so the first-hit cost this
trades for did not show at this workload. Full E2E suite passes 8/8.
Also from that guide, and **not** applicable here: `webpackMemoryOptimizations`,
`serverSourceMaps`, `enablePrerenderSourceMaps` and disabling the Webpack cache all address
*build-time* memory, and this build uses Turbopack.
### `--jitless`: rejected, and the reason generalises
V8's `--jitless` removes the optimising compiler and all generated machine code. It applies
through `NODE_OPTIONS` and its effect on code memory is real and unambiguous: **code space
11.6 → 0.0 MiB**. The stress figures looked spectacular — peak node RSS −24%, peak container
−36%, CPU −14%, median board load 30% *faster*.
Three of those are impossible. The flag was applied to the **server**, so it cannot make the
browser lighter, and removing an optimising compiler does not make code faster. The client
metrics show what actually happened:
| | normal | jitless |
| --- | --- | --- |
| DOM nodes | 8,256 / 8,280 | **5,442 / 5,442** |
| JS event listeners | 7,781 / 7,784 | **1,570 / 1,570** |
| browser JS heap | 50.0 / 49.5 MiB | 28.1 / 28.2 MiB |
| widget controls available | 244 | 216 |
Boards still reported 28/28 widgets ready, so the coarse readiness check passed — but the page
carries **80% fewer event listeners**. Much of the UI never mounted. The memory "win" is an app
doing less work, and the CPU and latency "improvements" are the same artefact.
The generalisable tell: **when a server-side change moves a client-side metric, the workload
changed and the comparison is void.**
Flags that cannot be used this way at all: `--optimize-for-size`, `--no-opt` and `--lite-mode`
are rejected by Node with "not allowed in NODE_OPTIONS".
## Closing the last gap: what the native bucket at peak actually is
At peak the `smaps` bucket "other anonymous" holds **118.8 MiB** and was the least understood
part of the footprint. It is now mostly accounted for:
| component | MiB | how it was established |
| --- | --- | --- |
| V8 `large_object_space` | 34.5 | `heapTotal 188.0 − the 256 KiB pages 153.1 = 34.9`, matching the space report |
| `external` — Buffers/ArrayBuffers | 24.6 | `process.memoryUsage()` |
| freed-but-unreturned V8 zone memory | ~38 | `peak_malloced_memory` 4.6 → **42.2 MiB** at the board-loaded stage, while *current* `malloced_memory` stays at 1.0 MiB |
| unattributed native malloc | ~22 | remainder |
The zone figure is the interesting one. V8 mallocs ~38 MiB of parser/compiler zone memory while
compiling the board's modules, frees it back to malloc, and **musl never returns it to the
kernel** — so it stays resident as anonymous memory with nothing alive in it.
### A prediction that was wrong
The obvious inference — loading fewer files means less compiler zone memory, so "ship less
server JS" pays twice — **does not hold**. `peak_malloced_memory` measured across the
`preloadEntriesOnStart` A/B:
| stage | before ×2 | after ×2 |
| --- | --- | --- |
| boot idle | 4.5, 4.5 | 4.5, 4.5 |
| post-login | 4.5, 4.5 | 4.5, 4.5 |
| board loaded | 42.1, 42.3 | 42.1, 42.1 |
Identical, despite 310 fewer files being loaded. The 38 MiB spike is a **fixed cost incurred at
board load**, not a per-file one — one large parse or compile, not the sum of many. So the
"pays twice" claim is withdrawn; `preloadEntriesOnStart` saves retained source and live heap, and
does nothing for the native bucket.
### The zlib-orphan hypothesis: disproven here
Next's own source documents a real leak class: a zlib stream left open by a client disconnect is
pinned by its native handle, survives GC, and permanently retains ~256 KiB of deflate state.
`releaseCompressionStream`, the fix, is **not present** in this Next build, and a peak heap
snapshot showed 16 `Zlib` and 15 `Gzip` objects — 14 still there at idle, which looked exactly
like accumulation.
It is not. `process.getActiveResourcesInfo()` reports **Zlib=0**, both at baseline and after
**300 deliberately aborted gzip requests**, with RSS flat at 136.5 MiB. Those snapshot entries
are heap objects, not live handles — an important distinction, since a snapshot happily lists
objects with no live native resource behind them. nginx buffers the response from Next, so Next
never observes the mid-response client disconnect that creates an orphan in the first place.
Recorded because the initial reading of the snapshot was wrong in a way that would have shipped a
fix for a leak this deployment does not have.
## The 38 MiB of compiler zones: attributed, then found to be unaddressable
The largest single item in the peak breakdown that was never explained is now explained, and then
closed off.
**It is V8's optimising compiler.** `peak_malloced_memory` jumps from 4.5 MiB to 42.1 MiB the
moment a board's modules are compiled, while *current* `malloced_memory` stays at 1.0 MiB — memory
V8 takes for compilation, frees back to malloc, and that musl never returns to the kernel. Proven
by re-reading the `--jitless` runs, where the spike **never happens at all**:
| stage | normal | `--jitless` |
| --- | --- | --- |
| boot idle | 4.5 MiB | 4.5 MiB |
| **board loaded** | **42.1 MiB** | **4.5 MiB** |
| multi-tab | 42.7 MiB | 4.5 MiB |
`--jitless` itself is unusable (it leaves the page under-rendered — see above), but V8 can cap the
*tier* instead of removing code generation: `--max-opt=3` is the default Turbofan, `2` stops at
Maglev, `1` at Sparkplug. It is rejected by `NODE_OPTIONS` but accepted on the command line, so it
was wired behind `HOMARR_MAX_OPT` and A/B'd on one image.
**`--max-opt=2` is a null result for memory:**
| stage | full | capped at Maglev |
| --- | --- | --- |
| peak anon | 276.0 MiB | 282.8 MiB (+2%) |
| boot idle | 64.0 MiB | 72.3 MiB (+13%) |
| board loaded | 186.1 MiB | 179.2 MiB (−4%) |
| 4 tabs | 270.8 MiB | 238.5 MiB (−12%) |
| after soak | 199.0 MiB | 214.3 MiB (+8%) |
| CPU | 28.1 s | 27.8 s (unchanged) |
Render integrity was checked explicitly this time, because that is how `--jitless` was caught:
listeners were **7,784 in all four runs** and DOM nodes within 40 of each other, so unlike jitless
the page rendered fully and the comparison is valid. There is simply no consistent memory win —
some stages better, some worse, peak unchanged.
The mechanism says why, and it is worth recording as a closed door. Measured directly with the
probe under `--max-opt=2`:
```
01-boot-idle peak_malloced= 4.5 MiB
03-post-login peak_malloced= 4.5 MiB
04-board-loaded peak_malloced= 42.1 MiB <- identical to Turbofan
```
**Maglev's zone allocation is just as large as Turbofan's.** Capping the tier does not reduce zone
memory; only not compiling at all does, and that breaks the page. So this 38 MiB is not reachable
through V8 configuration — it is proportional to how much JavaScript V8 has to compile, which puts
it back under the one remaining server-side lever: ship less server code. Reverted.
## Correction: the "16 MiB tRPC subtree" lever was already removed
Earlier rounds identified a **16.10 MiB subtree of 460 files reached by every API route** and named it
the one remaining lever with real headroom, attributing it to the tRPC router importing every
integration eagerly. Both halves of that are now known to be wrong.
**The integration creator was already fully lazy.** Every kind in
`packages/integrations/src/base/creator.ts` is `() => import(...)`, so no integration class is loaded
until one is constructed. The earlier "integrations barrel diet" had already done this properly; the
claim that the router imports them eagerly was never checked against the code.
**The subtree itself was an artefact of route preloading.** That measurement was taken before
`preloadEntriesOnStart: false` shipped, when Next loaded the module graph of all 74 routes at startup.
Re-measured on the current build, boot is **706 files and 10.56 MiB of source**, and it breaks down as:
| owner | size | files | can it be reduced? |
| --- | --- | --- | --- |
| `.next/server/chunks` (app code) | 3.73 MiB | 28 | this is the app's own boot graph, already lean at 28 chunks |
| better-sqlite3 | 2.16 MiB | 14 | no — 2.1 MiB of it is the native `.node` binary, and it is the default driver |
| next/dist (framework) | 1.75 MiB | 359 | no — framework, and flat regardless of load |
| next/dist/compiled (vendored) | 1.58 MiB | 25 | no |
| **mysql2 + pg** | **0.52 MiB** | 90 | yes in principle, but structurally blocked — see REJECTED.md |
| everything else | < 0.5 MiB | — | noise |
So there is no single large server-side lever left. What remains is diffuse: under a full multi-tab
session the app loads **15.44 MiB of its own chunks** across 241 files, and that is what the ~41 MiB of
retained source and the ~38 MiB of compiler zones both scale with. Reducing it means shipping less
application JavaScript per route — a broad refactor with no single target, not a 16 MiB subtree waiting
to be cut.
-299
View File
@@ -1,299 +0,0 @@
# Widget-by-widget audit
Every widget in `packages/widgets/src`, worked through one at a time. 50 widgets, 22,726 lines.
`_inputs/` is excluded on purpose, not overlooked. Its 2,748 lines are widget *option* editors, reached
only from the edit modal and the custom-widget form; the one widget that imports from it,
`timetable/component.tsx`, uses `import type`, which is erased at build. None of it is on the board
render path. The one real finding there — a drag handle rebuilt every frame of a drag — was found and
fixed by the anti-pattern audit, which does cover the directory.
The point of doing this exhaustively rather than opportunistically: the two largest client wins so far
(`OverflowBadge`, `content-visibility` on media-releases) were both found in widgets nobody suspected,
and both turned out to be *shared* problems that repeated across many widgets. A sweep finds the
pattern; a spot-check finds the instance.
## How the list is built
`scripts/benchmarks/widget-inventory.mjs` reads every widget and records the signals that predict a
render problem — rows multiplied by `.map()`, overlays, image sites, whether a scrolling list renders
lazily, memoisation, live subscriptions, refetch interval — then ranks by a weighted score that
prefers repetition over size. A per-row overlay in a scrolling list outranks a large file that renders
once.
Every column is a **signal, not a defect**. A widget with 6 maps and no memoisation is perfectly fine
if it renders three rows. The ranking decides reading order; the verdict comes from reading the code.
Two companion scripts: `audit-widget-render.mjs` finds specific anti-patterns and ranks them by
whether they sit inside a `.map()`, and `measure-widget-cost.mts` counts components and fibers per
widget on a real board.
## The same cost in `Accordion`, which this sweep missed
Credit where it is due: this came from reading [PR #6582](https://github.com/homarr-labs/homarr/pull/6582),
which adds `keepMounted={false}` to the two coolify accordions. The sweep below checked `Tabs` and never
checked `Accordion`, so it missed the problem entirely on five sites.
**`Accordion` behaves exactly like `Tabs`: collapsed panels render their children.** Verified in the
installed version — with three marker children per panel, the default renders **3 of 3** children of the
*collapsed* panel, and `keepMounted={false}` renders **0**.
It matters more here than in `Tabs`, because collapsed is the *normal* state of an accordion section
rather than the exception:
| widget | collapsed panels were building | verdict |
| --- | --- | --- |
| `coolify` instance-card | three remote-data lists — servers, applications, services | **fixed** |
| `coolify` single-instance-layout | same three lists | **fixed** |
| `health-monitoring` cluster-health | per-node resource tables | **fixed** — beyond what #6582 did |
| `firewall` | interface rows | **fixed** — beyond what #6582 did |
| `_inputs/widget-multiReleasesRepositories-input` | repository picker | left mounted — it holds selection state, and it is edit-modal only, not on the board render path |
In every fixed case the open/closed state lives in the parent component, so unmounting a collapsed
panel discards nothing.
This also corrects three verdicts in the table below: `coolify` was recorded as "clean (bounded)" and
`firewall` and `health-monitoring` as triaged/fixed on other grounds. All three were carrying this cost.
### What else #6582 had, and what was declined
Mining the rest of that PR's perf-shaped hunks:
| their change | taken? |
| --- | --- |
| `keepMounted={false}` on 2 accordions | **taken, and extended to 4 sites** — above |
| `loading="lazy"` on the `iframe` widget | **taken** — an iframe is a whole browsing context, so one scrolled off a tall board is the most expensive thing a widget can load eagerly |
| `loading="lazy"` on media-releases and rssFeed | already done here |
| `dynamic()` for `BoardItemMenu` | **declined, and it does not work as written.** `BoardItemMenu` already early-returns `null` outside edit mode, so there is no overlay-per-item cost to begin with — but it is *rendered* unconditionally, and `dynamic()` fetches its chunk when the component renders, not when it returns something. So the chunk still loads on every board render. It would only pay off with the `isEditMode` guard hoisted to the call site, which is a board-rendering change worth measuring first |
| `loading="lazy"` on the immich carousel | declined — the carousel's image *is* the visible content, so there is nothing off-screen to defer |
| `memo()` on `item-content` / `grid-editor` | not extractable — entangled with that PR's new advanced-focus feature, portals and viewport hooks, not a standalone perf change |
| 5 `<Suspense>` boundaries | mostly in that PR's new components; not applicable here |
## The systemic finding this pass: inactive tab panels render in full
**Mantine's `Tabs` keeps inactive panels mounted by default.** In Mantine 9 on React 19 that is
implemented with `<Activity>`, which preserves state and unmounts effects but *still builds the entire
subtree*. So a widget with two tabs pays for both, forever, while showing one.
Four widgets use `Tabs`. Two were costing real work and are now `keepMounted={false}`; two must keep
their panels mounted and are left alone:
| widget | panels | verdict |
| --- | --- | --- |
| `health-monitoring` | system view + cluster view | **fixed** — two live monitoring views were both built; only one is ever visible |
| `media-missing` | missing + queued media cards | **fixed** — both tabs built their full card list, each card with a poster and a blurred backdrop |
| `custom-api` | user-authored JSX | left mounted — panels can hold user component state, which unmounting would discard |
| `widget-edit-modal` | form sections | left mounted — unmounting would reset half-filled form fields |
`health-monitoring` is the worse of the two: its panels are the system and cluster monitors, so a
board showing one was building the other's whole tree as well.
**The premise is verified, not inferred.** This started as a reading of Mantine's documentation, which
is a weak basis for a prop that trades a remount cost for a mount saving — if Mantine ever stopped
rendering inactive panels, the prop would be pure loss. `common/keep-mounted.spec.ts` pins the
behaviour in the installed version: with three marker children per panel, the default renders **3 of 3**
children of the *inactive* panel into the DOM, and `keepMounted={false}` renders **0**. If that ever
changes, the test fails and the prop should come back out.
What remains unmeasured is the *magnitude* on a live board — `media-missing` and `health-monitoring`
need reachable integrations to render data, so neither could go through the per-widget component-count
harness. The mechanism is confirmed; the size of the saving is not.
## Changes made
| widget | change | why |
| --- | --- | --- |
| `health-monitoring` | `keepMounted={false}` on `Tabs` | the hidden monitoring view was fully built |
| `media-missing` | `keepMounted={false}` on `Tabs` | both tabs built every card |
| `coolify` ×2, `health-monitoring` cluster, `firewall` | `keepMounted={false}` on `Accordion` | collapsed panels built their lists; collapsed is an accordion's normal state |
| `media-missing` | `offscreenRowStyle(CARD_HEIGHT[density])` | renders every card into a scrolling tile; the card height is fixed per density, so the intrinsic size is exact rather than a guess |
| `media-missing` | `loading="lazy"` on the poster | a poster per card, all fetched regardless of scroll position |
| `calendar` | inline SVG instead of `placehold.co` | a failed poster made a **third-party network request** from a self-hosted dashboard, once per failing event, to draw a grey square |
| `rssFeed` handler | logger instead of raw `console.debug` | logged the entire serialised feed entry per entry, unsuppressable by log level |
The `placehold.co` fallback is the one I would call a bug rather than a cost: it fails on any install
without internet access, which is a normal way to run Homarr.
## The checklist
Verdicts, and the evidence behind each — the distinction matters, because "I read it" and "its list
cannot get long" are different strengths of claim:
- **fixed** — changed here or in an earlier pass
- **clean (read)** — the component was read; nothing worth changing
- **clean (bounded)** — list length verified bounded by a widget option, by viewport width, or by the
remote service (active streams, configured instances, physical disks), *and* the row is cheap:
no image, no overlay. Not a line-by-line read
- **triaged** — a finding was examined and deliberately declined, reason recorded
| ✓ | # | widget | LOC | verdict | note |
| --- | --- | --- | --- | --- | --- |
| [x] | 1 | `downloads` | 1525 | clean (read) | top of the ranking, and the flag was wrong: it memoises data, sorting, columns, sizes and stats. Renders via `DataTable`; list is bounded by active downloads |
| [x] | 2 | `media-transcoding` | 521 | triaged | 2 overlays per row, both functional tooltips with real labels |
| [x] | 3 | `dns-hole` | 761 | triaged | icons render through `MaskedOrNormalImage`, whose default branch is a CSS mask, so `loading="lazy"` does not apply |
| [x] | 4 | `patchmon` | 1154 | triaged | per-row tooltip is functional |
| [x] | 5 | `beszel` | 1186 | fixed | disk-usage mounted a `HoverCard` per row for systems reporting no filesystems |
| [x] | 6 | `health-monitoring` | 1250 | **fixed** | hidden tab panel built its whole tree |
| [x] | 7 | `media-requests` | 542 | fixed | `content-visibility` + `loading="lazy"` on both images |
| [x] | 8 | `tracearr` | 445 | clean (read) | poster is a CSS background per stream card, and the list is active streams — a handful |
| [x] | 9 | `custom-api` | 1014 | triaged | 6 index keys, all safe: stateless read-only rows from user JSONPath data with no stable id |
| [x] | 10 | `firewall` | 334 | **fixed** | its `Accordion` built the interface rows while collapsed. The 4 inline-style-per-row findings stay declined: children are not memoised, so nothing is defeated |
| [x] | 11 | `releases` | 817 | fixed | `OverflowBadge` mounted a `Popover` per row even with nothing to overflow |
| [x] | 12 | `coolify` | 752 | **fixed** | its `Accordion` built all three resource lists while collapsed; the icon is a fixed constant URL, not per-row data |
| [x] | 13 | `notebook` | 1189 | clean (read) | rich-text editor. Deliberately **not** given `content-visibility`: it measures itself, and skipping its layout would be wrong |
| [x] | 14 | `umami` | 595 | clean (read) | the top-list is bounded by the widget's own `limit` option and each row is three `<Text>` — containment would cost more than it saves |
| [x] | 15 | `traefik` | 289 | clean (read) | already bounded: `.slice(0, getEntryPointLimit(width))` |
| [x] | 16 | `weather` | 481 | triaged | per-row overlay is a functional tooltip |
| [x] | 17 | `system-resources` | 568 | clean (read) | the 14 maps build chart data series, not DOM rows, and none of the six chart components is memoised — so memoising the arrays would defeat nothing. Its data changes every tick anyway |
| [x] | 18 | `app` | 356 | triaged | icon is a masked image; single icon per widget, not a list |
| [x] | 19 | `beszel-alerts` | 288 | clean (read) | fully memoised — input, name map, alerts and history — and history is sliced to `options.maxHistoryItems` |
| [x] | 20 | `beszel-system-stats` | 220 | clean (bounded) | one row per configured system |
| [x] | 21 | `media-missing` | 261 | **fixed** | tabs, lazy poster, off-screen cards |
| [x] | 22 | `media-server` | 457 | clean (read) | the flat session list is explicitly memoised on `currentStreams`, and is bounded by active streams |
| [x] | 23 | `bookmarks` | 545 | triaged | 5 image sites, all masked images |
| [x] | 24 | `immich` | 301 | clean (bounded) | carousel shows one photo at a time |
| [x] | 25 | `ups` | 271 | clean (bounded) | one row per UPS device |
| [x] | 26 | `docker` | 590 | fixed | uses `OverflowBadge` |
| [x] | 27 | `indexer-manager` | 150 | clean (bounded) | one row per configured indexer |
| [x] | 28 | `vpn` | 227 | clean (bounded) | one row per tunnel |
| [x] | 29 | `calendar` | 507 | **fixed** | `HoverCard` per day cell (126 of 171 components), lazy images, off-screen event rows, and the `placehold.co` fallback |
| [x] | 30 | `speedtest-tracker` | 707 | clean (bounded) | no list map in the component |
| [x] | 31 | `system-disks` | 196 | clean (bounded) | one row per physical disk |
| [x] | 32 | `minecraft` | 91 | clean (bounded) | one server icon |
| [x] | 33 | `paperless-ngx` | 255 | clean (bounded) | statistic rows, fixed set |
| [x] | 34 | `uptime-kuma` | 268 | clean (bounded) | one row per monitor |
| [x] | 35 | `iframe` | 148 | clean (bounded) | maps are over option lists, not remote data |
| [x] | 36 | `stocks` | 146 | clean (bounded) | one row per configured symbol |
| [x] | 37 | `beszel-system-grid` | 496 | clean (bounded) | already slices its input |
| [x] | 38 | `audio-stats` | 305 | clean (bounded) | no list map in the component |
| [x] | 39 | `bazarr` | 141 | clean (bounded) | one row per configured instance |
| [x] | 40 | `beszel-system-table` | 488 | fixed | declared `PercentCell` inside the render body, so three cell subtrees per system remounted every second |
| [x] | 41 | `clock` | 213 | clean (read) | re-renders once a second by design; no list |
| [x] | 42 | `media-releases` | 275 | fixed | 80 cards into a 539 px tile; `OverflowBadge`; lazy posters |
| [x] | 43 | `network-controller` | 231 | clean (bounded) | one row per device |
| [x] | 44 | `timetable` | 110 | clean (bounded) | one map over the configured schedule |
| [x] | 45 | `anchor-note` | 340 | clean (read) | editor content, no list; self-measuring like `notebook` |
| [x] | 46 | `notifications` | 113 | fixed | off-screen rows |
| [x] | 47 | `rssFeed` | 131 | fixed | off-screen rows; payload and handler checked |
| [x] | 48 | `smart-home` | 235 | clean (bounded) | one entity per widget |
| [x] | 49 | `archive-team-warrior` | 105 | clean (bounded) | no list map |
| [x] | 50 | `video` | 144 | clean (bounded) | a single video element |
**All 50 have a verdict.** 11 fixed, 8 triaged, 10 read and found clean, 21 verified bounded.
The four the ranking suggested were most likely to hide something all came back clean, and three of
them were *already* bounded before I looked: `traefik` slices to what its width can show, `umami` to
its own `limit` option, `beszel-alerts` to `options.maxHistoryItems`. `system-resources` was the
interesting one — its 14 unmemoised maps look bad until you notice none of the six chart components it
feeds is memoised, so there is nothing for a stable array identity to preserve.
The 21 "bounded" verdicts are the weakest claim here. They rest on the list length being fixed by
configuration or by physical reality — disks, tunnels, configured instances — plus a cheap row with no
image and no overlay. That is sound reasoning, not a reading, and a widget whose data source changes
shape could invalidate it.
## Checked against Vercel's React/Next performance rules
Sourced with the `skills` CLI — `npx skills find "react performance"`, then
`npx skills use affaan-m/ecc@react-performance`, which is an adaptation of
[Vercel Labs `react-best-practices`](https://github.com/vercel-labs/agent-skills): 70+ rules across
8 priority categories. Every rule that is mechanically checkable was run against the widgets.
| rule | category | result |
| --- | --- | --- |
| `content-visibility: auto` for long lists | rendering | **applied** — 6 widgets |
| Don't define components inside components | re-render | **2 found, both fixed** (`PercentCell`, drag handle) |
| Ternary over `&&` for conditional render | rendering | **passes** — only 2 `&&` renders exist and both operands are already boolean or string, so nothing can render a literal `0` |
| Passive listeners for scroll | client fetching | **N/A** — no widget registers a scroll listener |
| `Set`/`Map` for membership over `Array.includes` | JS micro-perf | **passes** — the nested scans that exist are over configured integrations and column accessors, single digits either way. `use-persisted-table-layout` already uses a `Set` for the hot half |
| Hoist RegExp out of loops | JS micro-perf | **passes** — the only user-supplied pattern, the release version filter, is compiled once by `compileVersionRegex` before the `.filter()` that uses it |
| Statically analyzable dynamic imports | bundle | **one exception, justified** — `translation/src/mapping.ts` builds `import(`./lang/${language}.json`)` for 36 languages, 6.8 MiB of JSON. The rule targets accidental over-inclusion; here every language is genuinely reachable because the user can switch at runtime, so the context module includes exactly what is needed. Rewriting it as 36 explicit entries would emit the same 36 lazy chunks |
| `React.cache()` for per-request dedup | server | **applied** — 4 functions |
| TanStack Query for dedup, not hand-rolled `useEffect` + `fetch` | client fetching | **passes** — used throughout |
| Direct imports, not barrels | bundle | **partial** — see below |
| Hoist default non-primitive props | re-render | **declined** — see below |
| `<Activity>` for show/hide instead of mount/unmount | rendering | **deliberately contradicted** — see below |
### The one rule this codebase deliberately contradicts
The skill says to prefer React 19's `<Activity>` over unmounting for tabs, because it avoids
remount cost. `keepMounted={false}`, which this pass added to two widgets, is exactly the opposite.
That is a considered trade, not an oversight. The rule optimises for **switching**; a dashboard
optimises for **mounting**. Every board load mounts every widget, while a user switches a widget's
inner tabs rarely or never — so paying to build a hidden monitoring view on every single page load, and
holding its DOM for the whole session, buys a faster tab switch nobody performs. Where state loss
would be user-visible — `custom-api`'s user JSX, the edit modal's forms — the panels stay mounted.
The cost is real and worth stating: switching tabs in `health-monitoring` or `media-missing` now
remounts the panel. react-query still has the data cached, so it repaints rather than reloads.
### Bundle: the rule does not apply here at all
The "direct imports, not barrels" rule, and the whole `optimizePackageImports` lever behind it, is
**inert in this repo** — measured, then explained.
Seven Mantine entry points were missing from `optimizePackageImports`: `dates` (13 files), `dropzone`
(12), `charts` (10), `spotlight` (7), `form` (4), `notifications` (3), `tiptap` (1). Adding all seven
and rebuilding produced a client bundle that was **byte-for-byte identical** — 482 files, 18050.7 KiB
in both arms, down to the individual chunk hashes.
Identical output including hashes looks like a cached build, especially with
`turbopackFileSystemCacheForBuild` enabled, so it was checked instead of reported. The Next.js docs
settle it: *"This is not needed when using Turbopack. Turbopack automatically analyzes and optimizes
imports without requiring this configuration."* This app builds with Turbopack — the build banner says
so — and the config is dead weight.
Which also disposes of the sub-question underneath it. `@homarr/ui` is the largest barrel in the repo
at 111 importers, and I had noted it as ineligible because Next's barrel optimiser only rewrites pure
re-export files while `packages/ui/src/index.ts` also defines a runtime `uiConfiguration`. True, and
irrelevant: Turbopack does not use that optimiser and tree-shakes the barrel regardless.
Measuring first-load JS per route was not possible the usual way, incidentally: Next 16 + Turbopack
prints no size columns in the route table and emits no `app-build-manifest.json`. Total bytes under
`.next/static/chunks` is the substitute.
### `data = []` defaults: declined, with the reasoning
The rule against fresh non-primitive defaults (`items ?? []` creating a new array identity per render)
matches **20+ sites**, all of the form `const { data = [] } = useQuery(...)`.
It is declined because the window is narrow: react-query returns a stable reference once data has
loaded, so the fresh `[]` only appears while `data` is `undefined` — initial load, error, or no
integration configured. Outside that window the default never evaluates and nothing is destabilised.
20 edits to improve the render path of the loading state, with no measurement, is the same churn as the
29 inline-style sites already declined.
## Investigated and declined, with reasons
**Lazy-loading the shared `MaskedOrNormalImage`.** This looked like one change covering `releases`,
`bookmarks`, `dns-hole` and `app` at once. It is not: `hasColor` defaults to `true`, so the default
branch renders a CSS `mask-image`, not an `<img>`, and `loading="lazy"` has nothing to attach to. Only
the minority `hasColor={false}` branch would benefit. The row-level lever for masked icons is
`content-visibility`, not lazy loading.
**RSS payload size.** Feed entries looked like a likely over-fetch, since RSS `description` is often a
whole article. `@extractus/feed-extractor` truncates it at `descriptionMaxLen`, default **250**
characters, so the payload is already bounded. The router also limits entries twice — `count` at the
handler and `.slice(0, maximumAmountPosts)` after merging. No finding.
**Widget routers without result limits.** Only 4 of 39 widget routers limit their results, which reads
alarming, but the rest return data whose length is set by the remote service (containers, interfaces,
active downloads) rather than by anything unbounded. Not a defect on its own.
**Per-row inline styles (29 sites).** A fresh object per row only defeats memoisation if the child is
memoised, and these children are not. 29 edits with no measurement behind them is churn.
**Index keys (11 sites).** All safe — static text splits, or stateless read-only rows from
user-configured JSONPath data with no stable id, where an index key is correct and avoids remounts.
## One instrument corrected
The inventory first reported `downloads` — the top-ranked widget — as "12 maps, no memo", because it
counted `memo(` and not `useMemo(`. `downloads` is in fact one of the better-memoised widgets in the
codebase. Fixed to count either form before the ranking was used for anything.
That is the sixth time in this work that a measurement, rather than the application, was the thing at
fault — and this pass produced a seventh of a different kind: I reported that the `skills` CLI did not
exist on this machine after checking only `which` and the plugin directory. It does, and it is where
the Vercel rule set above came from. Both are recorded in [`METHODOLOGY.md`](./METHODOLOGY.md).
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,474 @@
# Intégration UniFi OS pour Homarr — conception
**Date :** 2026-09-08
**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0)
**Branche :** `feat/unifi-os-integration`
**Statut :** conception validée, prête pour le plan d'implémentation
> ⚠️ Aucune clé d'API ne figure dans ce document. Les clés utilisées pendant la
> conception ont été fournies hors dépôt et doivent être révoquées en fin de projet.
---
## 1. Objectif
Ajouter à Homarr une intégration **UniFi OS**, en complément — et non en
remplacement — de l'intégration `unifiController` existante.
L'intégration existante s'authentifie par identifiant/mot de passe et lit
`/api/s/{site}/stat/sites`, l'API interne de l'ancien Network Controller.
UniFi OS expose depuis la version 9 une **API d'intégration officielle**,
authentifiée par clé d'API, stable et documentée. C'est celle que cible ce
projet.
L'intégration existante n'est pas touchée. Les deux coexistent.
## 2. Décisions cadrées
| # | Décision | Justification |
|---|---|---|
| D1 | **Fork personnel**, pas de PR upstream | Choix de l'auteur. L'issue upstream [#4703](https://github.com/homarr-labs/homarr/issues/4703) demandait cette fonctionnalité et a été fermée par triage automatique, pas par refus technique : la porte reste ouverte pour plus tard. |
| D2 | **Hybride local + cloud optionnel** | L'API locale ne fournit ni latence ni perte de paquets. L'API cloud Site Manager les fournit. Le cloud reste facultatif pour que l'intégration fonctionne sans compte UI ni accès Internet. |
| D3 | **Widget dédié**, pas de réutilisation des widgets `networkController*` | Le contrat `NetworkControllerSummary` est taillé pour l'ancienne API. S'y conformer obligerait à inventer des valeurs. Un contrat propre évite ça. |
| D4 | **Déploiement par rsync du build standalone** | Le LXC exécute déjà un build standalone piloté par systemd. C'est le changement le plus petit possible, donc le plus facile à annuler. |
| D5 | **Structure client / mapping / orchestration séparés** | Modèle TrueNAS et Synology déjà présents dans le dépôt. Isole le cloud, optionnel et lent, de la partie locale, obligatoire et rapide. |
**Conséquence de D3 :** on ne modifie pas `NetworkControllerSummary`. Les champs
`latency` / `ping` restent `number` obligatoires pour `unifiController` et
`opnsense`. **Aucune régression possible sur les intégrations existantes.**
## 3. Constatations vérifiées sur l'API
Toutes les valeurs ci-dessous ont été relevées sur une console réelle
(UniFi Network **10.6.102**) le 2026-09-08, et non déduites de la documentation.
### 3.1 API locale — `{url}/proxy/network/integration/v1`
Authentification : en-tête `X-API-KEY`, clé générée **dans l'application Network**
(Paramètres → Control Plane → Integrations). Une clé du Site Manager cloud est
refusée avec un `401` sur cette API — les deux systèmes de clés sont distincts.
| Endpoint | Renvoie | Relevé |
|---|---|---|
| `GET /info` | `applicationVersion` | `10.6.102` |
| `GET /sites` | `id`, `internalReference`, `name` — **aucune statistique** | 1 site |
| `GET /sites/{id}/clients` | `type`, `access.type`, `name`, `ipAddress`, `macAddress`, `uplinkDeviceId`, `connectedAt` | 51 clients |
| — | **`limit` est plafonné à 200.** Une demande à 1000 renvoie `limit: 200`. | mesuré |
| `GET /sites/{id}/devices` | `state`, `model`, `macAddress`, `firmwareVersion`, `firmwareUpdatable`, `features` | 6 équipements |
| `GET /sites/{id}/devices/{id}/statistics/latest` | `uptimeSec`, `cpuUtilizationPct`, `memoryUtilizationPct`, `uplink.{tx,rx}RateBps` | — |
| `GET /sites/{id}/vpn/servers` | `type`, `enabled` | 1 serveur WireGuard actif — **non consommé en v1**, voir §12 |
| `GET /sites/{id}/wans` | `id`, `name` **uniquement** — inexploitable pour l'état | 2 WAN |
**Types de clients** (discriminant `type`) : `WIRED`, `WIRELESS`, `VPN`,
`TELEPORT`. Sous-objet `access.type` : `DEFAULT`, `GUEST`.
**Filtrage.** Supporté, avec une syntaxe **préfixe** :
```
✅ and(type.eq('WIRED'),access.type.eq('DEFAULT'))
❌ type.eq('WIRED') and access.type.eq('DEFAULT') → 400 invalid-filter
❌ type.eq('WIRED') & access.type.eq('DEFAULT') → 400 invalid-filter
❌ type.eq('WIRED'),access.type.eq('DEFAULT') → 400 invalid-filter
```
Combiné à `limit=1`, il permet d'obtenir un `totalCount` sans rapatrier les
enregistrements. Utilisé uniquement au-delà de 200 clients (voir §7).
**Écart avec la documentation.** Ubiquiti ne publie l'OpenAPI que pour
`v10.4.57`. La console en `10.6.102` renvoie des champs absents de ce spec
(`macAddress`, `uplinkDeviceId` sur les clients). **Corollaire : on code contre
les réponses réelles, validées par des schémas zod tolérants (`passthrough`),
et on ne génère pas de client depuis le spec publié.**
### 3.2 API cloud Site Manager — `https://api.ui.com`
Authentification : en-tête `X-API-KEY`, clé générée sur `unifi.ui.com`.
| Endpoint | Utilité |
|---|---|
| `GET /v1/sites` | Corrélation (`meta.gatewayMac`), `ispInfo`, IP publique par WAN, `percentages.wanUptime` |
| `POST /v1/isp-metrics/5m/query` | `avgLatency`, `maxLatency`, `packetLoss`, `uptime`, débits |
**Deux contraintes mesurées :**
1. `GET /v1/isp-metrics/5m` sans filtre renvoie **133 Ko** (288 points sur 24 h).
Il faut passer par `POST .../query` borné dans le temps.
2. Une fenêtre de 15 min renvoie un tableau **vide**. Une fenêtre de **3 h**
fonctionne. Le point le plus récent accuse un retard mesuré de **~27 minutes**.
Ces deux faits pilotent le design du cache (§7) et l'affichage de la fraîcheur (§8).
### 3.3 Corrélation locale ↔ cloud
Une clé Site Manager donne accès à **tous** les sites du compte UI, y compris des
consoles dont l'utilisateur n'est pas propriétaire. Le compte de référence en
exposait deux. Prendre les métriques cloud sans discriminer afficherait la
latence d'un autre réseau.
**Clé de jointure retenue : le MAC du gateway.**
```
LOCAL devices[] → gateway.macAddress aa:bb:cc:11:22:33
CLOUD sites[] → meta.gatewayMac aa:bb:cc:11:22:33 ✅ correspondance
CLOUD sites[] → meta.gatewayMac dd:ee:ff:44:55:66 ✗ autre console du compte
```
*(adresses anonymisées ; la correspondance a été vérifiée sur la console réelle)*
Retenu plutôt que le nom du site : un nom se renomme, un MAC non. **En l'absence
de correspondance, `unifi-sm-api.ts` renvoie `null` et n'essaie aucune
approximation.** Un manque visible vaut mieux qu'une donnée silencieusement fausse.
## 4. Contrat de données
```ts
export interface UnifiOsSummary {
site: {
id: string;
name: string;
applicationVersion: string;
};
wan: {
/** Dérivé de l'état du gateway côté local. */
state: "online" | "offline" | "unknown";
/** Débit temps réel de l'uplink du gateway, en bits/s. Local. */
throughput: { txBps: number; rxBps: number } | null;
/** Bloc cloud. `null` si la clé Site Manager n'est pas configurée
* ou si aucun site cloud ne correspond au MAC du gateway. */
isp: {
name: string;
externalIp: string | null;
/** Nullables : le cloud omet ces mesures pour les périodes où le WAN
* était coupé. Un `0` fabriqué s'afficherait comme « 0 ms » à côté d'un
* voyant vert, au moment précis où la connexion est tombée. */
latencyMs: number | null;
maxLatencyMs: number | null;
packetLossPct: number | null;
uptimePct: number | null;
/** Horodatage de la mesure. Rendu obligatoire : toute donnée cloud
* voyage avec sa fraîcheur. */
measuredAt: Date;
} | null;
};
/** `null` si l'appel a échoué. Surtout pas un compteur à zéro : le lecteur
* ne pourrait pas distinguer « échec » de « aucun client connecté ». */
clients: {
total: number;
wired: number;
wireless: number;
vpn: number; // VPN + TELEPORT
guests: number; // access.type === "GUEST", tous types confondus
} | null;
/** `null` si l'appel a échoué, pour la même raison. */
devices: {
total: number;
online: number;
offline: number;
firmwareUpdatable: number;
} | null;
gateway: {
name: string;
model: string;
macAddress: string;
uptimeSec: number | null;
cpuPct: number | null;
memoryPct: number | null;
} | null;
}
```
**`measuredAt` est obligatoire dans le bloc `isp`.** Le type impose que la
fraîcheur accompagne la donnée cloud : il devient impossible d'afficher une
latence sans savoir de quand elle date.
## 5. Architecture
### 5.1 Nouveaux fichiers
```
packages/integrations/src/unifi-os/
├── unifi-os-integration.ts # extends Integration — orchestre, mappe, dégrade
├── unifi-os-api.ts # client HTTP local
├── unifi-sm-api.ts # client HTTP cloud, optionnel, cache interne
├── unifi-os-types.ts # schémas zod + mappers purs
└── test/
├── unifi-os-types.spec.ts
└── unifi-os-integration.spec.ts
```
Chaque unité répond à une question unique :
- `unifi-os-api.ts` — *comment parler à la console locale ?* Endpoints,
pagination, résolution du `siteId`. Ne connaît pas Homarr.
- `unifi-sm-api.ts` — *comment parler au cloud ?* Fenêtre glissante de 3 h,
corrélation par MAC, cache interne. **Instancié uniquement si la clé cloud
est présente.**
- `unifi-os-types.ts` — *comment valider et transformer ?* Schémas zod et
fonctions pures. Aucun accès réseau, donc testable sans mock.
- `unifi-os-integration.ts` — *comment Homarr consomme tout ça ?* La seule
unité qui connaît la classe `Integration`.
### 5.2 Points d'attache dans le monorepo
| Fichier | Modification |
|---|---|
| `packages/definitions/src/integration.ts` | Ajouter le secret `siteManagerApiKey` à `integrationSecretKindObject` ; ajouter `"unifiOs"` à `integrationCategories` ; ajouter le kind `unifiOs` avec `secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]]`, `category: ["unifiOs"]`, `defaultPort: 443` |
| `packages/definitions/src/widget.ts` | Ajouter le `WidgetKind` `unifiOs` |
| `packages/integrations/src/base/creator.ts` | Enregistrer `unifiOs: UnifiOsIntegration` |
| `packages/integrations/src/types.ts` | Exporter `./unifi-os/unifi-os-types` |
| `packages/integrations/src/interfaces/unifi-os-summary/` | Interface `UnifiOsSummaryIntegration` |
| `packages/request-handler/src/unifi-os.ts` | `createIntegrationRequestHandler` |
| `packages/api/src/router/widgets/unifi-os.ts` + `index.ts` | Routeur tRPC |
| `packages/widgets/src/unifi-os/{index.ts,component.tsx}` | Définition + composant |
| `packages/widgets/src/{manifest.ts,index.tsx,refetch-intervals.ts}` | Enregistrement |
| `packages/translation/src/lang/en.json` | Libellés du widget + clé `integration.category.unifiOs` |
### 5.3 Pourquoi une catégorie dédiée, et pas `networkController`
`integrationCategories` n'est pas une étiquette de présentation, c'est un
mécanisme de câblage. `getIntegrationKindsByCategory("networkController")`
alimente trois endroits :
```
widget-integration-map.ts → intégrations proposées par les widgets networkController*
router/widgets/network-controller.ts → middleware tRPC
request-handler/network-controller.ts → typé IntegrationKindByCategory<"networkController">,
appelle getNetworkSummaryAsync()
```
Déclarer `unifiOs` dans cette catégorie la rendrait sélectionnable dans les
widgets de l'ancienne intégration **et** exigerait qu'elle implémente
`getNetworkSummaryAsync()` — l'interface que la décision D3 écarte
explicitement. Le typage `IntegrationKindByCategory` transforme cette
contradiction en erreur de compilation, pas en défaut silencieux.
On ajoute donc une catégorie `unifiOs`. Le dépôt a déjà un précédent de
catégorie mono-intégration : `beszel`. Une catégorie sans traduction ne casse
rien — `beszel` n'en a pas — mais on ajoute quand même
`integration.category.unifiOs`.
**Base d'URL.** L'utilisateur saisit l'origine de la console
(`https://console.exemple.tld`). Le client local préfixe systématiquement
`/proxy/network/integration/v1`. Ce préfixe appartient au client, pas à la
configuration : l'utilisateur n'a pas à le connaître.
**Précédent de conception validé :** le champ `secretKinds` accepte plusieurs
jeux alternatifs, et la classe de base expose `hasSecretValue()`. L'intégration
`ntfy` utilise déjà exactement ce motif (`[["topic"], ["topic", "apiKey"]]`).
La clé cloud optionnelle ne demande donc aucun mécanisme nouveau.
**Note TLS.** La classe de base `Integration` applique les certificats de
confiance de Homarr à l'URL de l'intégration uniquement. L'appel vers
`api.ui.com` sort de ce périmètre : c'est du HTTPS public à certificat valide,
un `fetch` standard suffit. Il ne doit **pas** passer par
`fetchWithTrustedCertificatesAsync`, pour ne pas laisser croire que les deux
appels partagent la même configuration.
## 6. Séquence d'un rafraîchissement
```
1. GET /info → applicationVersion
2. GET /sites → siteId, name
3. Promise.allSettled([
GET /sites/{id}/clients?limit=200 → comptage par type (voir §7)
GET /sites/{id}/devices → états, firmwares, gateway
])
4. GET /sites/{id}/devices/{gatewayId}/statistics/latest (si gateway trouvé)
5. si clé cloud présente :
unifi-sm-api.getIspMetricsAsync(gatewayMac) → cache 5 min
```
**`Promise.allSettled`, pas `Promise.all`.** Un endpoint en échec dégrade son
bloc à `null` ; il ne fait pas tomber le widget entier. L'étape 4 dépend de
l'étape 3 : sans gateway identifié, `gateway` et `wan.throughput` valent `null`
et le reste s'affiche normalement.
## 7. Cache et gestion d'erreurs
**Deux caches de rythmes différents**, parce que les deux sources n'évoluent pas
à la même vitesse :
| Couche | TTL | Motif |
|---|---|---|
| `createIntegrationRequestHandler` | 60 s | Les compteurs locaux sont vivants |
| Cache interne de `unifi-sm-api.ts`, clé = MAC gateway | 5 min | La donnée cloud ne change que toutes les 5 min et arrive avec ~27 min de retard |
Sans le second cache, on émettrait ~120 appels/h vers `api.ui.com` pour ~12
valeurs distinctes, avec un risque de limitation de débit. Le motif du cache
statique par instance est déjà utilisé par `TrueNasClient`.
**`fallbackToStaleOnError: true`** sur le handler. Une console injoignable
pendant quelques secondes affiche la dernière valeur connue au lieu d'une tuile
d'erreur — à condition que l'interface signale que la donnée est périmée (§8).
**Comptage des clients — règle explicite.** L'API plafonne `limit` à 200
(mesuré). Le comportement est donc conditionné par `totalCount`, renvoyé dès la
première page :
| Condition | Stratégie | Coût |
|---|---|---|
| `totalCount ≤ 200` | La première page contient tout. Comptage en mémoire par `type` et `access.type`. | 1 requête |
| `totalCount > 200` | Bascule sur des requêtes filtrées `limit=1`, dont on ne lit que `totalCount`. | 5 requêtes de taille fixe |
La seconde branche utilise la syntaxe préfixe du §3.1, par exemple
`and(type.eq('WIRELESS'),access.type.eq('GUEST'))`.
On ne pagine **pas** en boucle : au-delà de 200 clients, rapatrier tous les
enregistrements pour n'en garder que des compteurs coûte plus cher que cinq
requêtes bornées, et la charge croîtrait avec la taille du réseau.
*Écrire les deux branches maintenant est justifié : ce n'est pas de
l'optimisation spéculative mais la gestion d'une limite d'API mesurée, qui
produirait sinon des compteurs silencieusement faux au-delà de 200 clients.*
## 8. Widget
**Disposition retenue : bandeau WAN dominant + lignes** (option B des maquettes).
```
┌────────────────────────────────────────────────┐
│ ● Nom du site │
│ ┌────────────────────────────────────────────┐ │
│ │ ● Internet ↑14,1 ↓3,3 Mb/s │ │
│ │ Nom FAI · 203.0.113.10 │ │
│ │ 11 ms · 0 % perte [27min]│ │
│ └────────────────────────────────────────────┘ │
│ Clients 51 · 44 filaire · 7 Wi-Fi │
│ Équipements 6 / 6 en ligne │
│ ● Firmware 3 à mettre à jour │
│ Network 10.6.102 Gateway up 11 j 16 h │
└────────────────────────────────────────────────┘
```
Justification : la hiérarchie visuelle suit l'ordre de consultation réel — on
regarde d'abord si Internet fonctionne, ensuite le détail.
**Le badge de fraîcheur n'est pas cosmétique.** Il porte deux rôles distincts :
signaler le retard structurel des métriques cloud, et signaler une donnée servie
depuis le cache périmé après une erreur. Sans lui, le widget juxtaposerait des
données de fraîcheurs très différentes sans le dire.
**Comportement dégradé :**
| Situation | Rendu |
|---|---|
| Clé cloud absente | Bloc ISP masqué. Le bandeau garde l'état WAN et le débit. |
| Cloud injoignable | Idem, plus un indicateur discret. |
| Aucune correspondance de MAC | Bloc ISP masqué. Jamais la latence d'un autre site. |
| Gateway introuvable | `throughput`, `uptime`, CPU/RAM masqués ; clients et équipements affichés. |
| Console injoignable | Dernière valeur connue + badge périmé, puis tuile d'erreur au-delà du TTL. |
**Taille.** `WidgetDefinition` n'expose aucune contrainte de taille minimale :
l'adaptation est une affaire de CSS dans le composant. Le bandeau passe en deux
lignes en tuile étroite, et les lignes de détail se réduisent à leurs chiffres.
**Rafraîchissement.** Entrée dans `widgetQueryRefetchIntervals` avec
`intervalSeconds: 60`. Les widgets `networkController` sont à `null` — pas de
rafraîchissement automatique — mais les compteurs de clients ont une valeur
d'usage qui justifie l'écart.
## 9. Tests
| Niveau | Fichier | Couvre | Réseau |
|---|---|---|---|
| Unitaire | `unifi-os-types.spec.ts` | Comptage par type et par `access.type`, agrégation d'états, conversion bps, corrélation par MAC **y compris le cas sans correspondance**, tolérance aux champs inconnus | Non |
| Intégration | `unifi-os-integration.spec.ts` | Séquence complète sur fixtures capturées, dégradation par bloc, absence de clé cloud, endpoint en échec | Mocké |
| Manuel | script hors CI | Vérification contre une console réelle | Oui |
Le niveau unitaire est le plus important : c'est là que vivent les erreurs de
comptage, et c'est le seul qui reste valable si Ubiquiti fait évoluer ses
endpoints.
**Fixtures.** Capturées depuis la console réelle, puis **anonymisées** —
adresses MAC, IP publique, noms de clients et identifiants de site remplacés.
Un dépôt ne doit pas porter la cartographie d'un réseau privé.
**Multi-site.** Une URL locale désigne une console, et l'intégration se limite au
premier site qu'elle expose. Une console peut techniquement en héberger
plusieurs : le widget affiche donc le **nom du site** retenu, pour que ce choix
soit visible plutôt que subi. Deux tests couvrent les bornes — une console sans
aucun site renvoie une erreur explicite au test de connexion, et le nom du site
figure dans le contrat.
L'ancienne intégration agrégeait tous les sites, ce qui avait produit l'issue
[#3222](https://github.com/homarr-labs/homarr/issues/3222) (valeurs nulles en
multi-site). Ne pas agréger supprime la classe de défaut plutôt que de la
tester.
## 10. Phasage
Chaque phase se vérifie seule. C'est ce qui permet de savoir de quel côté
chercher quand quelque chose casse.
| Phase | Contenu | Critère de validation |
|---|---|---|
| 0 | Fork, `corepack enable`, `pnpm docker:dev:up`, `pnpm dev` | Homarr démarre en local |
| 1 | `unifi-os-api.ts`, `unifi-sm-api.ts`, types et mappers, tests unitaires | `pnpm test` vert, sans réseau |
| 2 | `UnifiOsIntegration` + enregistrement | Le test de connexion Homarr passe dans l'interface |
| 3 | request-handler + routeur tRPC | La query renvoie les vraies valeurs |
| 4 | Widget + traductions `en` | Le widget s'affiche sur un board local |
| 5 | Build standalone + rsync vers le LXC | Le widget s'affiche en production |
À l'issue de la phase 2, toute la plomberie est prouvée. Une panne en phase 4
est donc nécessairement côté interface.
## 11. Déploiement
Cible : LXC Ubuntu 24.04, `10.70.1.13`, 2 vCPU / 2 Go. Homarr y tourne en build
standalone sous systemd (`/opt/homarr`, `homarr.service`,
`EnvironmentFile=/etc/homarr/homarr.env`), avec nginx et Redis embarqués par
`run.sh`.
**Le build se fait sur le poste de développement** (16 cœurs, 60 Go). Le LXC ne
peut pas compiler ce monorepo Next.js.
Procédure :
1. `tar` de `/opt/homarr` **et** dump de la base.
2. `systemctl stop homarr`.
3. `rsync` du build standalone.
4. `systemctl start homarr`, puis vérification des logs.
Rollback : restauration de l'archive et redémarrage.
**Contrôle préalable obligatoire :** comparer les migrations de
`packages/db/migrations/` avec `/opt/homarr/db/migrations/`. Elles sont
alignées à `0041_add_right_click_preference.sql` au 2026-09-08. Une divergence
rendrait le rollback nettement plus complexe qu'une restauration d'archive.
**`SECRET_ENCRYPTION_KEY`** dans `/etc/homarr/homarr.env` chiffre les secrets
d'intégration en base. Le rsync ne touche pas ce fichier, mais toute
modification rendrait les intégrations existantes illisibles.
## 12. Hors périmètre
Volontairement exclus de cette spec :
- **Widget cloud multi-site** (SD-WAN, fabric, vue consolidée de plusieurs
consoles). Idée retenue pour un projet ultérieur, qui réutilisera
`unifi-sm-api.ts`. Sa mention ici sert à ne pas le construire maintenant.
- **État des serveurs VPN.** L'endpoint existe et fonctionne, mais le contrat §4
n'expose pas de champ pour lui et la disposition retenue au §8 ne l'affiche pas.
Le collecter sans l'afficher serait du code mort.
- **Actions d'écriture** (adoption d'équipement, redémarrage de port, gestion de
vouchers). L'API les expose ; l'intégration reste en lecture seule.
- **Traduction au-delà de l'anglais.** Conséquence de D1.
- **Documentation Docusaurus.** Conséquence de D1.
- **Pull request upstream.** Conséquence de D1 ; la structure du code ne
l'interdit pas plus tard.
- **Modification de l'intégration `unifiController`.** Elle reste en l'état.
## 13. Risques
| Risque | Portée | Traitement |
|---|---|---|
| L'OpenAPI publié (`10.4.57`) est en retard sur les consoles | Élevée — déjà constaté | Schémas zod tolérants, code écrit contre les réponses réelles |
| La clé Site Manager donne accès à tout le compte UI | Sécurité | Cloud optionnel ; corrélation stricte par MAC ; clé de conception à révoquer |
| Retard de ~27 min sur les métriques cloud | Interface | `measuredAt` obligatoire dans le contrat, badge de fraîcheur dans le widget |
| Un fork diverge à chaque version de Homarr | Maintenance | Modifications concentrées dans un dossier neuf ; seuls les points d'attache du §5.2 touchent l'existant |
| Limitation de débit sur `api.ui.com` | Fiabilité | Cache interne 5 min |
| Le déploiement casse l'instance de production | Exploitation | Sauvegarde avant rsync, contrôle des migrations, rollback par restauration |
@@ -0,0 +1,399 @@
# Intégration UniFi Site Manager pour Homarr — conception
**Date :** 2026-09-09
**Base :** homarr-labs/homarr `2b1a03849` (v1.77.0)
**Statut :** conception validée, prête pour le plan d'implémentation
**Précède :** `2026-09-08-unifi-os-integration-design.md` (intégration mono-site, locale + cloud optionnel)
> ⚠️ Aucune clé d'API, adresse MAC réelle, IP publique ou nom de site réel ne
> figure dans ce document.
---
## 1. Objectif
Ajouter une intégration **UniFi Site Manager**, distincte de `unifiOs`, lisant
**uniquement** l'API cloud `https://api.ui.com`, et un widget affichant une vue
d'ensemble **multi-sites**.
C'est ce que `unifiOs` ne peut structurellement pas faire : son URL désigne une
console, donc un site. Le Site Manager voit tous les sites d'un compte.
## 2. Décisions cadrées
| # | Décision | Justification |
|---|---|---|
| D1 | **Deux intégrations indépendantes** | `unifiOs` garde sa clé cloud optionnelle pour son bloc ISP ; `unifiSiteManager` a la sienne. Chacune reste installable et supprimable seule. Coût accepté : la clé est saisie deux fois si l'utilisateur emploie les deux, et deux appelants interrogent le cloud. |
| D2 | **Vue d'ensemble des sites d'abord** | Les captures fournies montrent trois vues — liste des sites, métriques ISP historisées, topologie SD-WAN. Ce sont trois projets. Celui-ci construit aussi le socle (intégration, client, routeur) dont les deux autres dépendront. |
| D3 | **Tableau, la bande en colonne** | Réutilise `HomarrDataTable`, le composant partagé déjà employé par les widgets beszel, docker et downloads. Tri par colonne gratuit, visibilité responsive documentée par la skill `datatable-migration` du dépôt, et lisible bien au-delà de dix sites. C'est aussi la forme qu'emploie l'interface Ubiquiti. La bande devient une colonne parmi d'autres : elle ne domine plus la lecture, mais elle reste présente et le widget tient à l'échelle. |
| D4 | **La bande « Internet Health » EST dans la v1** | Décision inversée en cours de conception. Elle avait été écartée au motif que `internetIssues` est un tableau creux dont il faudrait reconstituer les périodes saines. C'est vrai de cette source — mais `isp-metrics` fournit la même information **déjà dense** (§3.3). La bande coûte alors une boucle de coloration, pas un moteur de graphe. |
| D6 | **État SD-WAN par site, sans la topologie** | Ajouté après coup à la demande de l'utilisateur. L'état des tunnels — connecté, hors ligne, ping, erreurs — est indexé par `siteId` et se greffe donc sur les lignes existantes. La topologie (réseaux nommés, sous-réseaux) reste hors v1 : une liste de quinze réseaux n'a pas sa place dans une ligne de tableau. |
| D5 | **Rafraîchissement 5 min** | Les compteurs cloud n'évoluent pas plus vite. À contraster avec `unifiOs`, dont la source locale a justifié 5 s. |
## 3. Constatations vérifiées sur l'API
Relevées le 2026-09-09 contre `https://api.ui.com` avec une clé réelle.
### 3.1 Il n'existe pas d'API cachée
Une trentaine de chemins plausibles ont été sondés :
- **`/ea/*` est un alias complet de `/v1/*`** — mêmes payloads, mêmes tailles à
l'octet près. C'est le namespace « early access », pas une surface parallèle.
- `/v1/organizations`, `/orgs`, `/users`, `/me`, `/subscriptions`, `/licenses`,
`/backups`, `/tasks`, `/events`, `/topology`, `/clients`, `/networks`, `/wifi`,
`/health`, `/status`, `/version` : **tous 404**.
- `/v1/isp-metrics/{type}` n'accepte que `5m` et `1h`. `1d` et `24h` → 400.
**La surface documentée est toute la surface.** Aucune requête cachée à exploiter.
### 3.2 Ce que la v1 consomme
| Endpoint | Champs retenus |
|---|---|
| `GET /v1/sites` | `siteId`, `hostId`, `meta.desc`, `meta.name`, `statistics.percentages.wanUptime`, `statistics.ispInfo.name`, `statistics.wans[*].externalIp`, `statistics.counts.{totalDevice,offlineDevice,pendingUpdateDevice}` |
| `GET /v1/hosts` | `id`, `reportedState.deviceState`, `reportedState.firmwareUpdate` |
| `GET /v1/sd-wan-configs` | `id`, `name` — nécessaire pour atteindre le statut, qui est indexé par configuration |
| `GET /v1/sd-wan-configs/{id}/status` | `status.connections[].sites[]` → `siteId`, `status.{connected,offline,ping,errors}` ; `status.sites[]` → `siteId`, `errors`, `warnings` |
Corrélation site ↔ host par `hostId`, présent des deux côtés. Contrairement à
l'intégration `unifiOs`, aucune ruse par adresse MAC n'est nécessaire ici : la
clé de jointure est explicite.
### 3.3 Deux sources pour la santé, une seule utilisable
L'interface Ubiquiti affiche une barre « Internet Health ». Deux champs peuvent
la produire, et le choix entre eux décide du coût de la fonctionnalité.
**`statistics.internetIssues` — écarté.** Un tableau **creux** d'anomalies,
chacune portant un `index` de tranche de 5 min, et soit `highLatency` avec
`latencyAvgMs`/`latencyMaxMs`, soit `notReported` avec un `count`. L'index se
convertit par `index × 300` secondes — vérifié. Mais les périodes **saines** n'y
sont pas : il faudrait les reconstituer par différence avec la fenêtre.
**`GET /v1/isp-metrics/{5m|1h}` — retenu.** Vérifié : renvoie **une entrée par
site** (`hostId`, `siteId`) avec une série **dense**, chaque période portant
`downtime`, `packetLoss`, `avgLatency`, `maxLatency`, `uptime` et `ispName`.
Granularité `1h` : 30 jours d'historique disponible. Granularité `5m` : environ
24 h.
La v1 consomme donc `isp-metrics` en `1h` sur une fenêtre de 24 h, soit
**24 segments par site**. Rien à reconstituer, rien à décoder.
**Taille.** Le `GET` non borné pèse 216 Ko en `1h` et 133 Ko en `5m`. La v1 passe
par `POST /v1/isp-metrics/1h/query`, qui accepte plusieurs sites et une fenêtre
bornée dans une seule requête.
Reste hors v1 : `/v1/sd-wan-configs` (projet 3), données vérifiées complètes.
## 4. Contrat de données
```ts
export interface UnifiSiteManagerSite {
siteId: string;
/** `meta.desc` s'il existe, sinon `meta.name`. */
name: string;
/** `null` quand le cloud ne rapporte pas d'uptime pour ce site. */
wanUptimePct: number | null;
ispName: string | null;
/** Une entrée par WAN configuré ; vide si aucune IP publique n'est rapportée. */
externalIps: string[];
deviceCount: number;
offlineDeviceCount: number;
/** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */
pendingUpdateCount: number;
/** Mise à jour de la CONSOLE. `null` si aucun host ne correspond au site. */
consoleUpdateAvailable: boolean | null;
/** 24 tranches horaires, la plus ancienne en premier. Toujours 24 entrées :
* une tranche sans mesure vaut `"unknown"`, jamais `"healthy"`. */
health: UnifiSiteHealthSegment[];
/** Dernière mesure de la fenêtre. `null` si le cloud n'en rapporte aucune. */
latencyMs: number | null;
packetLossPct: number | null;
/** `null` si le site n'appartient à aucune configuration SD-WAN. */
sdWan: UnifiSiteSdWanStatus | null;
}
export interface UnifiSiteSdWanStatus {
configName: string;
connected: boolean;
offline: boolean;
/** `null` quand le cloud rapporte `0` — voir la note ci-dessous. */
pingMs: number | null;
errorCount: number;
warningCount: number;
}
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
export interface UnifiSiteHealthSegment {
/** Début de la tranche horaire. */
at: Date;
state: UnifiSiteHealthState;
}
export interface UnifiSiteManagerOverview {
sites: UnifiSiteManagerSite[];
}
```
**`consoleUpdateAvailable` est nullable et ce n'est pas un détail.** `null`
signifie « aucun host ne correspond à ce site », ce qui n'est pas la même chose
que `false` (« la console est à jour »). Le widget doit les distinguer, comme il
distingue partout ailleurs l'inconnu du connu.
**Dérivation de `state`, par ordre de gravité décroissante :** `downtime > 0`
→ `"down"` ; sinon `packetLoss > 0` → `"degraded"` ; sinon `"healthy"`. Une
tranche de la fenêtre pour laquelle le cloud ne rapporte rien → `"unknown"`.
**Cette dernière règle est le point délicat.** Un site enregistré depuis douze
heures n'a que douze tranches sur vingt-quatre. Les remplir en vert affirmerait
douze heures de bon fonctionnement qui n'ont jamais été mesurées. Le gris dit
« on ne sait pas », ce qui est la vérité — même principe que le tiret cadratin
ailleurs dans ces widgets.
**`ping: 0` ne veut pas dire zéro milliseconde.** Relevé sur un tunnel
`connected: true` reliant deux sites géographiquement distants — une latence
nulle y est physiquement impossible. Le champ signifie donc « non mesuré », et le
contrat le traduit par `pingMs: null` plutôt que de propager un zéro que
l'interface afficherait comme une performance.
**Un site peut appartenir à plusieurs configurations SD-WAN.** Le contrat n'en
porte qu'une : on retient la première configuration où le site apparaît, et le
nom de celle-ci accompagne l'état pour que la ligne reste interprétable. Si le
besoin de plusieurs se présente, c'est un élargissement du contrat, pas un
correctif.
**`pendingUpdateCount` et `consoleUpdateAvailable` ne mesurent pas la même
chose.** Le premier compte les équipements réseau dont le firmware est en
retard ; le second dit si UniFi OS lui-même a une version disponible. L'interface
L'interface Ubiquiti les sépare ; le contrat fait de même.
## 5. Architecture
### 5.1 Nouveaux fichiers
```
packages/integrations/src/unifi-site-manager/
├── unifi-site-manager-integration.ts # classe Integration : orchestre et mappe
├── unifi-site-manager-api.ts # client HTTP cloud
├── unifi-site-manager-types.ts # schémas zod + mappers purs
├── unifi-site-manager-parse.ts # parsing, dépend de @homarr/common/server
└── test/
```
**La séparation `types` / `parse` est imposée par un défaut rencontré sur
l'intégration précédente**, pas par goût. `packages/integrations/src/types.ts`
est importé par des composants **client** (la spotlight, entre autres). Tout
module qu'il réexporte doit donc être client-safe. `unifi-os-types.ts` importait
`ParseError` depuis `@homarr/common/server`, ce qui a traîné `ioredis` — et donc
`node:dns` — dans le bundle navigateur et **empêché l'application de démarrer**.
Ni le typecheck, ni 703 tests, ni cinq revues ne l'avaient vu : seul le
lancement de l'application l'a révélé.
Le module de types de cette intégration ne contiendra donc que zod et des
fonctions pures, et un test de garde vérifiera l'absence de cet import.
### 5.2 Points d'attache
Seize entrées, tous identifiés. L'entrée 14 en couvre quatre fichiers à elle
seule, soit dix-neuf fichiers touchés au total.
| # | Fichier | Ajout |
|---|---|---|
| 1 | `definitions/src/integration.ts` | catégorie `unifiSiteManager` ; kind avec `secretKinds: [["siteManagerApiKey"]]`, `defaultUrl: "https://api.ui.com"`, `documentationUrl: null` |
| 2 | `definitions/src/widget.ts` | `WidgetKind` `unifiSiteManager` |
| 3 | `definitions/src/widget-integration-map.ts` | `unifiSiteManager: ["unifiSiteManager"]` |
| 4 | `definitions/src/docs/integration-doc-slugs.ts` | `unifiSiteManager: null` |
| 5 | `definitions/src/docs/widget-doc-slugs.ts` | `unifiSiteManager: null` |
| 6 | `integrations/src/base/creator.ts` | enregistrement de la classe |
| 7 | `integrations/src/index.ts` | export de la classe |
| 8 | `integrations/src/types.ts` | export des types **client-safe uniquement** |
| 9 | `integrations/src/interfaces/unifi-site-manager/` | interface consommée par le handler |
| 10 | `request-handler/src/unifi-site-manager.ts` | handler de cache |
| 11 | `api/src/router/widgets/unifi-site-manager.ts` | routeur tRPC |
| 12 | `api/src/router/widgets/index.ts` | enregistrement du routeur |
| 13 | `widgets/src/unifi-site-manager/` | définition + composant |
| 14 | `widgets/src/{manifest.ts,index.tsx,catalog.ts,refetch-intervals.ts}` | quatre enregistrements ; l'icône du catalogue doit être **la même référence** que celle de la définition, `manifest.spec.ts` compare par identité |
| 15 | `apps/nextjs/.../integration-grid-shared.ts` | `unifiSiteManager: "integration.category.unifiSiteManager"` — table typée `Record<string, string>`, donc **non vérifiée par le compilateur** : une omission passe le typecheck et affiche la clé brute |
| 16 | `translation/src/lang/en.json` | libellés du widget + `integration.category.unifiSiteManager` |
**Un point d'attache en moins qu'`unifiOs` :** le secret `siteManagerApiKey`
existe déjà, ainsi que son icône et ses libellés. On le réutilise tel quel.
**URL fixe.** `defaultUrl: "https://api.ui.com"`, sur le modèle de l'intégration
`umami` (`https://api.umami.is/v1`). Le champ `url` est `notNull` en base, d'où
le recours à une valeur par défaut plutôt qu'à son absence.
## 6. Séquence d'un rafraîchissement
```
const [sites, hosts] = await Promise.allSettled([
GET /v1/sites, // source obligatoire
GET /v1/hosts, // enrichissement
]);
if (sites.status === "rejected") throw sites.reason; // pas de liste sans sites
// Séquentiel : la requête bornée exige les hostId/siteId de l'étape précédente.
const [metrics, sdWan] = await Promise.allSettled([
POST /v1/isp-metrics/1h/query, // fenêtre 24 h, tous les sites
GET /v1/sd-wan-configs // puis /{id}/status pour chacune
]);
// Les deux sont des enrichissements : leur échec n'est jamais fatal.
```
Quatre à cinq appels selon le nombre de configurations SD-WAN, deux niveaux de
criticité.
`/v1/sites` porte la liste : son échec doit remonter, sinon le widget afficherait
« aucun site » alors qu'il n'a rien pu lire.
`/v1/hosts`, `isp-metrics` et le SD-WAN sont des enrichissements. Leur échec met
leur part à l'inconnu — `consoleUpdateAvailable` à `null`, les vingt-quatre
tranches à `"unknown"`, `sdWan` à `null` — et laisse le reste affiché.
`Promise.allSettled` plutôt que `Promise.all` sert exactement cette asymétrie.
Le SD-WAN coûte deux appels : la liste des configurations, puis le statut de
chacune. Avec une seule configuration — le cas courant — cela fait deux requêtes.
Le plan borne ce nombre pour qu'un compte à dix configurations ne produise pas
onze requêtes par rafraîchissement.
L'appel aux métriques est **séquentiel** et non parallèle : la requête bornée a
besoin des identifiants renvoyés par `/v1/sites`. C'est le prix de ne pas
rapatrier les 216 Ko de la variante non bornée.
## 7. Cache et gestion d'erreurs
| Couche | Valeur | Motif |
|---|---|---|
| `createIntegrationRequestHandler` | 5 min | Les compteurs cloud n'évoluent pas plus vite ; interroger davantage expose à une limitation de débit sans rien gagner |
| Rafraîchissement du widget | 5 min | Aligné : un intervalle plus court relirait la même entrée de cache |
| `fallbackToStaleOnError` | activé, fenêtre 30 min | Sert la dernière valeur connue plutôt qu'une tuile d'erreur pour une coupure passagère |
**Différence de fond avec `unifiOs`.** Là-bas, le cloud était un enrichissement
optionnel : son indisponibilité vidait un bloc et le widget continuait. Ici le
cloud **est** la source. Un échec est un vrai échec, et le widget doit afficher
une erreur — jamais une liste vide, qui se lirait comme « aucun site », c'est-à-dire
comme une information alors qu'il n'y en a aucune.
Un compte réellement sans site est un cas distinct et légitime : il affiche un
état vide explicite, pas une erreur.
## 8. Widget
`HomarrDataTable`, une ligne par site.
| Colonne | Source | Rendu |
|---|---|---|
| Site | `name` | toujours visible, jamais masquée en responsive |
| Santé | `health` | bande de 24 segments d'une heure, du plus ancien au plus récent |
| Latence | `latencyMs`, `packetLossPct` | `11 ms · 0 %` ; `—` si aucune mesure |
| ISP | `ispName` | `—` si `null` |
| IP publique | `externalIps` | joint par ` · ` ; `—` si vide |
| Équipements | `deviceCount`, `offlineDeviceCount` | `en ligne / total`, en rouge si des équipements sont hors ligne |
| Mises à jour | `pendingUpdateCount` | en jaune si > 0 ; `0` affiché normalement, c'est une vraie mesure |
| Console | `consoleUpdateAvailable` | `—` si `null` — l'absence de host correspondant n'est pas « à jour » |
| SD-WAN | `sdWan` | `—` si le site n'est dans aucune configuration ; sinon état du tunnel, en rouge si hors ligne, en jaune s'il porte des erreurs. Le ping n'apparaît que s'il est mesuré |
Colonnes triables. Visibilité responsive selon la largeur de tuile, suivant la
convention documentée par la skill `datatable-migration` : les colonnes les moins
essentielles disparaissent en premier, `Site` et `Santé` jamais.
**La bande de santé.** Vingt-quatre segments jointifs séparés par un filet, pour
qu'on distingue deux tranches consécutives de même couleur. Vert sain, orange
dégradé, rouge coupé, gris inconnu. Dans une colonne de tableau elle sera étroite :
c'est le prix de la forme tableau, et c'est ce qui rend l'infobulle nécessaire
plutôt que confortable.
**Couleurs : palette de statut, pas palette catégorielle.** Distinction établie
en cours de conception, après avoir échoué à faire passer un jeu vert/orange/rouge
au validateur — lequel ne couvre que les palettes **catégorielles**. Sain,
dégradé et coupé sont des **statuts**, qui relèvent d'une palette réservée et
fixe, non thémée :
| État | Couleur | Origine |
|---|---|---|
| `healthy` | `#0ca30c` | statut *good* |
| `degraded` | `#fab219` | statut *warning* |
| `down` | `#d03b3b` | statut *critical* |
| `unknown` | gris neutre de la surface | ce n'est pas un statut, c'est une absence |
Mesuré au passage : un vert/orange/rouge classique donne un ΔE de **2,9** entre
l'orange et le vert en vision protanope — indistinguables. La palette de statut
ne prétend pas résoudre cela, elle impose une contrepartie.
**La contrepartie est obligatoire, et plus forte qu'une infobulle.** La règle
attachée aux couleurs de statut est qu'elles ne voyagent jamais seules : icône
et libellé les accompagnent. Une icône par segment étant impossible sur
vingt-quatre segments, la bande est **doublée d'un résumé textuel** — par exemple
`22 h saines · 2 h dégradées` — placé à côté d'elle dans la même colonne. Ce
résumé n'est pas décoratif : c'est le canal qui porte l'information quand la
couleur ne passe pas.
**Infobulle par segment** : heure de la tranche, latence moyenne, perte. Elle
complète le résumé pour qui veut le détail d'une heure précise.
**Badge de fraîcheur** en tête de widget, alimenté par `updatedAt`, comme sur le
widget `unifiOs`.
## 9. Tests
| Niveau | Couvre | Réseau |
|---|---|---|
| Unitaire, mappers | mapping d'un site complet ; site sans WAN ; site sans `hostId` ; host absent → `consoleUpdateAvailable` à `null` et non `false` ; compte sans aucun site | Non |
| Unitaire, SD-WAN | site absent de toute configuration → `sdWan` à `null` ; **`ping: 0` → `pingMs: null` et non `0`** ; erreurs et avertissements comptés ; site présent dans plusieurs configurations → la première est retenue | Non |
| Unitaire, santé | dérivation `down` > `degraded` > `healthy` ; **fenêtre incomplète → tranches `"unknown"`, jamais `"healthy"`** ; fenêtre toujours longue de 24 ; métriques absentes → 24 tranches `"unknown"` | Non |
| Unitaire, garde | **`unifi-site-manager-types.ts` n'importe pas `@homarr/common/server`** — la seule forme de test qui aurait attrapé le défaut de l'intégration précédente | Non |
| Intégration | séquence complète sur fixtures anonymisées ; `/v1/hosts` en échec laisse le tableau affiché ; `/v1/sites` en échec propage l'erreur | Mocké |
| Manuel | vérification contre le compte réel, hors CI | Oui |
Fixtures **anonymisées** : identifiants, noms de sites, IP publiques et adresses
MAC remplacés. Un dépôt ne porte pas la cartographie d'un réseau privé.
## 10. Phasage
| Phase | Contenu | Critère de validation |
|---|---|---|
| 1 | types + mappers + tests unitaires | `pnpm test` vert, sans réseau |
| 2 | client cloud + tests | idem |
| 3 | classe d'intégration + enregistrements (points 1 à 8) | `pnpm turbo typecheck` propre |
| 4 | request-handler + routeur tRPC (9 à 12) | typecheck propre |
| 5 | widget + enregistrements + traductions (13 à 16) | `manifest.spec.ts` vert, typecheck **global** propre |
| 6 | vérification contre le compte réel | Les valeurs correspondent à l'interface Site Manager |
**La phase 6 n'est pas optionnelle.** Sur l'intégration précédente, elle a
révélé deux défauts que le typecheck, 703 tests et cinq revues avaient laissés
passer — dont un qui empêchait l'application de démarrer.
## 11. Hors périmètre
- **Vue d'analyse ISP** — projet 2. La v1 consomme déjà `isp-metrics` pour la
bande de santé ; ce qui reste hors périmètre est l'**outil d'analyse** de la
seconde capture : sélecteur de fenêtre 1D à 1M, courbes de latence superposées
par site, filtres par ISP, par perte et par uptime. Un résumé et un instrument
d'analyse ne sont pas deux tailles du même widget.
- **Topologie SD-WAN** — l'**état** des tunnels entre dans la v1 (D6) ; la
topologie n'y entre pas. `/v1/sd-wan-configs/{id}` porte les sites, réseaux
nommés, sous-réseaux et l'état `paused` — vérifié complet — mais une liste de
quinze réseaux n'a pas sa place dans une ligne de tableau. Un widget dédié
reste possible et réutilisera le client de cette intégration.
- **Écriture** — l'API expose des mutations ; l'intégration reste en lecture seule.
- **Documentation Docusaurus et traductions au-delà de l'anglais** — fork
personnel, même décision que l'intégration précédente.
- **Mutualisation de la clé cloud avec `unifiOs`** — écartée par D1.
## 12. Risques
| Risque | Portée | Traitement |
|---|---|---|
| Un module de types non client-safe casse le build | Élevée — déjà survenu | Séparation `types` / `parse` dès le départ, plus un test de garde |
| Une omission dans `categoryTranslationKeys` passe le typecheck | Moyenne — déjà survenu | Point d'attache 15 explicite ; vérification visuelle en phase 6 |
| La clé cloud voit tous les sites du compte, y compris non possédés | Sécurité | Assumé et **voulu** ici : c'est une vue multi-sites. Contrairement à `unifiOs`, aucun filtrage n'est souhaitable — mais la documentation du widget doit dire ce qu'il expose |
| Limitation de débit sur `api.ui.com` | Fiabilité | Cadence 5 min, deux appels par cycle |
| Deux intégrations interrogent le cloud en parallèle | Fiabilité | Conséquence acceptée de D1 ; caches indépendants, ~24 requêtes/heure au total |
| `ping: 0` interprété comme une latence nulle | Justesse | Traduit en `pingMs: null` au contrat, testé. Relevé sur un tunnel connecté entre deux sites distants, où zéro est physiquement impossible |
| Un compte à nombreuses configurations SD-WAN multiplie les requêtes | Fiabilité | Nombre de configurations interrogées borné par le plan ; au-delà, les sites concernés ont `sdWan` à `null` plutôt qu'une rafale de requêtes |
| Le nombre de sites croît | Interface | Le tableau absorbe la croissance là où des cartes ou une liste se dégraderaient ; visibilité responsive des colonnes, `Site` et `Santé` toujours conservées |
| La bande est étroite dans une colonne | Lisibilité | Conséquence directe de D3. L'infobulle par segment n'est donc pas un confort mais le moyen principal de lire une tranche |
| Quatre états de santé distingués par la seule couleur | Accessibilité | Palette validée avant écriture ; infobulle par segment rendue obligatoire si le jeu à quatre états ne passe pas les contrôles de vision déficiente |
| Une tranche sans mesure affichée comme saine | Justesse | État `"unknown"` explicite au contrat, testé ; la fenêtre porte toujours 24 entrées |
-36
View File
@@ -7,42 +7,6 @@ import { createHomarrContainer } from "./shared/create-homarr-container";
import { createSqliteDbFileAsync } from "./shared/e2e-db";
describe("Onboarding", () => {
test("Credentials onboarding should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
const homarrContainer = await createHomarrContainer({
mounts: {
"/appdata": localMountPath,
},
}).start();
const browser = await chromium.launch();
const context = await browser.newContext();
const page = await context.newPage();
const actions = new OnboardingActions(page, db);
const assertions = new OnboardingAssertions(page, db);
try {
// Act
await page.goto(`http://${homarrContainer.getHost()}:${homarrContainer.getMappedPort(7575)}`);
await actions.startOnboardingAsync("scratch");
await actions.processUserStepAsync({
username: "admin",
password: "Comp(exP4sswOrd",
confirmPassword: "Comp(exP4sswOrd",
});
await actions.processSettingsStepAsync();
await actions.processIntegrationsStepAsync();
// Assert
await assertions.assertFinishStepVisibleAsync();
await assertions.assertUserAndAdminGroupInsertedAsync("admin");
await assertions.assertDbOnboardingStepAsync("finish");
} finally {
await Promise.all([browser.close(), homarrContainer.stop()]);
}
}, 60_000);
test("External provider onboarding setup should be successful", async () => {
// Arrange
const { db, localMountPath } = await createSqliteDbFileAsync();
+2 -2
View File
@@ -9,7 +9,7 @@ http {
# Route websockets traffic to port 3001
location /websockets {
proxy_pass http://${HOSTNAME}:3001;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";
@@ -18,7 +18,7 @@ http {
# Route all other traffic to port 3000
location / {
proxy_pass http://${HOSTNAME}:3000;
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+112
View File
@@ -0,0 +1,112 @@
# Fork personnel — exploitation
Ce dossier n'existe pas en amont. Il ne créera donc jamais de conflit lors d'un
rebase, ce qui est exactement pourquoi les fichiers d'exploitation vivent ici et
pas dans `scripts/`.
## Déployer
```bash
ops/deploy-lxc.sh --dry-run # montre ce qui serait écrit, sans rien toucher
ops/deploy-lxc.sh
```
Le script exige un arbre propre, construit, compare les migrations et l'ABI Node
avant de toucher quoi que ce soit, sauvegarde `/opt/homarr` **et** la base, arrête
le service, synchronise, redémarre, puis affiche les journaux. La commande de
retour arrière est imprimée à la fin, horodatage compris.
Ce qu'il ne remplace jamais : `/etc/homarr/homarr.env`, `run.sh`, `entrypoint.sh`,
`nginx.conf`, `redis.conf` — ils appartiennent à l'installation du LXC, pas au
build — ni `/var/lib/homarr/db.sqlite`, qui est sauvegardée mais laissée en place.
Après déploiement, le contrôle qui compte n'est pas que le nouveau widget
s'affiche : c'est que les intégrations **préexistantes** fonctionnent encore.
C'est la seule preuve que `SECRET_ENCRYPTION_KEY` n'a pas bougé.
## Suivre les mises à jour d'Homarr
Aujourd'hui `origin` pointe sur le dépôt d'Homarr lui-même et **aucune copie
distante de ce travail n'existe** : une panne de disque effacerait la branche.
Premier geste, avant tout le reste :
```bash
git remote rename origin upstream
git remote add origin <url-de-ton-Gitea>/homarr.git
git push -u origin feat/unifi-os-integration
```
### Le cycle de montée de version
Homarr publie souvent. Se caler sur une **étiquette de version** plutôt que sur
`dev` évite de rebaser sur du code en cours :
```bash
git fetch upstream --tags
git log --oneline dev..upstream/dev | head # ce qui est arrivé
git rebase v1.78.0 feat/unifi-os-integration # remplace par la version visée
```
Puis, dans cet ordre — chaque étape a déjà attrapé quelque chose :
```bash
pnpm install # le lockfile bouge presque à chaque version
pnpm turbo typecheck # les 37 paquets
pnpm test packages/integrations/ packages/widgets/
pnpm test packages/widgets/src/manifest.spec.ts # les points d'enregistrement
ops/deploy-lxc.sh --dry-run
```
`manifest.spec.ts` mérite sa ligne à part : il compare l'icône du catalogue à
celle de la définition **par identité**, et c'est le seul test qui détecte un
point d'enregistrement perdu dans un conflit.
### Où les conflits vont tomber
Le gros du travail vit dans des dossiers neufs — `packages/integrations/src/unifi-os/`,
`unifi-site-manager/`, `packages/widgets/src/unifi-*/` — qui ne peuvent pas
entrer en conflit. Les points de friction sont les fichiers partagés où ce fork
ajoute une ligne dans une liste que l'amont fait vivre aussi :
| Fichier | Ce qu'on y ajoute |
|---|---|
| `packages/definitions/src/integration.ts` | les deux genres d'intégration |
| `packages/definitions/src/widget.ts` | les deux genres de widget |
| `packages/definitions/src/widget-integration-map.ts` | l'association widget ↔ intégration |
| `packages/definitions/src/docs/integration-doc-slugs.ts` | les slugs de documentation |
| `packages/definitions/src/docs/widget-doc-slugs.ts` | idem, côté widgets |
| `packages/integrations/src/base/creator.ts` | la construction des classes |
| `packages/integrations/src/index.ts` | les exports |
| `packages/integrations/src/types.ts` | les types réexportés **côté client** |
| `packages/api/src/router/widgets/index.ts` | les routeurs tRPC |
| `packages/widgets/src/manifest.ts` | deux cartes distinctes, module et composant |
| `packages/widgets/src/index.tsx` | l'import et l'entrée |
| `packages/widgets/src/catalog.ts` | l'icône |
| `packages/widgets/src/refetch-intervals.ts` | l'intervalle de rafraîchissement |
| `apps/nextjs/src/components/integration/integration-grid-shared.ts` | le libellé de catégorie |
| `apps/nextjs/src/app/[locale]/manage/integrations/_components/secrets/integration-secret-icons.ts` | l'icône du secret |
| `packages/translation/src/lang/en.json` | tous les libellés |
Deux de ces lignes ne sont vérifiées par **aucun** compilateur, et les perdre
dans un conflit passerait tous les tests :
- `integration-grid-shared.ts` est typé `Record<string, string>` : une entrée
manquante affiche `UNIFISITEMANAGER` en majuscules collées au lieu du nom.
- `en.json` n'est pas typé du tout : une clé perdue affiche son chemin brut.
Les deux se voient d'un coup d'œil sur la page « New integration » — c'est le
contrôle visuel à faire après chaque montée de version.
### Rejouer les résolutions de conflit
Les mêmes conflits reviendront à chaque version, au même endroit. Git sait les
rejouer tout seul :
```bash
git config rerere.enabled true
```
À activer **avant** le premier rebase : Git n'enregistre que ce qu'il voit
résoudre. Une fois qu'une résolution est connue, les suivantes sont
automatiques — à relire quand même, l'amont peut avoir changé la forme de la
liste autour.
+177
View File
@@ -0,0 +1,177 @@
#!/usr/bin/env bash
#
# Déploie le build standalone de ce dépôt sur le LXC Homarr.
#
# Reproduit la disposition que le Dockerfile officiel assemble (ses COPY du
# stage `runner`), parce que l'installation du LXC en vient : /opt/homarr a la
# même forme qu'une image, sans le conteneur autour.
#
# Ce que le script NE touche PAS, volontairement :
# - /etc/homarr/homarr.env : secrets et configuration de l'instance
# - run.sh, entrypoint.sh, nginx.conf, redis.conf : modifiés par l'installateur
# du LXC, ils appartiennent à l'installation et non au build
# - /var/lib/homarr/db.sqlite : la base, sauvegardée mais jamais remplacée
#
# Usage : ops/deploy-lxc.sh [--dry-run]
#
set -euo pipefail
HOST="${HOMARR_HOST:-zachary@10.70.1.13}"
REMOTE_DIR="${HOMARR_REMOTE_DIR:-/opt/homarr}"
REMOTE_DB="${HOMARR_REMOTE_DB:-/var/lib/homarr/db.sqlite}"
SERVICE="${HOMARR_SERVICE:-homarr}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DRY_RUN=""
[ "${1:-}" = "--dry-run" ] && DRY_RUN="--dry-run"
say() { printf '\n\033[1m==> %s\033[0m\n' "$1"; }
# ---------------------------------------------------------------------------
say "Contrôles préalables"
cd "$REPO"
[ -z "$(git status --porcelain)" ] || {
echo "Arbre de travail sale. Committe ou remise avant de déployer." >&2
exit 1
}
echo "Commit déployé : $(git log --oneline -1)"
# Le dépôt exige un Node récent (champ engines.node). Le Node par défaut du poste
# est plus ancien que ça : sans ce contrôle, l'échec surviendrait au milieu du
# build, avec un message de pnpm qui ne dit pas quoi faire.
required_node="$(grep -oP '^node = "\K[^"]+' "$REPO/mise.toml" 2>/dev/null || echo '24.18.0')"
if ! node -e "process.exit(process.versions.node.localeCompare('$required_node', undefined, { numeric: true }) < 0 ? 1 : 0)"; then
echo "Node $(node -v) est trop ancien — il en faut au moins v$required_node." >&2
echo "Par exemple : export PATH=\"\$HOME/.local/share/nvm/v24.20.0/bin:\$PATH\"" >&2
exit 1
fi
# Une migration présente ici et absente en prod n'est pas bloquante — Homarr les
# applique au démarrage — mais elle rend le rollback non trivial : restaurer
# l'archive ne défait pas un schéma déjà migré. On veut le savoir AVANT.
#
# Comparaison par dialecte : la prod embarque les migrations de tous les moteurs,
# et prendre le maximum toutes dialectes confondues masquerait une divergence
# propre à celui qui tourne réellement.
DIALECT="${HOMARR_DB_DIALECT:-sqlite}"
local_migration="$(find "packages/db/migrations/$DIALECT" -name '*.sql' -printf '%f\n' | sort | tail -1)"
remote_migration="$(ssh "$HOST" "find $REMOTE_DIR/db/migrations/$DIALECT -name '*.sql' -printf '%f\n' | sort | tail -1")"
echo "Dernière migration — locale : $local_migration | prod : $remote_migration"
if [ "$local_migration" != "$remote_migration" ]; then
echo
echo "!! Les migrations divergent. Le rollback ne sera PAS une simple restauration."
echo " Sauvegarde la base avant de continuer, puis relance en connaissance de cause."
read -r -p " Continuer quand même ? [oui/NON] " answer
[ "$answer" = "oui" ] || exit 1
fi
# ---------------------------------------------------------------------------
say "Build"
pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
# ---------------------------------------------------------------------------
say "Assemblage de l'arborescence à envoyer"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
# L'ordre suit le Dockerfile : le standalone d'abord, le reste par-dessus.
cp -a apps/nextjs/.next/standalone/. "$STAGE/"
mkdir -p "$STAGE/apps/nextjs/.next"
cp -a apps/nextjs/.next/static "$STAGE/apps/nextjs/.next/static"
cp -a apps/nextjs/public "$STAGE/apps/nextjs/public"
cp -a apps/nextjs/next.config.ts apps/nextjs/package.json pnpm-workspace.yaml "$STAGE/"
mkdir -p "$STAGE/apps/cli"
cp -a packages/cli/cli.cjs "$STAGE/apps/cli/cli.cjs"
# Binaire natif : compilé ici, chargé là-bas. Même architecture et même ABI Node
# (24.x des deux côtés) sont donc des prérequis — le contrôle est plus bas.
mkdir -p "$STAGE/build"
cp -a node_modules/better-sqlite3/build/Release/better_sqlite3.node "$STAGE/build/"
mkdir -p "$STAGE/db"
cp -a packages/db/migrations "$STAGE/db/migrations"
echo "Arborescence prête : $(du -sh "$STAGE" | cut -f1)"
# ---------------------------------------------------------------------------
say "Contrôle de compatibilité du binaire natif"
local_abi="$(node -p 'process.versions.modules')"
remote_abi="$(ssh "$HOST" 'node -p "process.versions.modules"')"
local_arch="$(uname -m)"
remote_arch="$(ssh "$HOST" 'uname -m')"
echo "ABI Node — local : $local_abi | prod : $remote_abi"
echo "Arch — local : $local_arch | prod : $remote_arch"
if [ "$local_abi" != "$remote_abi" ] || [ "$local_arch" != "$remote_arch" ]; then
echo "!! better_sqlite3.node ne se chargera pas en prod. Build sur le LXC, ou aligne Node." >&2
exit 1
fi
if [ -n "$DRY_RUN" ]; then
say "Mode --dry-run : arrêt avant toute modification de la prod"
# -i sans quoi rsync ne dit rien du tout en mode simulation.
rsync -ain --delete --rsync-path="sudo rsync" \
--exclude 'run.sh' --exclude 'entrypoint.sh' \
--exclude 'nginx.conf' --exclude 'redis.conf' \
"$STAGE/" "$HOST:$REMOTE_DIR/" > /tmp/homarr-deploy-plan.txt
# Écrit d'abord, tronqué ensuite : un `| head` ferme le tuyau et rsync s'arrête
# sur SIGPIPE au bout de trente lignes, ce qui donnerait un plan mensonger.
head -30 /tmp/homarr-deploy-plan.txt
echo
echo "$(grep -c '^\*deleting' /tmp/homarr-deploy-plan.txt) suppressions, \
$(grep -vc '^\*deleting' /tmp/homarr-deploy-plan.txt) écritures. Plan complet : /tmp/homarr-deploy-plan.txt"
exit 0
fi
# ---------------------------------------------------------------------------
say "Sauvegarde"
stamp="$(date +%F-%H%M)"
ssh -t "$HOST" "sudo tar czf /root/homarr-backup-$stamp.tar.gz $REMOTE_DIR \
&& sudo cp $REMOTE_DB /root/homarr-db-$stamp.sqlite \
&& sudo ls -lh /root/homarr-backup-$stamp.tar.gz /root/homarr-db-$stamp.sqlite"
# ---------------------------------------------------------------------------
say "Arrêt du service"
# Jamais de rsync sur un Next.js en cours d'exécution : il lit ses chunks à la
# demande et servirait des morceaux de deux builds différents.
ssh -t "$HOST" "sudo systemctl stop $SERVICE && ! systemctl is-active --quiet $SERVICE && echo arrêté"
# ---------------------------------------------------------------------------
say "Envoi"
# --delete retire ce que le build ne produit plus ; les quatre fichiers exclus
# sont protégés de cette suppression par leur exclusion même.
rsync -a --delete --rsync-path="sudo rsync" \
--exclude 'run.sh' --exclude 'entrypoint.sh' \
--exclude 'nginx.conf' --exclude 'redis.conf' \
"$STAGE/" "$HOST:$REMOTE_DIR/"
# ---------------------------------------------------------------------------
say "Redémarrage"
ssh -t "$HOST" "sudo systemctl start $SERVICE"
sleep 10
ssh "$HOST" "systemctl is-active $SERVICE; curl -s -o /dev/null -w 'HTTP %{http_code}\n' http://127.0.0.1/"
say "Journaux (40 dernières lignes)"
ssh -t "$HOST" "sudo journalctl -u $SERVICE -n 40 --no-pager"
cat <<EOF
Déployé. Vérifie dans le navigateur que les intégrations PRÉEXISTANTES
fonctionnent encore — c'est ce qui prouve que SECRET_ENCRYPTION_KEY est intact.
Retour arrière :
ssh $HOST 'sudo systemctl stop $SERVICE \\
&& sudo rm -rf $REMOTE_DIR \\
&& sudo tar xzf /root/homarr-backup-$stamp.tar.gz -C / \\
&& sudo systemctl start $SERVICE'
La base n'a pas été remplacée ; sa copie est dans /root/homarr-db-$stamp.sqlite.
EOF
+2 -3
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.74.0",
"version": "1.77.0",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
@@ -45,8 +45,7 @@
"test:e2e": "cross-env NODE_ENV=development CI=true vitest e2e",
"test:ui": "cross-env NODE_ENV=development CI=true vitest --exclude e2e --ui --coverage.enabled",
"typecheck": "turbo typecheck",
"with-env": "dotenv -e .env --",
"docker:profiling": "docker build --build-arg HOMARR_PROFILING=true -t homarr:performance . && node --experimental-strip-types scripts/benchmarks/verify-react-profiling.mts homarr:performance"
"with-env": "dotenv -e .env --"
},
"dependencies": {
"jsonpath-plus": "catalog:"
-2
View File
@@ -10,8 +10,6 @@
".": "./src/index.ts",
"./client": "./src/client.ts",
"./board-server": "./src/board-server.ts",
"./cache-invalidation": "./src/cache-invalidation.ts",
"./cache-tags": "./src/cache-tags.ts",
"./query-cache": "./src/query-cache.ts",
"./server-settings-server": "./src/server-settings-server.ts",
"./user-server": "./src/user-server.ts",
@@ -1,127 +0,0 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getCachedIntegrationData, invalidateIntegrationDataCache } from "./integration-data-cache";
afterEach(() => {
invalidateIntegrationDataCache("integration-a");
invalidateIntegrationDataCache("integration-b");
});
describe("getCachedIntegrationData", () => {
test("returns cached data within TTL without refetching", async () => {
const fetcher = vi.fn(async () => ({ value: 1 }));
await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({
value: 1,
});
await expect(getCachedIntegrationData("integration-a", "widget:procedure", fetcher, 60_000)).resolves.toEqual({
value: 1,
});
expect(fetcher).toHaveBeenCalledTimes(1);
});
test("serves stale data while revalidating after TTL", async () => {
vi.useFakeTimers();
const fetcher = vi
.fn()
.mockResolvedValueOnce({ value: 1 })
.mockImplementationOnce(() => new Promise((resolve) => setTimeout(() => resolve({ value: 2 }), 100)));
await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50);
vi.advanceTimersByTime(60);
const staleResult = await getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50);
expect(staleResult).toEqual({ value: 1 });
expect(fetcher).toHaveBeenCalledTimes(2);
vi.advanceTimersByTime(100);
await vi.advanceTimersByTimeAsync(0);
await expect(getCachedIntegrationData("integration-a", "widget:stale", fetcher, 50)).resolves.toEqual({ value: 2 });
vi.useRealTimers();
});
test("throws on initial fetch failure when no stale data exists", async () => {
const fetcher = vi.fn().mockRejectedValue(new Error("upstream down"));
await expect(getCachedIntegrationData("integration-a", "q1", fetcher)).rejects.toThrow("upstream down");
});
test("returns stale data when refetch fails", async () => {
vi.useFakeTimers();
const fetcher = vi
.fn()
.mockResolvedValueOnce({ value: 1 })
.mockRejectedValueOnce(new Error("upstream unavailable"));
await getCachedIntegrationData("integration-a", "widget:error", fetcher, 50);
vi.advanceTimersByTime(60);
await expect(getCachedIntegrationData("integration-a", "widget:error", fetcher, 50)).resolves.toEqual({
value: 1,
});
vi.useRealTimers();
});
test("concurrent cold-cache calls coalesce into one fetch, never return undefined", async () => {
let resolveOuter!: (v: { value: number }) => void;
const fetcher = vi.fn(
() =>
new Promise<{ value: number }>((resolve) => {
resolveOuter = resolve;
}),
);
const p1 = getCachedIntegrationData("integration-a", "widget:concurrent", fetcher);
const p2 = getCachedIntegrationData("integration-a", "widget:concurrent", fetcher);
resolveOuter({ value: 42 });
const [r1, r2] = await Promise.all([p1, p2]);
expect(r1).toEqual({ value: 42 });
expect(r2).toEqual({ value: 42 });
expect(fetcher).toHaveBeenCalledTimes(1);
});
test("invalidation during pending fetch prevents stale write-back", async () => {
let resolveOuter!: (v: { value: string }) => void;
const fetcher = vi.fn(
() =>
new Promise<{ value: string }>((resolve) => {
resolveOuter = resolve;
}),
);
const p1 = getCachedIntegrationData("integration-a", "widget:race", fetcher);
invalidateIntegrationDataCache("integration-a");
resolveOuter({ value: "stale" });
await p1;
const freshFetcher = vi.fn(async () => ({ value: "fresh" }));
const result = await getCachedIntegrationData("integration-a", "widget:race", freshFetcher);
expect(result).toEqual({ value: "fresh" });
expect(freshFetcher).toHaveBeenCalledTimes(1);
});
test("invalidates all cache entries for an integration", async () => {
const fetcherOne = vi.fn(async () => ({ value: "one" }));
const fetcherTwo = vi.fn(async () => ({ value: "two" }));
const fetcherOther = vi.fn(async () => ({ value: "other" }));
await getCachedIntegrationData("integration-a", "widget:one", fetcherOne);
await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo);
await getCachedIntegrationData("integration-b", "widget:one", fetcherOther);
invalidateIntegrationDataCache("integration-a");
await getCachedIntegrationData("integration-a", "widget:one", fetcherOne);
await getCachedIntegrationData("integration-a", "widget:two", fetcherTwo);
await getCachedIntegrationData("integration-b", "widget:one", fetcherOther);
expect(fetcherOne).toHaveBeenCalledTimes(2);
expect(fetcherTwo).toHaveBeenCalledTimes(2);
expect(fetcherOther).toHaveBeenCalledTimes(1);
});
});
@@ -1,75 +0,0 @@
const isMultiInstance = process.env.REDIS_IS_EXTERNAL === "true";
interface CachedEntry<T> {
data: T;
timestamp: number;
pending?: Promise<T>;
}
const cache = new Map<string, CachedEntry<unknown>>();
const DEFAULT_TTL_MS = 60_000;
const MAX_ENTRIES = 200;
export const getCachedIntegrationData = async <T>(
integrationId: string,
queryKey: string,
fetcher: () => Promise<T>,
ttlMs = DEFAULT_TTL_MS,
): Promise<T> => {
if (isMultiInstance) {
return fetcher();
}
const key = `${integrationId}:${queryKey}`;
const existing = cache.get(key) as CachedEntry<T> | undefined;
const now = Date.now();
if (existing?.pending) return existing.timestamp > 0 ? existing.data : existing.pending;
if (existing && existing.timestamp > 0 && now - existing.timestamp < ttlMs) {
return existing.data;
}
// Set pending synchronously BEFORE creating the fetch promise to prevent
// concurrent stale-revalidation from spawning duplicate upstream calls.
const placeholder = existing ?? { data: undefined as T, timestamp: 0 };
if (!existing) cache.set(key, placeholder);
const pending = fetcher()
.then((data) => {
if (cache.get(key) === placeholder) {
cache.set(key, { data, timestamp: Date.now() });
if (cache.size > MAX_ENTRIES) {
let oldestKey: string | undefined;
let oldestTime = Infinity;
for (const [k, e] of cache) {
if (e.timestamp < oldestTime) {
oldestTime = e.timestamp;
oldestKey = k;
}
}
if (oldestKey) cache.delete(oldestKey);
}
}
return data;
})
.catch((error) => {
const entry = cache.get(key) as CachedEntry<T> | undefined;
if (entry) {
delete entry.pending;
if (entry.timestamp > 0) return entry.data;
}
cache.delete(key);
throw error;
});
placeholder.pending = pending;
return existing ? existing.data : pending;
};
export const invalidateIntegrationDataCache = (integrationId: string) => {
const prefix = `${integrationId}:`;
for (const key of cache.keys()) {
if (key.startsWith(prefix)) cache.delete(key);
}
};
@@ -5,7 +5,7 @@ import type { Database } from "@homarr/db";
import { eq, or } from "@homarr/db";
import { items } from "@homarr/db/schema";
import type { WidgetComponentProps } from "../../../../widgets/src/definition";
import type { WidgetComponentProps } from "../../../../widgets/src";
export class AppAccessControl {
constructor(
@@ -2,8 +2,6 @@ import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { hasQueryAccessToIntegrationsAsync } from "@homarr/auth/server";
import { mediaRequestListRequestHandler } from "@homarr/request-handler/media-request-list";
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { constructIntegrationPermissions } from "@homarr/auth/shared";
import { createId, objectEntries } from "@homarr/common";
import { decryptSecret, encryptSecret } from "@homarr/common/server";
@@ -39,7 +37,6 @@ import {
import { mediaRequestOptionsSchema, mediaRequestRequestSchema } from "@homarr/validation/widgets/media-request";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { createTRPCRouter, permissionRequiredProcedure, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "./integration-access";
import { MissingSecretError, testConnectionAsync } from "./integration-test-connection";
@@ -371,9 +368,6 @@ export const integrationRouter = createTRPCRouter({
kind: input.kind,
url: input.url,
});
await invalidateIntegrationCacheAsync(integrationId);
invalidateIntegrationDataCache(integrationId);
}),
update: protectedProcedure.input(integrationUpdateSchema).mutation(async ({ ctx, input }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -480,7 +474,6 @@ export const integrationRouter = createTRPCRouter({
// Invalidate all cached data for this integration so that widgets pick up the
// new configuration immediately instead of serving stale (or errored) data.
await invalidateIntegrationCacheAsync(input.id);
invalidateIntegrationDataCache(input.id);
}),
delete: protectedProcedure
.meta({
@@ -505,7 +498,6 @@ export const integrationRouter = createTRPCRouter({
// Clean up any cached data left behind by the deleted integration.
await invalidateIntegrationCacheAsync(input.id);
invalidateIntegrationDataCache(input.id);
}),
getIntegrationPermissions: protectedProcedure.input(byIdSchema).query(async ({ input, ctx }) => {
await throwIfActionForbiddenAsync(ctx, eq(integrations.id, input.id), "full");
@@ -624,8 +616,6 @@ export const integrationRouter = createTRPCRouter({
});
},
});
invalidateIntegrationDataCache(input.entityId);
}),
saveGroupIntegrationPermissions: protectedProcedure
.input(integrationSavePermissionsSchema)
@@ -672,8 +662,6 @@ export const integrationRouter = createTRPCRouter({
});
},
});
invalidateIntegrationDataCache(input.entityId);
}),
searchInIntegration: protectedProcedure
.meta({
@@ -793,11 +781,7 @@ export const integrationRouter = createTRPCRouter({
.input(mediaRequestRequestSchema)
.mutation(async ({ ctx, input }) => {
const integration = await createIntegrationAsync(ctx.integration);
const result = await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
invalidateIntegrationDataCache(ctx.integration.id);
mediaRequestListRequestHandler.invalidateCache();
mediaRequestStatsRequestHandler.invalidateCache();
return result;
return await integration.requestMediaAsync(input.mediaType, input.mediaId, input.seasons);
}),
});
@@ -1,15 +1,10 @@
import { describe, expect, test, vi } from "vitest";
import * as homarrDefinitions from "@homarr/definitions";
import type { Integration } from "@homarr/integrations";
import * as homarrIntegrations from "@homarr/integrations";
import { testConnectionAsync } from "../../integration/integration-test-connection";
const mockIntegration = (): Pick<Integration, "testConnectionAsync"> => ({
testConnectionAsync: async () => ({ success: true }),
});
vi.mock("@homarr/common/server", async (importActual) => {
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
const actual = await importActual<typeof import("@homarr/common/server")>();
@@ -25,8 +20,10 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -66,8 +63,10 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -114,8 +113,10 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["apiKey"]]);
@@ -162,8 +163,10 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
@@ -214,8 +217,10 @@ describe("testConnectionAsync should run test connection of integration", () =>
// Arrange
const factorySpy = vi.spyOn(homarrIntegrations, "createIntegrationAsync");
const optionsSpy = vi.spyOn(homarrDefinitions, "getAllSecretKindOptions");
factorySpy.mockResolvedValue(
mockIntegration() as Awaited<ReturnType<typeof homarrIntegrations.createIntegrationAsync>>,
factorySpy.mockReturnValue(
Promise.resolve({
testConnectionAsync: async () => await Promise.resolve({ success: true }),
} as homarrIntegrations.PiHoleIntegrationV6),
);
optionsSpy.mockReturnValue([["username", "password"], ["apiKey"]]);
@@ -7,7 +7,6 @@ import { anchorNotesListInputSchema, anchorNoteUpdateInputSchema, createIntegrat
import { anchorNoteRequestHandler, anchorNotesListRequestHandler } from "@homarr/request-handler/anchor-notes";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const noteIdInput = z.object({
@@ -76,9 +75,6 @@ export const anchorNotesRouter = createTRPCRouter({
...(normalizedContent !== undefined ? { content: normalizedContent } : {}),
});
invalidateIntegrationDataCache(ctx.integration.id);
anchorNoteRequestHandler.invalidateCache();
anchorNotesListRequestHandler.invalidateCache();
return updatedNote;
} catch (error) {
if (error instanceof ResponseError && error.statusCode === 400) {
+1 -6
View File
@@ -10,7 +10,7 @@ import {
beszelSystemsRequestHandler,
} from "@homarr/request-handler/beszel";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
import { BoundedAsyncQueue } from "./bounded-async-queue";
@@ -47,7 +47,6 @@ export const beszelRouter = createTRPCRouter({
};
},
{
queryKey: integrationQueryKey("beszel", "getSystems"),
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
@@ -120,10 +119,6 @@ export const beszelRouter = createTRPCRouter({
};
},
{
queryKey: integrationQueryKey("beszel", "getAlerts", {
includeHistory: input.includeHistory,
maxHistoryItems: input.maxHistoryItems,
}),
fallback: (integration, error) => ({
integrationId: integration.id,
integrationName: integration.name,
+12 -16
View File
@@ -1,28 +1,24 @@
import { coolifyRequestHandler } from "@homarr/request-handler/coolify";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const coolifyRouter = createTRPCRouter({
getInstancesInfo: publicProcedure
.concat(createManyIntegrationMiddleware("query", "coolify"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = coolifyRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("coolify", "getInstancesInfo") },
);
return {
integrationId: integration.id,
integrationName: integration.name,
integrationUrl: integration.url,
instanceInfo: data,
updatedAt: timestamp,
};
});
}),
});
+8 -17
View File
@@ -5,8 +5,7 @@ import { createIntegrationAsync } from "@homarr/integrations";
import { dnsHoleRequestHandler } from "@homarr/request-handler/dns-hole";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const dnsHoleRouter = createTRPCRouter({
@@ -20,17 +19,13 @@ export const dnsHoleRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("dnsHole")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
},
{ queryKey: integrationQueryKey("dns-hole", "summary") },
);
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await dnsHoleRequestHandler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data,
};
});
}),
enable: protectedProcedure
@@ -45,8 +40,6 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration } }) => {
const client = await createIntegrationAsync(integration);
await client.enableAsync();
invalidateIntegrationDataCache(integration.id);
dnsHoleRequestHandler.invalidateCache();
}),
disable: protectedProcedure
@@ -66,7 +59,5 @@ export const dnsHoleRouter = createTRPCRouter({
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.disableAsync(input.duration);
invalidateIntegrationDataCache(integration.id);
dnsHoleRequestHandler.invalidateCache();
}),
});
+9 -33
View File
@@ -6,8 +6,7 @@ import { downloadClientRequestHandler } from "@homarr/request-handler/downloads"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createDownloadClientIntegrationMiddleware = (action: IntegrationAction) =>
@@ -25,22 +24,14 @@ export const downloadsRouter = createTRPCRouter({
.concat(createDownloadClientIntegrationMiddleware("query"))
.input(z.object({ limitPerIntegration: z.number().default(50) }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
},
{
queryKey: integrationQueryKey("downloads", "getJobsAndStatuses", {
limitPerIntegration: input.limitPerIntegration,
}),
},
);
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = downloadClientRequestHandler.handler(integration, { limit: input.limitPerIntegration });
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
data,
};
});
}),
pause: protectedProcedure
.meta({
@@ -58,9 +49,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseQueueAsync();
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
pauseItem: protectedProcedure
@@ -73,9 +61,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.pauseItemAsync(input.item);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
resume: protectedProcedure
@@ -94,9 +79,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeQueueAsync();
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
resumeItem: protectedProcedure
@@ -109,9 +91,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.resumeItemAsync(input.item);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
deleteItem: protectedProcedure
@@ -124,9 +103,6 @@ export const downloadsRouter = createTRPCRouter({
await integrationInstance.deleteItemAsync(input.item, input.fromDisk);
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
downloadClientRequestHandler.invalidateCache();
}),
});
+12 -29
View File
@@ -7,7 +7,7 @@ import {
} from "@homarr/request-handler/firewall";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const firewallMiddleware = createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("firewall"));
@@ -18,37 +18,20 @@ const queryFirewall = <
},
>(
handler: THandler,
queryKey: string,
) =>
publicProcedure.concat(firewallMiddleware).query(async ({ ctx }) =>
settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
},
{ queryKey },
),
settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await handler.handler(integration, {}).getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind, updatedAt: timestamp },
summary: data as Awaited<ReturnType<ReturnType<THandler["handler"]>["getDataAsync"]>>["data"],
};
}),
);
export const firewallRouter = createTRPCRouter({
getFirewallCpuStatus: queryFirewall(
firewallCpuRequestHandler,
integrationQueryKey("firewall", "getFirewallCpuStatus"),
),
getFirewallInterfacesStatus: queryFirewall(
firewallInterfacesRequestHandler,
integrationQueryKey("firewall", "getFirewallInterfacesStatus"),
),
getFirewallVersionStatus: queryFirewall(
firewallVersionRequestHandler,
integrationQueryKey("firewall", "getFirewallVersionStatus"),
),
getFirewallMemoryStatus: queryFirewall(
firewallMemoryRequestHandler,
integrationQueryKey("firewall", "getFirewallMemoryStatus"),
),
getFirewallCpuStatus: queryFirewall(firewallCpuRequestHandler),
getFirewallInterfacesStatus: queryFirewall(firewallInterfacesRequestHandler),
getFirewallVersionStatus: queryFirewall(firewallVersionRequestHandler),
getFirewallMemoryStatus: queryFirewall(firewallMemoryRequestHandler),
});
@@ -1,8 +1,9 @@
import { createIntegrationAsync } from "@homarr/integrations";
import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const healthMonitoringIntegrationKinds = [
@@ -26,19 +27,15 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("health-monitoring", "getSystemHealthStatus") },
);
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
return {
integrationId: integration.id,
integrationName: integration.name,
healthInfo: data,
updatedAt: timestamp,
};
});
}),
listStorageVolumes: publicProcedure
.meta({
@@ -50,12 +47,11 @@ export const healthMonitoringRouter = createTRPCRouter({
})
.concat(createOneIntegrationMiddleware("query", "synology"))
.query(async ({ ctx }) => {
if (ctx.integration.kind !== "synology") {
const integrationInstance = await createIntegrationAsync(ctx.integration);
if (!(integrationInstance instanceof SynologyIntegration)) {
throw new Error("Expected Synology integration");
}
const integrationInstance = await createIntegrationAsync(ctx.integration);
return await integrationInstance.listStorageVolumesAsync();
}),
getClusterHealthStatus: publicProcedure
+2
View File
@@ -31,6 +31,8 @@ export const widgetRouter = createTRPCRouter({
options: lazy(() => import("./options").then((mod) => mod.optionsRouter)),
releases: lazy(() => import("./releases").then((mod) => mod.releasesRouter)),
networkController: lazy(() => import("./network-controller").then((mod) => mod.networkControllerRouter)),
unifiOs: lazy(() => import("./unifi-os").then((mod) => mod.unifiOsRouter)),
unifiSiteManager: lazy(() => import("./unifi-site-manager").then((mod) => mod.unifiSiteManagerRouter)),
firewall: lazy(() => import("./firewall").then((mod) => mod.firewallRouter)),
notifications: lazy(() => import("./notifications").then((mod) => mod.notificationsRouter)),
timetable: lazy(() => import("./timetable").then((mod) => mod.timetableRouter)),
@@ -6,8 +6,7 @@ import { indexerManagerRequestHandler } from "@homarr/request-handler/indexer-ma
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
const createIndexerManagerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -17,19 +16,15 @@ export const indexerManagerRouter = createTRPCRouter({
getIndexersStatus: publicProcedure
.concat(createIndexerManagerIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = indexerManagerRequestHandler.handler(integration, {});
const { data: indexers } = await innerHandler.getDataAsync();
return {
integrationId: integration.id,
indexers,
};
},
{ queryKey: integrationQueryKey("indexer-manager", "getIndexersStatus") },
);
return {
integrationId: integration.id,
indexers,
};
});
}),
testAllIndexers: protectedProcedure
.concat(createIndexerManagerIntegrationMiddleware("interact"))
@@ -46,9 +41,5 @@ export const indexerManagerRouter = createTRPCRouter({
});
}),
);
for (const integration of ctx.integrations) {
invalidateIntegrationDataCache(integration.id);
}
indexerManagerRequestHandler.invalidateCache();
}),
});
@@ -2,31 +2,27 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { mediaReleaseRequestHandler } from "@homarr/request-handler/media-release";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const mediaReleaseRouter = createTRPCRouter({
getMediaReleases: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRelease")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = mediaReleaseRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
},
{ queryKey: integrationQueryKey("media-release", "getMediaReleases") },
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
releases: data,
};
});
return results.flatMap((result) =>
result.releases.map((release) => ({
...release,
@@ -10,8 +10,7 @@ import {
import { mediaRequestStatsRequestHandler } from "@homarr/request-handler/media-request-stats";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { invalidateIntegrationDataCache } from "../../integration-data-cache";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
export const mediaRequestsRouter = createTRPCRouter({
@@ -26,14 +25,10 @@ export const mediaRequestsRouter = createTRPCRouter({
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.input(mediaRequestListInputSchema)
.query(async ({ ctx, input }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ queryKey: integrationQueryKey("media-requests", "getLatestRequests", input) },
);
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestListRequestHandler.handler(integration, input).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return results
.flatMap(({ data, integration }) =>
data.map((request) => ({
@@ -62,14 +57,10 @@ export const mediaRequestsRouter = createTRPCRouter({
})
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("mediaRequest")))
.query(async ({ ctx }) => {
const results = await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
},
{ queryKey: integrationQueryKey("media-requests", "getStats") },
);
const results = await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaRequestStatsRequestHandler.handler(integration, {}).getDataAsync();
return { integration: { id: integration.id, name: integration.name, kind: integration.kind }, data };
});
return {
stats: results.flatMap((result) => result.data.stats),
users: results
@@ -108,8 +99,5 @@ export const mediaRequestsRouter = createTRPCRouter({
} as const;
await answerActions[input.answer](input.requestId);
invalidateIntegrationDataCache(integration.id);
mediaRequestListRequestHandler.invalidateCache();
mediaRequestStatsRequestHandler.invalidateCache();
}),
});
@@ -5,7 +5,7 @@ import { mediaServerRequestHandler } from "@homarr/request-handler/media-server"
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createMediaServerIntegrationMiddleware = (action: IntegrationAction) =>
@@ -23,19 +23,11 @@ export const mediaServerRouter = createTRPCRouter({
.concat(createMediaServerIntegrationMiddleware("query"))
.input(z.object({ showOnlyPlaying: z.boolean() }))
.query(async ({ ctx, input }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
},
{
queryKey: integrationQueryKey("media-server", "getCurrentStreams", {
showOnlyPlaying: input.showOnlyPlaying,
}),
},
);
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data } = await mediaServerRequestHandler
.handler(integration, { showOnlyPlaying: input.showOnlyPlaying })
.getDataAsync();
return { integrationId: integration.id, integrationKind: integration.kind, sessions: data };
});
}),
});
@@ -2,30 +2,26 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { networkControllerRequestHandler } from "@homarr/request-handler/network-controller";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const networkControllerRouter = createTRPCRouter({
summary: publicProcedure
.concat(createManyIntegrationMiddleware("query", ...getIntegrationKindsByCategory("networkController")))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = networkControllerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
},
{ queryKey: integrationQueryKey("network-controller", "summary") },
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
});
}),
});
@@ -3,7 +3,7 @@ import { notificationsRequestHandler } from "@homarr/request-handler/notificatio
import type { IntegrationAction } from "../../middlewares/integration";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { integrationQueryKey, settleIntegrationQueries } from "../../settle-integrations";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const createNotificationsIntegrationMiddleware = (action: IntegrationAction) =>
@@ -13,23 +13,19 @@ export const notificationsRouter = createTRPCRouter({
getNotifications: publicProcedure
.unstable_concat(createNotificationsIntegrationMiddleware("query"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(
ctx.integrations,
async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = notificationsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
},
{ queryKey: integrationQueryKey("notifications", "getNotifications") },
);
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
updatedAt: timestamp,
},
data,
};
});
}),
});
+1 -1
View File
@@ -6,7 +6,7 @@ import { eq } from "@homarr/db";
import { getServerSettingsAsync } from "@homarr/db/queries";
import { boards, items } from "@homarr/db/schema";
import type { WidgetOptionsSettings } from "../../../../widgets/src/definition";
import type { WidgetOptionsSettings } from "../../../../widgets/src";
import { createTRPCRouter, protectedProcedure, publicProcedure } from "../../trpc";
import { throwIfActionForbiddenAsync } from "../board/board-access";
+1 -1
View File
@@ -9,7 +9,7 @@ import { items, users } from "@homarr/db/schema";
import { createLogger } from "@homarr/core/infrastructure/logs";
import { rssFeedsRequestHandler } from "@homarr/request-handler/rss-feeds";
import type { WidgetComponentProps } from "../../../../widgets/src/definition";
import type { WidgetComponentProps } from "../../../../widgets/src";
import { createTRPCRouter, publicProcedure } from "../../trpc";
const logger = createLogger({ module: "rssFeed" });
Loaded 100 of 286 files, more files were not shown because too many files have changed in this diff. Show more