Compare commits

...
Author SHA1 Message Date
zacharyandClaude Opus 5 a029760f66 chore(ops): move instance state to /var/lib/homarr without touching the image
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:43:37 +02:00
zacharyandClaude Opus 5 34d45d7448 chore(ops): create only the state directories the instance uses
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:39:22 +02:00
zacharyandClaude Opus 5 db558990a0 chore(ops): write the deploy plan before truncating it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:44 +02:00
zacharyandClaude Opus 5 8ffae729cc chore(ops): itemize the dry-run so it actually shows the plan
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:44 +02:00
zacharyandClaude Opus 5 fbebe31ef3 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 22:21:43 +02:00
zacharyandClaude Opus 5 0a94110a67 chore(ops): add LXC deploy script and fork maintenance notes
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 04069610fa refactor(widgets): let the UniFi OS chart fill the tile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 f2572bdeef fix(integrations): end the health window on the last measured hour
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 d08bc942a2 feat(widgets): show WAN uptime in the UniFi site table
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 e505e94644 feat(integrations): fill the recent hours from the 5-minute ISP metrics
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 e32b956385 fix(widgets): make the UniFi site table sortable and fill the tile
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zachary 5e02ad4e78 Revert "fix(integrations): label sites with the console name Site Manager shows"
This reverts commit 12db2c0677.
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 8bdbc30376 fix(integrations): label sites with the console name Site Manager shows
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 44e9c22092 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 22:21:43 +02:00
zacharyandClaude Opus 5 d2912c5e31 fix(integrations): treat an unstated console update state as unknown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 e3e077fcb8 fix(widgets): hide the decorative health bar from assistive technology
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 ca298cd688 fix(widgets): report every health state in the UniFi site summary
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 ce886f7ca1 fix(widgets): omit packet loss when the cloud did not measure it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 b39e27fd3b feat(widgets): add UniFi Site Manager overview widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 74ff68993e fix(widgets): remove out-of-scope UniFi Site Manager widget stub
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 9a7915d230 feat(api): expose unifiSiteManager overview query
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 4b7b7c5d1c fix(integrations): type the Site Manager transport boundary and guard it
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 c3633e7834 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 3d31ce8eec feat(integrations): add UnifiSiteManagerIntegration
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 58e7fe730a 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 92b949c47f feat(integrations): add UniFi Site Manager cloud client
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 3ef0b68112 feat(integrations): add UniFi Site Manager schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 ba3a060b06 feat(definitions): declare unifiSiteManager integration and widget kinds
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 974bf83bde 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 896c60df55 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 9d1465c4fc 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 7b443c9da3 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 64beae817d 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 a3b7ca9a8c 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 1c602a305b 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 ff24a5b009 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 d5ae666c41 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 b29d4f5445 fix(nextjs): register the unifiOs category translation key
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 4c04f91c17 fix(integrations): keep unifi-os-types client-safe
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 d0865c3b15 fix: address final review — cloud TLS, MAC guard, honest ISP metrics
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 367eb82db5 docs: spec — make ISP metrics nullable, they are absent during a WAN outage
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 731d63c19c 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 a81894dd8a feat(widgets): add UniFi OS widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 99282742c6 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 31781d25ad 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 c9b3611b77 fix(integrations): make client/device counts nullable and test real degradation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zachary d156d70b31 docs: spec — drop unused VPN servers call, make client/device counts nullable 2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 67c3cba167 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 67ec3cab3d fix(integrations): select newest ISP metric period explicitly
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 eed8c5fe91 fix(integrations): type unifi-os test fetch mocks for tsc
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 3ce3eefa5e 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 6f9a30799e 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 d33f2155a7 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-13 22:21:43 +02:00
zachary 957211164f docs: fix test command in plan (pnpm test, not pnpm vitest run) 2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 84498d4e7b feat(integrations): add UniFi OS schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 a6e4df0460 feat(definitions): declare unifiOs integration kind, category and widget kind
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-13 22:21:43 +02:00
zacharyandClaude Opus 5 503735bc0d 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-13 22:21:43 +02:00
zacharyandClaude Opus 5 c47be12e69 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-13 22:21:43 +02:00
Releases Homarr 7859faaa1d chore(release): 1.77.1 [skip ci] 2026-09-11 19:22:36 +00:00
homarr-releases[bot] bff6ec735e chore(release): automatic release v1.77.1 2026-09-11 19:21:19 +00:00
Thomas 1fbe844598 fix(settings): move board access control out of the nested form (#6662) 2026-09-10 18:13:56 +02:00
Thomas 365ef7f02b Revert "feat: announce Homarr v2 beta" (#6813) 2026-09-10 16:36:54 +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
Thomas 2513e8454f fix: open user menu instantly (#6568) 2026-08-13 11:44:10 +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
Thomas 527e911eef fix(beszel): let Recharts own tooltip lifecycle (#6560) 2026-08-11 15:35:17 +02:00
Thomas ff2cdecc86 feat: rewrite image picker (#6556)
* feat: rewrite image picker

* style: auto-fix lint and format

* fix: address image picker review

* fix: preserve unicode icon searches

* test: stabilize custom widget login

* fix: suggest image searches only on create
2026-08-11 14:49:52 +02:00
Sven 742bde1d27 fix(synology): support vol_info in storage_v2 responses (#6554) 2026-08-11 08:59:28 +00:00
homarr-update-contributors[bot] 64febbce12 chore: update integrations list readme (#6551)
Co-authored-by: Homarr Update Contributors <210161987+homarr-update-contributors[bot]@users.noreply.github.com>
2026-08-10 11:44:39 +00:00
TimonandThomas 98bb1aecf8 feat(integrations): add What's Up Docker (WUD) integration and widget (#6523)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 08:42:23 +00:00
homarr-crowdin[bot]andThomas 06c489bd48 chore(lang): updated translations from crowdin (#6548)
Co-authored-by: Thomas <thomas@ajnart.fr>
2026-08-10 09:06:56 +02:00
Thomas 464d98e124 feat(settings): unify manage and board settings into a single save form (#6541) 2026-08-08 15:33:38 +02:00
Thomas 987c9ade18 fix: make internal nginx proxy tolerate disabled IPv6 (#4596) (#6539)
* fix: keep nginx IPv6 listen only when IPv6 is available (#4596)

Commit 2c48b1896 (1.45.2) made the internal nginx proxy listen on the IPv6
wildcard ('listen [::]:7575;'), which aborts nginx startup on hosts/VMs with
IPv6 disabled:

    nginx: [emerg] socket() [::]:7575 failed (97: Address family not supported by protocol)

A plain revert would reintroduce #2697 and break IPv6-only/dual-stack
environments where this listener is required. Instead, only emit the IPv6
listen directive when the host actually has IPv6 configured.

Reuse the envsubst step run.sh already performs: compute NGINX_LISTEN_IPV6
from /proc/net/if_inet6 and substitute it into the template. proc files always
report size 0, so presence is detected by grepping the file (not 'test -s').

* fix: honor HOMARR_DISABLE_IPV6 override in nginx config generation

Review feedback on #6539: give users an explicit opt-out from the IPv6
listener even when the host reports IPv6. The issue reporter (#4596)
requested such an env var, and the pattern matches the existing
ENABLE_DNS_CACHING IPv6 toggle. Auto-detection behavior is unchanged when
the var is unset. Validated in containers: override wins, dual-stack
preserved by default, IPv4-only fallback on IPv6-less hosts.
2026-08-08 11:16:49 +02:00
Sven Gottwald 6141d505e4 fix(docker): Expose TCP port in Dockerfile (#6538) 2026-08-08 08:53:50 +02:00
Thomas 6178843198 Merge pull request #6536 from homarr-labs/docs/update-helm-docs 2026-08-07 23:53:22 +02:00
github-actions[bot] a7006d746e docs(helm): update chart installation page 2026-08-07 19:37:35 +00:00
211 changed files with 41660 additions and 28383 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
+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
@@ -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 -1
View File
@@ -71,7 +71,8 @@ apps/nextjs/public/images/background.png
# personal agent config
.opencode
.agents/
.agents/*
!.agents/skills/
skills-lock.json
# docusaurus
+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.
+12
View File
@@ -1,3 +1,15 @@
## [1.77.1](https://github.com/homarr-labs/homarr/compare/v1.77.0...v1.77.1) (2026-09-11)
## [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)
+8
View File
@@ -206,6 +206,14 @@ packages/integrations/src/unraid @manuel-rw
apps/docs/docs/integrations/uptime-kuma @ajnart
packages/integrations/src/uptime-kuma @ajnart
# What's Up Docker (WUD) - introduced in https://github.com/homarr-labs/homarr/pull/6523 by @tiimonn
apps/docs/docs/integrations/whats-up-docker @tiimonn
apps/docs/docs/widgets/whats-up-docker @tiimonn
packages/api/src/router/widgets/wud.ts @tiimonn
packages/integrations/src/wud @tiimonn
packages/request-handler/src/wud.ts @tiimonn
packages/widgets/src/wud @tiimonn
# Traefik - by @Nerdy-Technician
apps/docs/docs/integrations/traefik @Nerdy-Technician
apps/docs/docs/widgets/traefik @Nerdy-Technician
+1
View File
@@ -95,5 +95,6 @@ ENV AUTH_PROVIDERS='credentials'
ENV REDIS_IS_EXTERNAL='false'
ENV NODE_ENV='production'
EXPOSE 7575
ENTRYPOINT [ "/app/entrypoint.sh" ]
CMD ["sh", "run.sh"]
+2 -2
View File
@@ -55,8 +55,8 @@ pnpm typecheck
docs/
├── getting-started/ # Installation guides, glossary, prerequisites
├── management/ # Boards, apps, users, integrations, settings, tasks, API
├── integrations/ # 55 integration guides (one folder each)
├── widgets/ # 42 widget guides (one folder each)
├── integrations/ # 56 integration guides (one folder each)
├── widgets/ # 43 widget guides (one folder each)
├── advanced/ # SSO, env vars, proxy, styling, CLI, development
└── community/ # FAQ, donate, license, translations, get-in-touch
```
@@ -25,13 +25,14 @@ This is especially useful for [docker secrets](https://docs.docker.com/engine/sw
Using the `PUID` and `PGID` will require you to set the correct permissions on the mounted volumes and if used the docker socket. See more in the [Running as a different user](/docs/advanced/running-as-different-user) documentation.
:::
| Environment Variable | Description | Possible values | Default |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| Environment Variable | Description | Possible values | Default |
| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | ------- |
| `PUID` | User ID to run the container as | Any valid user ID | `0` |
| `PGID` | Group ID to run the container as | Any valid group ID | `0` |
| `LOG_LEVEL` | Log level to use | `debug` / `info` / `warn` / `error` | `info` |
| `NO_EXTERNAL_CONNECTION` | Disables Homarr's internet-backed requests, including update checks, weather, and RSS feeds | `true` or `false` | `false` |
| `ENABLE_DNS_CACHING` | Enables dns caching. Enabled by default, but if you experience issues with IPv6 or static IPs, see [#4006](https://github.com/homarr-labs/homarr/issues/4006) or disable this flag | `true` or `false` | `true` |
| `HOMARR_DISABLE_IPV6` | Disables the IPv6 listener of the internal nginx proxy, even when the host supports IPv6. IPv6 is normally disabled automatically when the host has no IPv6 configured. See [#4596](https://github.com/homarr-labs/homarr/issues/4596) | `true` or `false` | `false` |
## Authentication
+18 -9
View File
@@ -9,17 +9,19 @@ tags:
---
## Icon picker
Icons in Homarr are automatically requested from multiple sources:
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with over 1888+ curated, high-quality icons
- [selfh.st icons](https://github.com/selfhst/icons)
- [Simple icons](https://github.com/simple-icons/simple-icons)
- [Tabler icons](https://tabler-icons.io/)
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
- [Dashboard Icons](https://dashboardicons.com/) - Our recommended source with more than 1,888 curated, high-quality icons
- [selfh.st icons](https://github.com/selfhst/icons)
- [Simple icons](https://github.com/simple-icons/simple-icons)
- [Tabler icons](https://tabler-icons.io/)
- [Papirus icons](https://github.com/PapirusDevelopmentTeam/papirus-icon-theme)
- [Homelab Svg assets](https://github.com/loganmarchione/homelab-svg-assets)
- **Local icons**: automatically fetched from the [medias feature](/docs/management/media/)
Using these icon sources, Homarr provides a total collection of over 11,000 icons at your fingertips.
The icon picker is a fast searcher that helps you to find your desired icon simply by entering a search term.
The image picker searches this collection after a short typing delay. When a name is available, such as an app or search engine name, the picker uses it as the initial search. Results show your locally uploaded images first, followed by SVG icons and then other image formats.
### Finding Missing Icons
@@ -29,6 +31,13 @@ If you can't find the icon you're looking for in Homarr's built-in picker, we st
Icons that are high quality and printable ([SVG](https://wikipedia.org/wiki/Scalable_Vector_Graphics)) are marked with a red badge "SVG" at the top right.
You can clear the selected image with the clear button or the usual keyboard shortcut for deleting a line (Command+Backspace on macOS or Control+Backspace on other platforms). Clearing the field does not restore the previous image.
### Using external icons
If you don't find the icon you're looking for on Dashboard Icons or wish to use a different source, you can simply enter a URL that points to an icon.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. It is important that the URL directly points to the file without any advertisements or elements around it.
Direct HTTP(S) image URLs are used as entered instead of being searched in the built-in repositories. The picker previews the image and shows whether it loaded successfully. It is important that the URL directly points to the file without any advertisements or elements around it.
### Uploading a local image
Users with media upload permission can select **Upload image** next to the field. After the upload finishes, the new local image is selected immediately and is available at the top of future searches.
-1
View File
@@ -47,7 +47,6 @@ services:
traefik.http.routers.homarr.rule: Host(`your.internal.dns.address.here.com`)
traefik.http.routers.homarr.entrypoints: websecure
traefik.http.routers.homarr-secure.app: homarr
traefik.http.apps.homarr.loadbalancer.server.port: 7575
networks:
proxy:
@@ -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.25.0](https://img.shields.io/badge/Version-8.25.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.73.0](https://img.shields.io/badge/AppVersion-v1.73.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.73.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 />
@@ -0,0 +1,48 @@
---
title: "What's Up Docker"
description: "What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available."
hide_title: true
---
import { IntegrationHeader } from "@site/src/components/integrations/header";
import { IntegrationCapabilites } from "@site/src/components/integrations/widgets";
import { AddingIntegration } from "@site/src/components/integrations/adding";
import { IntegrationSecrets } from "@site/src/components/integrations/secrets";
import { wudIntegration } from ".";
import { wudWidget } from "@site/docs/widgets/whats-up-docker";
<IntegrationHeader integration={wudIntegration} categories={["Health Monitoring"]} />
The What's Up Docker integration lets Homarr connect to your [WUD](https://github.com/getwud/wud) instance so you can see how many of your watched containers have image updates available directly from your dashboard.
### Widgets & Capabilities
<IntegrationCapabilites items={[{ widget: wudWidget }]} />
### Adding the integration
<AddingIntegration />
:::info URL configuration
Use the root URL of your WUD instance, for example `https://wud.example.com`. Homarr calls the `/api/app` and `/api/containers` endpoints under that URL automatically.
:::
### Secrets
WUD does not require authentication by default. If your instance is protected with [Basic Authentication](https://github.com/getwud/wud/blob/main/docs/configuration/authentications/basic/README.md), you can provide a username and password.
<IntegrationSecrets
secrets={[
{
credentials: [],
steps: [],
},
{
credentials: ["username", "password"],
steps: [
"Open your WUD instance's configuration and configure a WUD_AUTH_BASIC_* user as described in the WUD documentation",
"Enter that username and password in Homarr — Basic authentication is handled automatically",
],
},
]}
/>
@@ -0,0 +1,9 @@
import { IntegrationDefinition } from "@site/src/types";
export const wudIntegration: IntegrationDefinition = {
name: "What's Up Docker",
description:
"What's Up Docker (WUD) watches your Docker containers and reports which ones have newer image versions available.",
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
path: "../../integrations/whats-up-docker",
};
+1 -1
View File
@@ -28,7 +28,7 @@ The name must be at least one character long and can be chosen freely.
### Icon URL
This is the URL to the icon that will be displayed on the app widget.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. Direct image URLs are used as entered and are not searched in the icon repositories.
You can paste a direct HTTP(S) URL to an image, search the included icon repositories by name, or upload a local file through the upload button. The app name is pre-filled as the initial library search when no image is selected. Local images appear first, followed by SVG icons and other formats. Direct image URLs are previewed, used as entered, and are not searched in the icon repositories.
![Icon URL](./img/app-icon-url.png)
+70 -33
View File
@@ -9,11 +9,11 @@ tags:
- Layout
---
import iconColorImage from './img/icon-color.png'
import itemRadiusImage from './img/item-radius.png'
import layoutLargeImage from './img/layout-large.png'
import layoutMediumImage from './img/layout-medium.png'
import layoutSmallImage from './img/layout-small.png'
import iconColorImage from "./img/icon-color.png";
import itemRadiusImage from "./img/item-radius.png";
import layoutLargeImage from "./img/layout-large.png";
import layoutMediumImage from "./img/layout-medium.png";
import layoutSmallImage from "./img/layout-small.png";
Each dashboard is called a "board".
You can create as many boards as you want.
@@ -24,24 +24,29 @@ Other boards can be viewed by direct links (e.g. my-homarr.com/board/smart-home)
Names of boards must be unique because this will be used in the URL to load the desired board.
## Create a new board
When creating a board, the availability of the board name will be checked for you.
![](./img/create-board-modal.png)
### Name
The name must not include any special characters and must be at least one character long.
Underscores and hyphen-minus are allowed.
### Column count
Homarr has a complex drag and drop system that can be used from the browser.
Homarr has a complex drag-and-drop system that can be used from the browser.
The column count defines how many horizontal columns are available to drag and drop elements to.
### Public
This will enable public access for everyone - regardless of whether they are logged in or not.
This can be really useful if you want to have public boards in your company or for family and fiends.
This can be really useful if you want to have public boards in your company or for family and friends.
Modifications for public boards must be allowed explicitly using user groups and their permissions.
## Board settings
You navigate to the board settings by clicking on these three dots:
![](./img/navigate-to-settings.png)
@@ -50,8 +55,12 @@ Alternatively, you can also navigate from the board page itself using the cog ic
![](./img/navigate-to-settings-2.png)
All board settings are edited on a single page. Click the **Save changes** button in the save bar at the bottom to apply them, or **Discard** to restore the last saved values. The save bar only appears when you have unsaved changes. Access control and the Danger zone are independent sections and are applied immediately.
### General
#### Page title
This is the title that will be visible at the top left in the navigation bar:
![](./img/page-title.png)
@@ -59,6 +68,7 @@ This is the title that will be visible at the top left in the navigation bar:
This field is optional and Homarr will fall back to "Homarr" if nothing is set.
#### Meta title
This is the secondary title of the page that is not directly visible in the page itself.
Browser will use it to set the title of tabs:
@@ -67,10 +77,12 @@ Browser will use it to set the title of tabs:
Homarr will fall back to the board name and a suffix if nothing is set.
#### Logo image URL
This is the URL that is used for the image at the top left.
Homarr will fall back to the Homarr logo if nothing is set.
#### Favicon image URL
This is the URL that is used for the favicon in the tab title.
Homarr will fall back to the Homarr logo if nothing is set.
@@ -94,51 +106,65 @@ Amount of columns for this layout. Defines how many items can be placed in a row
#### Breakpoint
Minimum width of the screen where the layout will be used.
Once the minmum of the next layout is reached the layout will change.
Minimum width of the screen where the layout will be used.
Once the minimum of the next layout is reached the layout will change.
#### Preview
<img src={layoutSmallImage} height={256} />
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
<img src={layoutSmallImage} height={256} />
<img src={layoutMediumImage} height={256} />
<img src={layoutLargeImage} height={256} />
### Background
#### Background image URL
Homarr can display a background image behind apps and widgets.
This URL can be used to define what image should be shown.
#### Background image attachment
This defines whether the background should move when the user scrolls.
#### Background image size
This defines whether the background should cover the entire page (and possibly be cropped) or never be cropped but may not cover the entire page.
#### Background image repeat
This defines whether the background should be repeated if it cannot cover the entire page.
This option can be useful if you have patterns that can be repeated easily (e.g. patterns):
![](./img/background-repeat.png)
### Appearance
#### Primary color
Color that can be chosen freely to give accents to active elements.
#### Secondary color
Color that can be chosen freely to give accents.
#### Opacity
Opacity of the colors.
#### Icon color
Change the color of your bookmark / app icons.
<img src={iconColorImage} height={100} />
<img src={iconColorImage} height={100} />
#### Item radius
Change the radius of your items / categories.
<img src={itemRadiusImage} height={100} />
<img src={itemRadiusImage} height={100} />
### Custom CSS
This is the field where you can enter custom CSS.
The styling will only be applied on the respective board page.
@@ -147,14 +173,16 @@ The styling will only be applied on the respective board page.
### Behaviour
#### Disable app status
This will disable the status check for apps.
### Access control
With the access control, you can permit a single user or a group to your board.
The users and groups can have one of these three levels of access:
| **Permission Level** | View board | Edit mode | Settings | Danger zone (eg. delete) |
|----------------------|------------|-----------|----------|--------------------------|
| -------------------- | ---------- | --------- | -------- | ------------------------ |
| View board | X | | | |
| Modify board | X | X | X | |
| Full access | X | X | X | X |
@@ -173,22 +201,27 @@ After editing permissions, the save button below the table must be pressed.
Otherwise, all changes will be discarded.
### Danger zone
#### Rename
Renaming a board will break all existing links to it permanently with no redirect.
#### Board visibility
By default, boards will be private. This means only authenticated users that are authorized have access.
Making a board public will expose it to everyone - including unauthenticated users.
#### Delete
This will permanently delete the board.
This action is irreversible and cannot be undone.
## Delete boards
The deletion of boards is permanent and cannot be undone.
There are two ways to delete a board:
- Navigate to the settings of your board, open the "Danger zone" dropdown and click on the "delete this board" button.
- Navigate to the settings of your board, open the "Danger zone" section and click on the "delete this board" button.
- Navigate to the list of boards in the management pages, click the three dots and click on "delete permanently".
Both actions will execute the same deletion process.
@@ -196,7 +229,7 @@ Both actions will execute the same deletion process.
## Duplicate boards
:::note
Duplicating a board will not copy the users and groups that have access to the board
Duplicating a board will not copy the users and groups that have access to the board
nor the integrations the user does not have access to.
:::
@@ -206,47 +239,51 @@ You can duplicate a board by clicking on the three dots in the list of boards an
![](./img/duplicate-board-modal.png)
## Setting boards as your home board
The home board is the dashboard, that will be shown at the root path of Homarr or if no board name was passed in the URL.
All the following paths will lead to the home board:
- ``my-homarr.com``
- ``my-homarr.com/boards``
- `my-homarr.com`
- `my-homarr.com/boards`
Your home board will have a special small badge at the top right in the list of boards:
![](./img/home-board-flag.png)
### For yourself
To set the home board yourself, the easiest option is to set it directly in the list of boards:
![](./img/set-as-home-board-menu.png)
Alternatively, you can also define it via your user profile:
1. Click on your user profile at the top right
2. Click on ``Your preferences``
3. Change the home board in the ``home board`` dropdown and click on ``Save``.
2. Click on `Your preferences`
3. Change the home board in the `home board` dropdown and click on `Save`.
### For other users
1. Navigate to the users: ``Management`` > ``Users`` > ``Manage``
1. Navigate to the users: `Management` > `Users` > `Manage`
2. Click on the username of the user that you want to adjust
3. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-other-user.png)
3. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-other-user.png)
### For user groups
1. Navigate to the user groups: ``Management`` > ``Users`` > ``Groups`` > ``Manage``
2. Go to tab ``Settings``
3. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-user-group.png)
1. Navigate to the user groups: `Management` > `Users` > `Groups` > `Manage`
2. Go to tab `Settings`
3. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-user-group.png)
### For whole server
You can also set a global home board that will be used for all users that have not set a home board themselves.
It will also be used for anonymous users.
1. Navigate to the settings: ``Management`` > ``Settings`` > ``Boards``
2. Select the desired home board via the dropdown and click on ``Save`` \
![](./img/set-home-board-global.png)
1. Navigate to the settings: `Management` > `Settings` > `Boards`
2. Select the desired home board via the dropdown and click on `Save` \
![](./img/set-home-board-global.png)
:::note
For the global home board you can only select boards that are public.
@@ -255,4 +292,4 @@ For the global home board you can only select boards that are public.
### Mobile home board
Same as the home board, you can also set a mobile home board.
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
It will be used for devices that are detected as mobile devices. (Using User-Agent header)
@@ -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,6 +13,8 @@ tags:
The settings page applies options to the whole Homarr application.
Some changes need a full page reload before they take effect.
All changes are collected in a single form. Click the **Save changes** button in the save bar at the bottom of the page to apply them. The save bar only appears when you have unsaved changes, and the **Discard** button reverts your changes.
## Analytics
Homarr uses [PostHog](https://posthog.com/) to collect anonymous usage data.
+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} />
@@ -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} />
@@ -0,0 +1,35 @@
---
title: "What's Up Docker"
description: "Displays how many watched containers have image updates available."
hide_title: true
---
import { WidgetHeader } from "@site/src/components/widgets/header";
import { AddingWidget } from "@site/src/components/widgets/adding";
import { WidgetConfig } from "@site/src/components/widgets/configuration";
import { WidgetIntegrations } from "@site/src/components/widgets/integrations";
import { wudWidget } from ".";
import { wudIntegration } from "@site/docs/integrations/whats-up-docker";
<WidgetHeader widget={wudWidget} categories={["Health Monitoring"]} />
This widget shows two stats from your WUD instance: the total number of monitored containers, and how many of them currently have an image update available. Optionally, it can also list each outdated container with its current and new version, linking to the release notes when WUD provides one.
### Supported Integrations
<WidgetIntegrations
items={[
{
integration: wudIntegration,
note: "Displays monitored container and available-update counts",
},
]}
/>
### Adding the widget
<AddingWidget />
### Configuration
<WidgetConfig configuration={wudWidget.configuration} />
@@ -0,0 +1,37 @@
import { WidgetDefinition } from "@site/src/types";
import { IconBrandDocker } from "@tabler/icons-react";
export const wudWidget: WidgetDefinition = {
icon: IconBrandDocker,
name: "What's Up Docker",
description: "Displays how many watched containers have image updates available.",
path: "../../widgets/whats-up-docker",
configuration: {
items: [
{
name: "Show title",
description: "Displays the icon and \"What's Up Docker\" label above the stats",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Show update ring",
description: "Displays a ring showing the percentage of monitored containers with a pending update",
values: { type: "boolean" },
defaultValue: "yes",
},
{
name: "Layout",
description: "Arrange the two stats side by side (Horizontal) or stacked (Vertical)",
values: { type: "select", options: ["Horizontal", "Vertical"] },
defaultValue: "Horizontal",
},
{
name: "Show container update list",
description: "Lists each monitored container that has an image update available, linking to its release notes when known",
values: { type: "boolean" },
defaultValue: "no",
},
],
},
};
@@ -19,130 +19,106 @@ import {
import { useDisclosure } from "@mantine/hooks";
import { IconX } from "@tabler/icons-react";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { SectionCard } from "~/components/manage/section-card";
import { generateColors } from "../../(content)/_theme";
import { useSavePartialSettingsMutation } from "./_shared";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
const hexRegex = /^#[0-9a-fA-F]{6}$/;
const progressPercentageLabel = (value: number) => `${value}%`;
export const ColorSettingsContent = ({ board }: Props) => {
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
},
});
export const ColorSettingsContent = ({ form }: Props) => {
const [showPreview, { toggle }] = useDisclosure(false);
const t = useI18n();
const theme = useMantineTheme();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("primaryColor")}
/>
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<SectionCard title={t("board.setting.section.appearance.title")}>
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Stack gap="xs">
<ColorInput
label={t("board.field.secondaryColor.label")}
label={t("board.field.primaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
{...form.getInputProps("primaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
</Stack>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<ColorInput
label={t("board.field.secondaryColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
{...form.getInputProps("secondaryColor")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Anchor onClick={toggle}>{showPreview ? t("common.preview.hide") : t("common.preview.show")}</Anchor>
</Grid.Col>
<Grid.Col span={12}>
<Collapse expanded={showPreview}>
<Stack>
<ColorsPreview previewColor={form.values.primaryColor} />
<ColorsPreview previewColor={form.values.secondaryColor} />
</Stack>
</Collapse>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<InputWrapper label={t("board.field.opacity.label")}>
<Slider
my={6}
min={0}
max={100}
step={5}
label={progressPercentageLabel}
{...form.getInputProps("opacity")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
</InputWrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Group align="end">
<ColorInput
label={t("board.field.iconColor.label")}
format="hex"
swatches={Object.values(theme.colors).map((color) => color[6])}
flex={1}
{...form.getInputProps("iconColor")}
/>
<Button
type="button"
variant="subtle"
leftSection={<IconX />}
onClick={() => form.setFieldValue("iconColor", "")}
disabled={!form.values.iconColor}
>
{t("board.field.clearColor.label")}
</Button>
</Group>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Select
label={t("board.field.itemRadius.label")}
description={t("board.field.itemRadius.description")}
data={[
{ label: t("board.field.itemRadius.option.xs"), value: "xs" },
{ label: t("board.field.itemRadius.option.sm"), value: "sm" },
{ label: t("board.field.itemRadius.option.md"), value: "md" },
{ label: t("board.field.itemRadius.option.lg"), value: "lg" },
{ label: t("board.field.itemRadius.option.xl"), value: "xl" },
]}
{...form.getInputProps("itemRadius")}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -1,41 +1,32 @@
"use client";
import { startTransition } from "react";
import { ActionIcon, Autocomplete, Button, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { ActionIcon, Autocomplete, Center, Grid, Group, Popover, Stack, Text } from "@mantine/core";
import { useDebouncedValue } from "@mantine/hooks";
import { IconPhotoOff, IconUpload } from "@tabler/icons-react";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { backgroundImageAttachments, backgroundImageRepeats, backgroundImageSizes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { UploadMedia } from "@homarr/forms-collection";
import type { TranslationObject } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
import type { SelectItemWithDescriptionBadge } from "@homarr/ui";
import { SelectWithDescriptionBadge } from "@homarr/ui";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const BackgroundSettingsContent = ({ board }: Props) => {
export const BackgroundSettingsContent = ({ form }: Props) => {
const t = useI18n();
const { data: session } = useSession();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(boardSavePartialSettingsSchema, {
initialValues: {
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
},
});
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl, 200);
const [debouncedSearch] = useDebouncedValue(form.values.backgroundImageUrl ?? "", 200);
const medias = clientApi.media.getPaginated.useQuery({
page: 1,
pageSize: 10,
@@ -53,121 +44,106 @@ export const BackgroundSettingsContent = ({ board }: Props) => {
const backgroundImageRepeatData = useBackgroundOptionData("backgroundImageRepeat", backgroundImageRepeats);
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
<SectionCard title={t("board.setting.section.background.title")}>
<Grid>
<Grid.Col span={12}>
<Group wrap="nowrap" gap="xs" w="100%" align="start">
<Autocomplete
flex={1}
leftSection={
form.values.backgroundImageUrl &&
form.values.backgroundImageUrl.trim().length >= 2 && (
<Popover width={300} withArrow>
<Popover.Target>
<Center h="100%">
<ImagePreview src={form.values.backgroundImageUrl} w={20} h={20} />
</Center>
</Popover.Target>
<Popover.Dropdown>
<ImagePreview src={form.values.backgroundImageUrl} w="100%" />
</Popover.Dropdown>
</Popover>
)
}
// We filter it on the server
filter={({ options }) => options}
label={t("board.field.backgroundImageUrl.label")}
placeholder={`${t("board.field.backgroundImageUrl.placeholder")}...`}
renderOption={({ option }) => {
const current = imageMap.get(option.value);
if (!current) return null;
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
return (
<Group gap="sm">
<ImagePreview src={option.value} w={20} h={20} />
<Stack gap={0}>
<Text size="sm">{current.name}</Text>
<Text size="xs" c="dimmed">
{option.value}
</Text>
</Stack>
</Group>
);
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(uploadedMedias) => {
const first = uploadedMedias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
data={[
{
group: t("board.field.backgroundImageUrl.group.your"),
items: images
.filter((media) => media.creatorId === session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
{
group: t("board.field.backgroundImageUrl.group.other"),
items: images
.filter((media) => media.creatorId !== session?.user.id)
.map((media) => `/api/user-medias/${media.id}`),
},
]}
{...form.getInputProps("backgroundImageUrl")}
/>
{session?.user.permissions.includes("media-upload") && (
<UploadMedia
onSuccess={(medias) => {
const first = medias.at(0);
if (!first) return;
startTransition(() => {
form.setFieldValue("backgroundImageUrl", first.url);
});
}}
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
>
{({ onClick, loading }) => (
<ActionIcon onClick={onClick} loading={loading} mt={24} size={36} variant="default">
<IconUpload size={16} stroke={1.5} />
</ActionIcon>
)}
</UploadMedia>
)}
</Group>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageAttachment.label")}
data={backgroundImageAttachmentData}
{...form.getInputProps("backgroundImageAttachment")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageSize.label")}
data={backgroundImageSizeData}
{...form.getInputProps("backgroundImageSize")}
/>
</Grid.Col>
<Grid.Col span={12}>
<SelectWithDescriptionBadge
label={t("board.field.backgroundImageRepeat.label")}
data={backgroundImageRepeatData}
{...form.getInputProps("backgroundImageRepeat")}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -1,48 +1,27 @@
"use client";
import { Button, Group, Stack, Switch } from "@mantine/core";
import { Switch } from "@mantine/core";
import { useForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const BehaviorSettingsContent = ({ board }: Props) => {
export const BehaviorSettingsContent = ({ form }: Props) => {
const t = useI18n();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
disableStatus: board.disableStatus,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<SectionCard title={t("board.setting.section.behavior.title")}>
<Switch
label={t("board.field.disableStatus.label")}
description={t("board.field.disableStatus.description")}
{...form.getInputProps("disableStatus", { type: "checkbox" })}
/>
</SectionCard>
);
};
@@ -1,6 +1,6 @@
"use client";
import { Alert, Button, Group, Input, Stack } from "@mantine/core";
import { Alert, Input } from "@mantine/core";
import { highlight, languages } from "prismjs";
import Editor from "react-simple-code-editor";
@@ -8,59 +8,39 @@ import "~/styles/prismjs.scss";
import { IconInfoCircle } from "@tabler/icons-react";
import { useForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
import classes from "./customcss.module.css";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const CustomCssSettingsContent = ({ board }: Props) => {
export const CustomCssSettingsContent = ({ form }: Props) => {
const t = useI18n();
const customCssT = useScopedI18n("board.field.customCss");
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useForm({
initialValues: {
customCss: board.customCss ?? "",
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<CustomCssInput {...form.getInputProps("customCss")} />
<SectionCard title={t("board.setting.section.customCss.title")}>
<CustomCssInput {...form.getInputProps("customCss")} />
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<Alert variant="light" color="cyan" title={customCssT("customClassesAlert.title")} icon={<IconInfoCircle />}>
{customCssT("customClassesAlert.description")}
</Alert>
</SectionCard>
);
};
interface CustomCssInputProps {
value?: string;
onChange: (value: string) => void;
error?: React.ReactNode;
}
const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
const CustomCssInput = ({ value, onChange, error }: CustomCssInputProps) => {
const customCssT = useScopedI18n("board.field.customCss");
return (
@@ -70,6 +50,7 @@ const CustomCssInput = ({ value, onChange }: CustomCssInputProps) => {
htmlFor: "custom-css",
}}
description={customCssT("description")}
error={error}
inputWrapperOrder={["label", "description", "input", "error"]}
>
<div className={classes.codeEditorRoot}>
@@ -2,7 +2,7 @@
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { Button, Divider, Group, Stack, Text } from "@mantine/core";
import { Button } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useRequiredBoard } from "@homarr/boards/context";
@@ -10,7 +10,7 @@ import { useConfirmModal, useModalAction } from "@homarr/modals";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardRenameModal } from "~/components/board/modals/board-rename-modal";
import classes from "./danger.module.css";
import { DangerZoneItem, DangerZoneRoot } from "~/components/manage/danger-zone";
export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility: boolean }) => {
const board = useRequiredBoard();
@@ -82,60 +82,36 @@ export const DangerZoneSettingsContent = ({ hideVisibility }: { hideVisibility:
}, [board.id, deleteBoard, router, t, openConfirmModal]);
return (
<Stack gap="sm">
<Divider />
<DangerZoneRow
<DangerZoneRoot>
<DangerZoneItem
label={t("section.dangerZone.action.rename.label")}
description={t("section.dangerZone.action.rename.description")}
buttonText={t("section.dangerZone.action.rename.button")}
onClick={onRenameClick}
action={
<Button variant="subtle" color="red" onClick={onRenameClick}>
{t("section.dangerZone.action.rename.button")}
</Button>
}
/>
{hideVisibility ? null : (
<>
<Divider />
<DangerZoneRow
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
buttonText={t(`section.dangerZone.action.visibility.button.${visibility}`)}
onClick={onVisibilityClick}
isPending={isChangeVisibilityPending}
/>
</>
{!hideVisibility && (
<DangerZoneItem
label={t("section.dangerZone.action.visibility.label")}
description={t(`section.dangerZone.action.visibility.description.${visibility}`)}
action={
<Button variant="subtle" color="red" loading={isChangeVisibilityPending} onClick={onVisibilityClick}>
{t(`section.dangerZone.action.visibility.button.${visibility}`)}
</Button>
}
/>
)}
<Divider />
<DangerZoneRow
<DangerZoneItem
label={t("section.dangerZone.action.delete.label")}
description={t("section.dangerZone.action.delete.description")}
buttonText={t("section.dangerZone.action.delete.button")}
onClick={onDeleteClick}
isPending={isDeletePending}
action={
<Button variant="subtle" color="red" loading={isDeletePending} onClick={onDeleteClick}>
{t("section.dangerZone.action.delete.button")}
</Button>
}
/>
</Stack>
);
};
interface DangerZoneRowProps {
label: string;
description: string;
buttonText: string;
isPending?: boolean;
onClick: () => void;
}
const DangerZoneRow = ({ label, description, buttonText, onClick, isPending }: DangerZoneRowProps) => {
return (
<Group justify="space-between" px="md" className={classes.dangerZoneGroup}>
<Stack gap={0}>
<Text fw="bold" size="sm">
{label}
</Text>
<Text size="sm">{description}</Text>
</Stack>
<Group justify="end" w={{ base: "100%", xs: "auto" }}>
<Button variant="subtle" color="red" loading={isPending} onClick={onClick}>
{buttonText}
</Button>
</Group>
</Group>
</DangerZoneRoot>
);
};
@@ -1,128 +1,66 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Grid, Group, Loader, Stack, TextInput } from "@mantine/core";
import { useEffect } from "react";
import { Grid, Loader, TextInput } from "@mantine/core";
import { useDebouncedValue, useDocumentTitle } from "@mantine/hooks";
import { useUpdateBoard } from "@homarr/boards/updater";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { IconPicker } from "@homarr/forms-collection";
import { useI18n } from "@homarr/translation/client";
import { boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { createMetaTitle } from "~/metadata";
import type { Board } from "../../_types";
import { useSavePartialSettingsMutation } from "./_shared";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const GeneralSettingsContent = ({ board }: Props) => {
export const GeneralSettingsContent = ({ board, form }: Props) => {
const t = useI18n();
const ref = useRef({
pageTitle: board.pageTitle,
logoImageUrl: board.logoImageUrl,
});
const { updateBoard } = useUpdateBoard();
const { mutate: savePartialSettings, isPending } = useSavePartialSettingsMutation(board);
const form = useZodForm(
boardSavePartialSettingsSchema
.pick({
pageTitle: true,
logoImageUrl: true,
metaTitle: true,
faviconImageUrl: true,
})
.required(),
{
initialValues: {
pageTitle: board.pageTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
metaTitle: board.metaTitle ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
},
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
},
);
useLogoPreview(form.values.logoImageUrl);
const metaTitleStatus = useMetaTitlePreview(form.values.metaTitle);
// Cleanup for not applied changes of the page title and logo image URL
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: ref.current.pageTitle,
logoImageUrl: ref.current.logoImageUrl,
}));
};
}, [updateBoard]);
return (
<form
onSubmit={form.onSubmit((values) => {
// Save the current values to the ref so that it does not reset if the form is submitted
ref.current = {
pageTitle: values.pageTitle,
logoImageUrl: values.logoImageUrl,
};
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
savePartialSettings({
id: board.id,
...values,
});
})}
>
<Stack>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
</Grid>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
</Button>
</Group>
</Stack>
</form>
<SectionCard title={t("board.setting.section.general.title")}>
<Grid>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.pageTitle.label")}
placeholder="Homarr"
{...form.getInputProps("pageTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<TextInput
label={t("board.field.metaTitle.label")}
placeholder={createMetaTitle(t("board.content.metaTitle", { boardName: board.name }))}
rightSection={metaTitleStatus.isPending && <Loader size="xs" />}
{...form.getInputProps("metaTitle")}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("logoImageUrl")}
label={t("board.field.logoImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
<Grid.Col span={{ xs: 12, md: 6 }}>
<IconPicker
{...form.getInputProps("faviconImageUrl")}
label={t("board.field.faviconImageUrl.label")}
placeholder="/logo/logo.png"
withAsterisk={false}
/>
</Grid.Col>
</Grid>
</SectionCard>
);
};
@@ -146,14 +84,3 @@ const useMetaTitlePreview = (title: string | null) => {
isPending: (title ?? "") !== metaTitleDebounced,
};
};
// Previously we used the useFavicon hook from Mantine
// However if the user tried to click on a link to a different page
// it caused an error "Cannot read properties of null (reading 'removeChild')"
// Probably because the head element was null (https://github.com/mantinedev/mantine/blob/b90d9b81031f45f5d15e75f25138ed6477f65bce/packages/%40mantine/hooks/src/use-favicon/use-favicon.ts#L21)
// See https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link: HTMLLinkElement | null = document.querySelector('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -2,114 +2,79 @@
import { Button, Fieldset, Grid, Group, Input, NumberInput, Slider, Stack, Text, TextInput } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { createId } from "@homarr/common";
import { useZodForm } from "@homarr/form";
import type { UseFormReturnType } from "@homarr/form";
import { useI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema } from "@homarr/validation/board";
import type { Board } from "../../_types";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./_settings-form";
interface Props {
board: Board;
form: UseFormReturnType<FormValues>;
}
export const LayoutSettingsContent = ({ board }: Props) => {
export const LayoutSettingsContent = ({ form }: Props) => {
const t = useI18n();
const utils = clientApi.useUtils();
const { mutate: saveLayouts, isPending } = clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
const form = useZodForm(boardSaveLayoutsSchema.omit({ id: true }).required(), {
initialValues: {
layouts: board.layouts,
},
});
return (
<form
onSubmit={form.onSubmit((values) => {
saveLayouts({
id: board.id,
...values,
});
})}
>
<Stack>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
variant="subtle"
onClick={() => {
form.setValues({
layouts: [
...form.values.layouts,
{
id: createId(),
name: "",
columnCount: 10,
breakpoint: 0,
},
],
});
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
variant="subtle"
onClick={() => {
form.setValues((previous) =>
previous.layouts !== undefined && previous.layouts.length >= 2
? {
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}
: previous,
);
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.saveChanges")}
<SectionCard title={t("board.setting.section.layout.title")}>
<Stack gap="sm">
<Group justify="space-between" align="center">
<Text fw={500}>{t("board.setting.section.layout.responsive.title")}</Text>
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: [...(previous.layouts ?? []), { id: createId(), name: "", columnCount: 10, breakpoint: 0 }],
}));
}}
>
{t("board.setting.section.layout.responsive.action.add")}
</Button>
</Group>
{form.values.layouts.map((layout, index) => (
<Fieldset key={layout.id} legend={layout.name} bg="transparent">
<Grid>
<Grid.Col span={{ sm: 12, md: 6 }}>
<TextInput {...form.getInputProps(`layouts.${index}.name`)} label={t("layout.field.name.label")} />
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<Input.Wrapper label={t("layout.field.columnCount.label")}>
<Slider mt="xs" min={1} max={24} step={1} {...form.getInputProps(`layouts.${index}.columnCount`)} />
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={{ sm: 12, md: 6 }}>
<NumberInput
{...form.getInputProps(`layouts.${index}.breakpoint`)}
label={t("layout.field.breakpoint.label")}
description={t("layout.field.breakpoint.description")}
/>
</Grid.Col>
</Grid>
{form.values.layouts.length >= 2 && (
<Group justify="end">
<Button
type="button"
variant="subtle"
onClick={() => {
form.setValues((previous) => ({
...previous,
layouts: form.values.layouts.filter((filteredLayout) => filteredLayout.id !== layout.id),
}));
}}
>
{t("common.action.remove")}
</Button>
</Group>
)}
</Fieldset>
))}
</Stack>
</form>
</SectionCard>
);
};
@@ -0,0 +1,221 @@
"use client";
import { useEffect, useRef } from "react";
import { Button, Stack } from "@mantine/core";
import type { z } from "zod/v4";
import type { RouterOutputs } from "@homarr/api";
import { useUpdateBoard } from "@homarr/boards/updater";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { boardSaveLayoutsSchema, boardSavePartialSettingsSchema } from "@homarr/validation/board";
import { homarrLogoPath } from "~/components/layout/logo/homarr-logo";
import { SectionCard } from "~/components/manage/section-card";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import type { Board } from "../../_types";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { useSaveLayoutsMutation, useSavePartialSettingsMutation } from "./_shared";
const boardSettingsFormSchema = boardSavePartialSettingsSchema
.extend({
layouts: boardSaveLayoutsSchema.shape.layouts,
})
.required();
export type FormValues = z.infer<typeof boardSettingsFormSchema>;
const PARTIAL_FORM_KEYS = [
"pageTitle",
"metaTitle",
"logoImageUrl",
"faviconImageUrl",
"backgroundImageUrl",
"backgroundImageAttachment",
"backgroundImageRepeat",
"backgroundImageSize",
"primaryColor",
"secondaryColor",
"opacity",
"iconColor",
"itemRadius",
"customCss",
"disableStatus",
] as const;
const buildInitialValues = (board: Board): FormValues => ({
pageTitle: board.pageTitle ?? "",
metaTitle: board.metaTitle ?? "",
logoImageUrl: board.logoImageUrl ?? "",
faviconImageUrl: board.faviconImageUrl ?? "",
backgroundImageUrl: board.backgroundImageUrl ?? "",
backgroundImageAttachment: board.backgroundImageAttachment,
backgroundImageRepeat: board.backgroundImageRepeat,
backgroundImageSize: board.backgroundImageSize,
primaryColor: board.primaryColor,
secondaryColor: board.secondaryColor,
opacity: board.opacity,
iconColor: board.iconColor ?? "",
itemRadius: board.itemRadius,
customCss: board.customCss ?? "",
disableStatus: board.disableStatus,
layouts: board.layouts,
});
interface BoardSettingsFormProps {
board: Board;
permissions: RouterOutputs["board"]["getBoardPermissions"];
hasFullAccess: boolean;
hideVisibility: boolean;
}
export const BoardSettingsForm = ({ board, permissions, hasFullAccess, hideVisibility }: BoardSettingsFormProps) => {
const t = useI18n();
const tSection = useScopedI18n("board.setting.section");
const { updateBoard } = useUpdateBoard();
const savePartialSettings = useSavePartialSettingsMutation(board);
const saveLayouts = useSaveLayoutsMutation(board);
const form = useZodForm(boardSettingsFormSchema, {
initialValues: buildInitialValues(board),
onValuesChange({ pageTitle }) {
updateBoard((previous) => ({
...previous,
pageTitle,
}));
},
});
const initialValuesRef = useRef(buildInitialValues(board));
const lastSavedRef = useRef({ pageTitle: board.pageTitle, logoImageUrl: board.logoImageUrl });
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
useEffect(() => {
return () => {
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
}, [updateBoard]);
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const { layouts, ...partialSettings } = values;
const saveActions: { when: boolean; action: () => Promise<unknown> }[] = [
{
when: changed(...PARTIAL_FORM_KEYS),
action: () =>
savePartialSettings.mutateAsync({ id: board.id, ...partialSettings }).then(() => {
updateFavicon(values.faviconImageUrl ?? homarrLogoPath);
}),
},
{
when: changed("layouts"),
action: () => saveLayouts.mutateAsync({ id: board.id, layouts }),
},
];
const promises = saveActions.filter((saveAction) => saveAction.when).map((saveAction) => saveAction.action());
if (promises.length === 0) return;
try {
await Promise.all(promises);
lastSavedRef.current = { pageTitle: values.pageTitle, logoImageUrl: values.logoImageUrl };
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync(`/boards/${board.name}/settings`);
showSuccessNotification({
title: t("common.notification.update.success"),
message: t("common.notification.update.success"),
});
} catch {
showErrorNotification({
title: t("common.notification.update.error"),
message: t("common.notification.update.error"),
});
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
updateBoard((previous) => ({
...previous,
pageTitle: lastSavedRef.current.pageTitle,
logoImageUrl: lastSavedRef.current.logoImageUrl,
}));
};
const isPending = savePartialSettings.isPending || saveLayouts.isPending;
return (
<Stack gap="xl">
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<GeneralSettingsContent board={board} form={form} />
<LayoutSettingsContent form={form} />
<BackgroundSettingsContent form={form} />
<ColorSettingsContent form={form} />
<CustomCssSettingsContent form={form} />
<BehaviorSettingsContent form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isPending} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isPending} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
{hasFullAccess && (
<SectionCard title={tSection("access.title")}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</SectionCard>
)}
{hasFullAccess && <DangerZoneSettingsContent hideVisibility={hideVisibility} />}
</Stack>
);
};
// Previously part of the general settings section; applied on the unified save
// https://github.com/homarr-labs/homarr/issues/4905
const updateFavicon = (url: string) => {
const link = document.querySelector<HTMLLinkElement>('link[rel="icon"]');
if (!link) return;
link.href = url;
};
@@ -11,3 +11,13 @@ export const useSavePartialSettingsMutation = (board: Board) => {
},
});
};
export const useSaveLayoutsMutation = (board: Board) => {
const utils = clientApi.useUtils();
return clientApi.board.saveLayouts.useMutation({
onSettled() {
void utils.board.getBoardByName.invalidate({ name: board.name });
void utils.board.getHomeBoard.invalidate();
},
});
};
@@ -1,5 +0,0 @@
@media (min-width: 36em) {
.dangerZoneGroup {
--group-wrap: nowrap !important;
}
}
@@ -1,44 +1,20 @@
import type { PropsWithChildren } from "react";
import { notFound } from "next/navigation";
import { AccordionControl, AccordionItem, AccordionPanel, Container, Stack, Text, Title } from "@mantine/core";
import {
IconAlertTriangle,
IconBrush,
IconClick,
IconFileTypeCss,
IconLayout,
IconPhoto,
IconSettings,
IconUser,
} from "@tabler/icons-react";
import { Container, Stack, Title } from "@mantine/core";
import { TRPCError } from "@trpc/server";
import { api } from "@homarr/api/server";
import { capitalize } from "@homarr/common";
import { db } from "@homarr/db";
import { getServerSettingByKeyAsync } from "@homarr/db/queries";
import type { TranslationObject } from "@homarr/translation";
import { getScopedI18n } from "@homarr/translation/server";
import type { TablerIcon } from "@homarr/ui";
import { getBoardPermissionsAsync } from "~/components/board/permissions/server";
import { ActiveTabAccordion } from "../../../../../components/active-tab-accordion";
import { ColorSettingsContent } from "./_appereance";
import { BackgroundSettingsContent } from "./_background";
import { BehaviorSettingsContent } from "./_behavior";
import { BoardAccessSettings } from "./_board-access";
import { CustomCssSettingsContent } from "./_customCss";
import { DangerZoneSettingsContent } from "./_danger";
import { GeneralSettingsContent } from "./_general";
import { LayoutSettingsContent } from "./_layout";
import { BoardSettingsForm } from "./_settings-form";
interface Props {
params: Promise<{
name: string;
}>;
searchParams: Promise<{
tab?: keyof TranslationObject["board"]["setting"]["section"];
}>;
}
const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) => {
@@ -57,11 +33,7 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
} catch (error) {
// Ignore not found errors and redirect to 404
// error is already logged in _layout-creator.tsx
if (error instanceof TRPCError && error.code === "NOT_FOUND") {
notFound();
}
if (error instanceof TRPCError && error.code === "BAD_REQUEST") {
if (error instanceof TRPCError && (error.code === "NOT_FOUND" || error.code === "BAD_REQUEST")) {
notFound();
}
@@ -70,7 +42,6 @@ const getBoardAndPermissionsAsync = async (params: Awaited<Props["params"]>) =>
};
export default async function BoardSettingsPage(props: Props) {
const searchParams = await props.searchParams;
const params = await props.params;
const { board, permissions } = await getBoardAndPermissionsAsync(params);
const boardSettings = await getServerSettingByKeyAsync(db, "board");
@@ -85,76 +56,13 @@ export default async function BoardSettingsPage(props: Props) {
<Container>
<Stack>
<Title>{t("title", { boardName: capitalize(board.name) })}</Title>
<ActiveTabAccordion variant="separated" defaultValue={searchParams.tab ?? "general"}>
<AccordionItemFor value="general" icon={IconSettings}>
<GeneralSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="layout" icon={IconLayout}>
<LayoutSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="background" icon={IconPhoto}>
<BackgroundSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="appearance" icon={IconBrush}>
<ColorSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="customCss" icon={IconFileTypeCss}>
<CustomCssSettingsContent board={board} />
</AccordionItemFor>
<AccordionItemFor value="behavior" icon={IconClick}>
<BehaviorSettingsContent board={board} />
</AccordionItemFor>
{hasFullAccess && (
<>
<AccordionItemFor value="access" icon={IconUser}>
<BoardAccessSettings board={board} initialPermissions={permissions} />
</AccordionItemFor>
<AccordionItemFor value="dangerZone" icon={IconAlertTriangle} danger noPadding>
<DangerZoneSettingsContent
hideVisibility={
boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id
}
/>
</AccordionItemFor>
</>
)}
</ActiveTabAccordion>
<BoardSettingsForm
board={board}
permissions={permissions}
hasFullAccess={hasFullAccess}
hideVisibility={boardSettings.homeBoardId === board.id || boardSettings.mobileHomeBoardId === board.id}
/>
</Stack>
</Container>
);
}
type AccordionItemForProps = PropsWithChildren<{
value: keyof TranslationObject["board"]["setting"]["section"];
icon: TablerIcon;
danger?: boolean;
noPadding?: boolean;
}>;
const AccordionItemFor = async ({ value, children, icon: Icon, danger, noPadding }: AccordionItemForProps) => {
const t = await getScopedI18n("board.setting.section");
return (
<AccordionItem
value={value}
styles={
danger
? {
item: {
"--__item-border-color": "rgba(248,81,73,0.4)",
borderWidth: 4,
},
}
: undefined
}
>
<AccordionControl icon={<Icon />}>
<Text fw="bold" size="lg">
{t(`${value}.title`)}
</Text>
</AccordionControl>
<AccordionPanel styles={noPadding ? { content: { paddingRight: 0, paddingLeft: 0 } } : undefined}>
{children}
</AccordionPanel>
</AccordionItem>
);
};
@@ -697,7 +697,11 @@ export function CustomWidgetForm({ mode, initialValues, definitionId }: CustomWi
<Stack gap="sm">
<TextInput label={t("field.name")} required {...form.getInputProps("name")} />
<Textarea label={t("field.description")} {...form.getInputProps("description")} />
<IconPicker withAsterisk={false} {...form.getInputProps("iconUrl")} />
<IconPicker
withAsterisk={false}
suggestedSearch={mode === "create" ? form.values.name : undefined}
{...form.getInputProps("iconUrl")}
/>
</Stack>
</Fieldset>
@@ -28,6 +28,7 @@ export const integrationSecretIcons = {
opnsenseApiSecret: IconPassword,
patchmonApiKey: IconKey,
patchmonApiSecret: IconPassword,
siteManagerApiKey: IconKey,
githubAppId: IconCode,
githubInstallationId: IconPlug,
privateKey: IconKey,
@@ -57,7 +57,10 @@ export const SearchEngineForm = (props: SearchEngineFormProps) => {
/>
</Grid.Col>
</Grid>
<IconPicker {...form.getInputProps("iconUrl")} />
<IconPicker
{...form.getInputProps("iconUrl")}
suggestedSearch={initialValues === undefined ? form.values.name : undefined}
/>
<Fieldset legend={t("search.engine.page.edit.configControl")}>
<SegmentedControl
@@ -1,53 +1,22 @@
"use client";
import React from "react";
import { Card, LoadingOverlay, Stack, Title } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface AnalyticsSettingsProps {
initialData: typeof defaultServerSettings.analytics;
form: UseFormReturnType<FormValues>;
}
export const AnalyticsSettings = ({ initialData }: AnalyticsSettingsProps) => {
export const AnalyticsSettings = ({ form }: AnalyticsSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.analytics");
const { instanceId: _, ...formDefaults } = initialData;
const form = useForm({
initialValues: formDefaults,
onValuesChange: (updatedValues, _prev) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "analytics",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative">
<LoadingOverlay visible={isPending} />
<Stack>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</Stack>
</Card>
</>
<SectionCard title={t("title")}>
<SwitchSetting form={form} formKey="enableGeneral" title={t("general.title")} text={t("general.text")} />
</SectionCard>
);
};
@@ -5,31 +5,32 @@ import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react";
import type { ColorScheme } from "@homarr/definitions";
import { colorSchemes } from "@homarr/definitions";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SelectWithCustomItems } from "@homarr/ui";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const AppearanceSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["appearance"] }) => {
const tApperance = useScopedI18n("management.page.settings.section.appearance");
interface AppearanceSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const AppearanceSettingsForm = ({ form }: AppearanceSettingsFormProps) => {
const tAppearance = useScopedI18n("management.page.settings.section.appearance");
return (
<CommonSettingsForm settingKey="appearance" defaultValues={defaultValues}>
{(form) => (
<>
<SelectWithCustomItems
label={tApperance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tApperance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={ApperanceCustomOption}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tAppearance("title")}>
<SelectWithCustomItems
label={tAppearance("defaultColorScheme.label")}
data={colorSchemes.map((scheme) => ({
value: scheme,
label: tAppearance(`defaultColorScheme.options.${scheme}`),
}))}
{...form.getInputProps("defaultColorScheme")}
SelectOption={AppearanceCustomOption}
/>
</SectionCard>
);
};
@@ -39,7 +40,7 @@ const appearanceIcons = {
dark: IconMoon,
};
const ApperanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const AppearanceCustomOption = ({ value, label }: { value: ColorScheme; label: string }) => {
const Icon = appearanceIcons[value];
return (
@@ -3,49 +3,50 @@
import { Switch, Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { BoardSelect } from "~/components/board/board-select";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const BoardSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["board"] }) => {
interface BoardSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const BoardSettingsForm = ({ form }: BoardSettingsFormProps) => {
const tBoard = useScopedI18n("management.page.settings.section.board");
const [selectableBoards] = clientApi.board.getPublicBoards.useSuspenseQuery();
return (
<CommonSettingsForm settingKey="board" defaultValues={defaultValues}>
{(form) => (
<>
<BoardSelect
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
/>
<SectionCard title={tBoard("title")}>
<BoardSelect
label={tBoard("homeBoard.label")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("homeBoardId")}
/>
<BoardSelect
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<BoardSelect
label={tBoard("homeBoard.mobileLabel")}
description={tBoard("homeBoard.description")}
clearable
boards={selectableBoards}
{...form.getInputProps("mobileHomeBoardId")}
/>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</>
)}
</CommonSettingsForm>
<Text fw={500}>{tBoard("status.title")}</Text>
<Switch
{...form.getInputProps("enableStatusByDefault", { type: "checkbox" })}
label={tBoard("status.enableStatusByDefault.label")}
description={tBoard("status.enableStatusByDefault.description")}
/>
<Switch
{...form.getInputProps("forceDisableStatus", { type: "checkbox" })}
label={tBoard("status.forceDisableStatus.label")}
description={tBoard("status.forceDisableStatus.description")}
/>
</SectionCard>
);
};
@@ -1,57 +0,0 @@
"use client";
import { Button, Group, Stack } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { useForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
export const CommonSettingsForm = <TKey extends keyof ServerSettings>({
settingKey,
defaultValues,
children,
}: {
settingKey: TKey;
defaultValues: ServerSettings[TKey];
children: (form: ReturnType<typeof useForm<ServerSettings[TKey]>>) => React.ReactNode;
}) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSuccess() {
showSuccessNotification({
message: tSettings("notification.success.message"),
});
},
onError() {
showErrorNotification({
message: tSettings("notification.error.message"),
});
},
});
const form = useForm({
initialValues: defaultValues,
});
const handleSubmitAsync = async (values: ServerSettings[TKey]) => {
await mutateAsync({
settingsKey: settingKey,
value: values,
});
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="sm">
{children(form)}
<Group justify="end">
<Button type="submit" loading={isPending}>
{t("common.action.save")}
</Button>
</Group>
</Stack>
</form>
);
};
@@ -1,68 +1,35 @@
"use client";
import React from "react";
import { Card, LoadingOverlay, Stack, Text, Title } from "@mantine/core";
import { Text } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { useForm } from "@homarr/form";
import type { defaultServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { SwitchSetting } from "~/app/[locale]/manage/settings/_components/setting-switch";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
import { SwitchSetting } from "./setting-switch";
interface CrawlingAndIndexingSettingsProps {
initialData: typeof defaultServerSettings.crawlingAndIndexing;
form: UseFormReturnType<FormValues>;
}
export const CrawlingAndIndexingSettings = ({ initialData }: CrawlingAndIndexingSettingsProps) => {
export const CrawlingAndIndexingSettings = ({ form }: CrawlingAndIndexingSettingsProps) => {
const t = useScopedI18n("management.page.settings.section.crawlingAndIndexing");
const form = useForm({
initialValues: initialData,
onValuesChange: (updatedValues, _) => {
if (!form.isValid()) {
return;
}
void mutateAsync({
settingsKey: "crawlingAndIndexing",
value: updatedValues,
});
},
});
const { mutateAsync, isPending } = clientApi.serverSettings.saveSettings.useMutation({
onSettled: async () => {
await revalidatePathActionAsync("/manage/settings");
},
});
return (
<>
<Title order={2}>{t("title")}</Title>
<Card pos="relative">
<Text c={"dimmed"} mb={"lg"}>
{t("warning")}
</Text>
<LoadingOverlay visible={isPending} />
<Stack>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
<SwitchSetting
form={form}
formKey="noTranslate"
title={t("noTranslate.title")}
text={t("noTranslate.text")}
/>
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</Stack>
</Card>
</>
<SectionCard title={t("title")}>
<Text c="dimmed" size="sm">
{t("warning")}
</Text>
<SwitchSetting form={form} formKey="noIndex" title={t("noIndex.title")} text={t("noIndex.text")} />
<SwitchSetting form={form} formKey="noFollow" title={t("noFollow.title")} text={t("noFollow.text")} />
<SwitchSetting form={form} formKey="noTranslate" title={t("noTranslate.title")} text={t("noTranslate.text")} />
<SwitchSetting
form={form}
formKey="noSiteLinksSearchBox"
title={t("noSiteLinksSearchBox.title")}
text={t("noSiteLinksSearchBox.text")}
/>
</SectionCard>
);
};
@@ -1,26 +1,28 @@
"use client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import type { SupportedLanguage } from "@homarr/translation";
import { useScopedI18n } from "@homarr/translation/client";
import { LanguageCombobox } from "~/components/language/language-combobox";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const CultureSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["culture"] }) => {
interface CultureSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const CultureSettingsForm = ({ form }: CultureSettingsFormProps) => {
const tCulture = useScopedI18n("management.page.settings.section.culture");
const localeInputProps = form.getInputProps("defaultLocale");
return (
<CommonSettingsForm settingKey="culture" defaultValues={defaultValues}>
{(form) => (
<>
<LanguageCombobox
label={tCulture("defaultLocale.label")}
value={form.getInputProps("defaultLocale").value as SupportedLanguage}
{...form.getInputProps("defaultLocale")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tCulture("title")}>
<LanguageCombobox
label={tCulture("defaultLocale.label")}
{...localeInputProps}
value={localeInputProps.value as SupportedLanguage}
/>
</SectionCard>
);
};
@@ -3,27 +3,28 @@
import { Select } from "@mantine/core";
import { clientApi } from "@homarr/api/client";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const SearchSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["search"] }) => {
interface SearchSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const SearchSettingsForm = ({ form }: SearchSettingsFormProps) => {
const tSearch = useScopedI18n("management.page.settings.section.search");
const [selectableSearchEngines] = clientApi.searchEngine.getSelectable.useSuspenseQuery({ withIntegrations: false });
return (
<CommonSettingsForm settingKey="search" defaultValues={defaultValues}>
{(form) => (
<>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tSearch("title")}>
<Select
label={tSearch("defaultSearchEngine.label")}
description={tSearch("defaultSearchEngine.description")}
data={selectableSearchEngines}
{...form.getInputProps("defaultSearchEngineId")}
/>
</SectionCard>
);
};
@@ -3,10 +3,13 @@ import React from "react";
import type { MantineSpacing } from "@mantine/core";
import { Group, Stack, Switch, Text, UnstyledButton } from "@mantine/core";
import type { Modify } from "@homarr/common/types";
import type { UseFormReturnType } from "@homarr/form";
export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
type BooleanKeys<TFormValue> = {
[TKey in keyof TFormValue]: TFormValue[TKey] extends boolean ? TKey : never;
}[keyof TFormValue];
export const SwitchSetting = <TFormValue extends Record<string, unknown>>({
form,
ms,
title,
@@ -14,13 +17,8 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
formKey,
disabled,
}: {
form: Modify<
UseFormReturnType<TFormValue, TFormValue>,
{
setFieldValue: (key: keyof TFormValue, value: (previous: boolean) => boolean) => void;
}
>;
formKey: keyof TFormValue;
form: UseFormReturnType<TFormValue, TFormValue>;
formKey: string & BooleanKeys<TFormValue>;
ms?: MantineSpacing;
title: string;
text: ReactNode;
@@ -31,7 +29,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
return;
}
form.setFieldValue(formKey, (previous) => !previous);
form.setFieldValue(formKey, (previous) => !previous as never);
}, [form, formKey, disabled]);
return (
@@ -44,7 +42,7 @@ export const SwitchSetting = <TFormValue extends Record<string, boolean>>({
</Text>
</Stack>
</UnstyledButton>
<Switch disabled={disabled} onClick={handleClick} checked={form.values[formKey] && !disabled} />
<Switch disabled={disabled} onClick={handleClick} checked={Boolean(form.values[formKey])} />
</Group>
);
};
@@ -0,0 +1,193 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Button, Stack } from "@mantine/core";
import { z } from "zod/v4";
import { clientApi } from "@homarr/api/client";
import { revalidatePathActionAsync } from "@homarr/common/client";
import { env } from "@homarr/common/env";
import { colorSchemes } from "@homarr/definitions";
import { useZodForm } from "@homarr/form";
import { showErrorNotification, showSuccessNotification } from "@homarr/notifications";
import type { ServerSettings, defaultServerSettingsKeys } from "@homarr/server-settings";
import { useI18n, useScopedI18n } from "@homarr/translation/client";
import { UnsavedChangesBar } from "~/components/manage/unsaved-changes-bar";
import { AnalyticsSettings } from "./analytics.settings";
import { AppearanceSettingsForm } from "./appearance-settings-form";
import { BoardSettingsForm } from "./board-settings-form";
import { CrawlingAndIndexingSettings } from "./crawling-and-indexing.settings";
import { CultureSettingsForm } from "./culture-settings-form";
import { SearchSettingsForm } from "./search-settings-form";
import { UserSettingsForm } from "./user-settings-form";
const settingsFormSchema = z.object({
enableGeneral: z.boolean(),
noIndex: z.boolean(),
noFollow: z.boolean(),
noTranslate: z.boolean(),
noSiteLinksSearchBox: z.boolean(),
homeBoardId: z.string().nullable(),
mobileHomeBoardId: z.string().nullable(),
enableStatusByDefault: z.boolean(),
forceDisableStatus: z.boolean(),
enableGravatar: z.boolean(),
defaultSearchEngineId: z.string().nullable(),
defaultColorScheme: z.enum(colorSchemes),
defaultLocale: z.string(),
});
export type FormValues = z.infer<typeof settingsFormSchema>;
const buildInitialValues = (initialData: ServerSettings): FormValues => ({
enableGeneral: initialData.analytics.enableGeneral,
noIndex: initialData.crawlingAndIndexing.noIndex,
noFollow: initialData.crawlingAndIndexing.noFollow,
noTranslate: initialData.crawlingAndIndexing.noTranslate,
noSiteLinksSearchBox: initialData.crawlingAndIndexing.noSiteLinksSearchBox,
homeBoardId: initialData.board.homeBoardId,
mobileHomeBoardId: initialData.board.mobileHomeBoardId,
enableStatusByDefault: initialData.board.enableStatusByDefault,
forceDisableStatus: initialData.board.forceDisableStatus,
enableGravatar: initialData.user.enableGravatar,
defaultSearchEngineId: initialData.search.defaultSearchEngineId,
defaultColorScheme: initialData.appearance.defaultColorScheme,
defaultLocale: initialData.culture.defaultLocale,
});
interface SettingsFormProps {
initialData: ServerSettings;
}
export const SettingsForm = ({ initialData }: SettingsFormProps) => {
const t = useI18n();
const tSettings = useScopedI18n("management.page.settings");
const initialValues = buildInitialValues(initialData);
const initialValuesRef = useRef(initialValues);
const form = useZodForm(settingsFormSchema, {
initialValues,
});
const isDirtyRef = useRef(false);
isDirtyRef.current = form.isDirty();
const [isSubmitting, setIsSubmitting] = useState(false);
const saveSettingsMutation = clientApi.serverSettings.saveSettings.useMutation({
onError(error) {
showErrorNotification({ title: t("common.notification.update.error"), message: error.message });
},
});
useEffect(() => {
const handler = (event: BeforeUnloadEvent) => {
if (env.NODE_ENV === "development") return;
if (isDirtyRef.current) event.preventDefault();
};
window.addEventListener("beforeunload", handler);
return () => window.removeEventListener("beforeunload", handler);
}, []);
const handleSubmitAsync = async (values: FormValues) => {
const defaults = initialValuesRef.current;
const changed = <TKey extends keyof FormValues>(...fields: TKey[]) =>
fields.some((field) => values[field] !== defaults[field]);
const groups: {
settingsKey: (typeof defaultServerSettingsKeys)[number];
when: boolean;
value: Record<string, unknown>;
}[] = [
{ settingsKey: "analytics", when: changed("enableGeneral"), value: { enableGeneral: values.enableGeneral } },
{
settingsKey: "crawlingAndIndexing",
when: changed("noIndex", "noFollow", "noTranslate", "noSiteLinksSearchBox"),
value: {
noIndex: values.noIndex,
noFollow: values.noFollow,
noTranslate: values.noTranslate,
noSiteLinksSearchBox: values.noSiteLinksSearchBox,
},
},
{
settingsKey: "board",
when: changed("homeBoardId", "mobileHomeBoardId", "enableStatusByDefault", "forceDisableStatus"),
value: {
homeBoardId: values.homeBoardId,
mobileHomeBoardId: values.mobileHomeBoardId,
enableStatusByDefault: values.enableStatusByDefault,
forceDisableStatus: values.forceDisableStatus,
},
},
{ settingsKey: "user", when: changed("enableGravatar"), value: { enableGravatar: values.enableGravatar } },
{
settingsKey: "search",
when: changed("defaultSearchEngineId"),
value: { defaultSearchEngineId: values.defaultSearchEngineId },
},
{
settingsKey: "appearance",
when: changed("defaultColorScheme"),
value: { defaultColorScheme: values.defaultColorScheme },
},
{ settingsKey: "culture", when: changed("defaultLocale"), value: { defaultLocale: values.defaultLocale } },
];
const promises = groups
.filter((group) => group.when)
.map((group) => saveSettingsMutation.mutateAsync({ settingsKey: group.settingsKey, value: group.value }));
if (promises.length === 0) return;
setIsSubmitting(true);
try {
const results = await Promise.allSettled(promises);
if (!results.every((result) => result.status === "fulfilled")) {
return;
}
initialValuesRef.current = values;
form.setInitialValues(values);
form.resetDirty();
await revalidatePathActionAsync("/manage/settings");
showSuccessNotification({
title: t("common.notification.update.success"),
message: tSettings("notification.success.message"),
});
} finally {
setIsSubmitting(false);
}
};
const handleDiscard = () => {
form.setValues(initialValuesRef.current);
form.resetDirty();
};
return (
<form onSubmit={form.onSubmit((values) => void handleSubmitAsync(values))}>
<Stack gap="xl">
<AnalyticsSettings form={form} />
<CrawlingAndIndexingSettings form={form} />
<BoardSettingsForm form={form} />
<UserSettingsForm form={form} />
<SearchSettingsForm form={form} />
<AppearanceSettingsForm form={form} />
<CultureSettingsForm form={form} />
{form.isDirty() && (
<UnsavedChangesBar>
<Button type="button" disabled={isSubmitting} variant="default" onClick={handleDiscard}>
{t("common.action.discard")}
</Button>
<Button loading={isSubmitting} type="submit" disabled={!form.isValid()}>
{t("common.action.saveChanges")}
</Button>
</UnsavedChangesBar>
)}
</Stack>
</form>
);
};
@@ -2,25 +2,26 @@
import { Switch } from "@mantine/core";
import type { ServerSettings } from "@homarr/server-settings";
import type { UseFormReturnType } from "@homarr/form";
import { useScopedI18n } from "@homarr/translation/client";
import { CommonSettingsForm } from "./common-form";
import { SectionCard } from "~/components/manage/section-card";
import type { FormValues } from "./settings-form";
export const UserSettingsForm = ({ defaultValues }: { defaultValues: ServerSettings["user"] }) => {
interface UserSettingsFormProps {
form: UseFormReturnType<FormValues>;
}
export const UserSettingsForm = ({ form }: UserSettingsFormProps) => {
const tUser = useScopedI18n("management.page.settings.section.user");
return (
<CommonSettingsForm settingKey="user" defaultValues={defaultValues}>
{(form) => (
<>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</>
)}
</CommonSettingsForm>
<SectionCard title={tUser("title")}>
<Switch
{...form.getInputProps("enableGravatar", { type: "checkbox" })}
label={tUser("enableGravatar.label")}
description={tUser("enableGravatar.description")}
/>
</SectionCard>
);
};
@@ -0,0 +1,9 @@
import { Center, Loader } from "@mantine/core";
export default function SettingsLoading() {
return (
<Center h="40vh">
<Loader />
</Center>
);
}
@@ -5,14 +5,8 @@ import { api } from "@homarr/api/server";
import { auth } from "@homarr/auth/next";
import { getScopedI18n } from "@homarr/translation/server";
import { CrawlingAndIndexingSettings } from "~/app/[locale]/manage/settings/_components/crawling-and-indexing.settings";
import { DynamicBreadcrumb } from "~/components/navigation/dynamic-breadcrumb";
import { AnalyticsSettings } from "./_components/analytics.settings";
import { AppearanceSettingsForm } from "./_components/appearance-settings-form";
import { BoardSettingsForm } from "./_components/board-settings-form";
import { CultureSettingsForm } from "./_components/culture-settings-form";
import { SearchSettingsForm } from "./_components/search-settings-form";
import { UserSettingsForm } from "./_components/user-settings-form";
import { SettingsForm } from "./_components/settings-form";
export async function generateMetadata() {
const t = await getScopedI18n("management");
@@ -32,33 +26,13 @@ export default async function SettingsPage() {
const serverSettings = await api.serverSettings.getAll();
const tSettings = await getScopedI18n("management.page.settings");
return (
<>
<DynamicBreadcrumb />
<Stack>
<Title order={1}>{tSettings("title")}</Title>
<AnalyticsSettings initialData={serverSettings.analytics} />
<CrawlingAndIndexingSettings initialData={serverSettings.crawlingAndIndexing} />
<Stack>
<Title order={2}>{tSettings("section.board.title")}</Title>
<BoardSettingsForm defaultValues={serverSettings.board} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.user.title")}</Title>
<UserSettingsForm defaultValues={serverSettings.user} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.search.title")}</Title>
<SearchSettingsForm defaultValues={serverSettings.search} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.appearance.title")}</Title>
<AppearanceSettingsForm defaultValues={serverSettings.appearance} />
</Stack>
<Stack>
<Title order={2}>{tSettings("section.culture.title")}</Title>
<CultureSettingsForm defaultValues={serverSettings.culture} />
</Stack>
<SettingsForm initialData={serverSettings} />
</Stack>
</>
);
@@ -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";
}
@@ -44,9 +44,13 @@ interface WidgetContextMenuProps {
export const WidgetContextMenu = ({ item, definition, widgetStateRef, children }: WidgetContextMenuProps) => {
const { data: session } = useSession();
const [isEditMode] = useEditMode();
const settings = useSettings();
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 (
<WidgetContextMenuInner item={item} definition={definition} widgetStateRef={widgetStateRef} settings={settings}>
@@ -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 {
@@ -1,38 +1,17 @@
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Suspense, use, useEffect, useState } from "react";
import { Box, Loader, UnstyledButton, useMantineColorScheme } from "@mantine/core";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { UnstyledButton, useMantineColorScheme } from "@mantine/core";
import { useHotkeys } from "@mantine/hooks";
import { ErrorBoundary } from "react-error-boundary";
import { clientApi } from "@homarr/api/client";
import { useSession } from "@homarr/auth/client";
import { hotkeys } from "@homarr/definitions";
import { UserAvatarMenu } from "~/components/user-avatar-menu";
import { UpdateIndicator } from "./update";
type UserAvatarMenuModule = typeof import("~/components/user-avatar-menu");
type UserAvatarMenuProps = ComponentProps<UserAvatarMenuModule["UserAvatarMenu"]>;
let userAvatarMenuPromise: Promise<UserAvatarMenuModule> | undefined;
const loadUserAvatarMenu = () => {
if (userAvatarMenuPromise) return userAvatarMenuPromise;
const promise = import("~/components/user-avatar-menu");
userAvatarMenuPromise = promise;
void promise.catch(() => {
if (userAvatarMenuPromise === promise) userAvatarMenuPromise = undefined;
});
return promise;
};
const preloadUserAvatarMenu = () => void loadUserAvatarMenu().catch(() => undefined);
const LazyUserAvatarMenu = (props: UserAvatarMenuProps) => {
const { UserAvatarMenu } = use(loadUserAvatarMenu());
return <UserAvatarMenu {...props} />;
};
interface UserButtonClientProps {
avatar: ReactNode;
isAdmin: boolean;
@@ -41,8 +20,6 @@ interface UserButtonClientProps {
export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButtonClientProps) => {
const [canCheckForUpdates, setCanCheckForUpdates] = useState(false);
const [isMenuMounted, setIsMenuMounted] = useState(false);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const session = useSession();
const { toggleColorScheme } = useMantineColorScheme();
useHotkeys([[hotkeys.toggleColorScheme, toggleColorScheme]]);
@@ -66,60 +43,14 @@ export const UserButtonClient = ({ avatar, isAdmin, isDockerEnabled }: UserButto
staleTime: 60 * 60 * 1_000,
});
const visibleUpdates = isCurrentSessionAdmin ? availableUpdates : undefined;
const openMenu = () => {
setIsMenuMounted(true);
setIsMenuOpen(true);
};
const renderButton = (onClick: () => void, isLoading = false) => (
<UnstyledButton
onClick={onClick}
onFocus={preloadUserAvatarMenu}
onMouseEnter={preloadUserAvatarMenu}
onPointerDown={preloadUserAvatarMenu}
aria-busy={isLoading || undefined}
disabled={isLoading}
>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
<Box pos="relative" style={{ display: "inline-flex" }}>
<Box opacity={isLoading ? 0.35 : 1}>{avatar}</Box>
{isLoading && (
<Loader
aria-label="Loading user menu"
size={18}
pos="absolute"
top="50%"
left="50%"
style={{ transform: "translate(-50%, -50%)" }}
/>
)}
</Box>
</UpdateIndicator>
</UnstyledButton>
);
const button = renderButton(openMenu);
const loadingButton = renderButton(() => undefined, true);
if (!isMenuMounted) return button;
return (
<ErrorBoundary
fallbackRender={({ resetErrorBoundary }) =>
renderButton(() => {
resetErrorBoundary();
openMenu();
})
}
>
<Suspense fallback={loadingButton}>
<LazyUserAvatarMenu
availableUpdates={visibleUpdates}
isDockerEnabled={isDockerEnabled}
opened={isMenuOpen}
onOpenChange={setIsMenuOpen}
>
{button}
</LazyUserAvatarMenu>
</Suspense>
</ErrorBoundary>
<UserAvatarMenu availableUpdates={visibleUpdates} isDockerEnabled={isDockerEnabled}>
<UnstyledButton>
<UpdateIndicator availableUpdates={visibleUpdates} disabled={!isCurrentSessionAdmin}>
{avatar}
</UpdateIndicator>
</UnstyledButton>
</UserAvatarMenu>
);
};
@@ -1,19 +1,21 @@
"use client";
import { Fragment } from "react";
import { Card, CardSection, Divider, Group, Stack, Text, Title } from "@mantine/core";
import { getI18n } from "@homarr/translation/server";
import { useScopedI18n } from "@homarr/translation/client";
interface DangerZoneRootProps {
children: React.ReactNode[] | React.ReactNode;
}
export const DangerZoneRoot = async ({ children }: DangerZoneRootProps) => {
const t = await getI18n();
export const DangerZoneRoot = ({ children }: DangerZoneRootProps) => {
const t = useScopedI18n("common");
return (
<Stack gap="sm">
<Title c="red.8" order={2}>
{t("common.dangerZone")}
{t("dangerZone")}
</Title>
<Card style={{ borderColor: "var(--mantine-color-red-8)", borderWidth: 3 }}>
<Stack gap="sm">
@@ -0,0 +1,19 @@
import type { ReactNode } from "react";
import { Card, Divider, Stack, Title } from "@mantine/core";
interface SectionCardProps {
title: ReactNode;
children: ReactNode;
}
export const SectionCard = ({ title, children }: SectionCardProps) => {
return (
<Card withBorder bg="transparent">
<Stack gap="md">
<Title order={3}>{title}</Title>
<Divider />
{children}
</Stack>
</Card>
);
};
@@ -0,0 +1,25 @@
"use client";
import type { ReactNode } from "react";
import { Card, Group, Text } from "@mantine/core";
import { useI18n } from "@homarr/translation/client";
interface UnsavedChangesBarProps {
children: ReactNode;
}
export const UnsavedChangesBar = ({ children }: UnsavedChangesBarProps) => {
const t = useI18n();
return (
<div style={{ position: "sticky", bottom: 20 }}>
<Card withBorder>
<Group justify="space-between" wrap="wrap">
<Text fw={500}>{t("common.unsavedChanges")}</Text>
<Group>{children}</Group>
</Group>
</Card>
</div>
);
};
@@ -21,17 +21,9 @@ interface UserAvatarMenuProps {
children: ReactNode;
availableUpdates?: RouterOutputs["updateChecker"]["getAvailableUpdates"];
isDockerEnabled?: boolean;
opened: boolean;
onOpenChange: (opened: boolean) => void;
}
export const UserAvatarMenu = ({
children,
availableUpdates,
isDockerEnabled,
opened,
onOpenChange,
}: UserAvatarMenuProps) => {
export const UserAvatarMenu = ({ children, availableUpdates, isDockerEnabled }: UserAvatarMenuProps) => {
const t = useScopedI18n("common.userAvatar.menu");
const session = useSession();
@@ -48,7 +40,7 @@ export const UserAvatarMenu = ({
return (
// We use keepMounted so we can add event listeners to prevent navigating away without saving the board
<Menu width={300} withinPortal keepMounted opened={opened} onChange={onOpenChange}>
<Menu width={300} withinPortal keepMounted>
<Menu.Dropdown>
<AvailableUpdatesMenuItem availableUpdates={availableUpdates} />
<Menu.Item component={Link} href="/boards" leftSection={<IconHome size="1rem" />}>
+7
View File
@@ -429,6 +429,13 @@
<br/>
<p align="center">Uptime<br/>Kuma</p>
</a>
</td>
<td align="center">
<a href="https://homarr.dev/docs/integrations/whats-up-docker" target="_blank" rel="noreferrer noopener">
<img src="https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg" alt="What's Up Docker" width="90" height="90" />
<br/>
<p align="center">What's<br/>Up<br/>Docker</p>
</a>
</td></tr>
</tbody>
</table>
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 |
+4 -1
View File
@@ -45,7 +45,10 @@ describe("Custom JSX custom widgets", () => {
await page.goto(`${baseUrl}/auth/login`);
await page.getByLabel("Username").fill(adminCredentials.username);
await page.locator("#password").fill(adminCredentials.password);
const loginNavigation = page.waitForURL(baseUrl, { timeout: 15_000, waitUntil: "commit" });
const loginNavigation = page.waitForURL((url) => url.origin === baseUrl && url.pathname !== "/auth/login", {
timeout: 60_000,
waitUntil: "commit",
});
await page.locator("css=button[type='submit']").click();
await loginNavigation;
-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();
+3 -3
View File
@@ -5,11 +5,11 @@ events {
http {
server {
listen 7575;
listen [::]:7575;
${NGINX_LISTEN_IPV6}
# 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;
+140
View File
@@ -0,0 +1,140 @@
# 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é.
## Déplacer l'état hors de /appdata
`/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
machine, il ne suit aucune convention : l'état d'un service se range sous
`/var/lib`. La correction se fait sur l'**instance installée**, jamais dans le
dépôt — ni le code ni le Dockerfile ne sont touchés, sans quoi chaque rebase sur
l'amont rouvrirait le sujet.
```bash
sudo ops/use-var-lib-homarr.sh /opt/homarr # ou /opt/homarr-dev
```
Le script réécrit `/appdata` en `/var/lib/homarr` dans le `run.sh` et le
`redis.conf` de l'instance, sauvegarde chaque fichier avant de le toucher, et
ajoute `LOCAL_CERTIFICATE_PATH` au démarrage. Cette dernière ligne n'est pas
cosmétique : le chemin des certificats n'est pas lu par `run.sh` mais par
l'application, qui retombe sur le dossier de l'image dès qu'il manque — elle
recréerait donc `/appdata` au premier certificat chargé.
Le script ne déplace **pas** les données ; il imprime les commandes à passer une
fois, service arrêté. Sur cette installation, `/appdata` ne contient qu'un
fichier : le certificat racine qui valide la console UniFi locale. Le contrôle
d'après-déplacement est donc tout trouvé — si le widget UniFi OS répond encore,
le certificat a suivi.
À relancer après chaque assemblage : `ops/assemble-standalone.sh` recopie le
`run.sh` du dépôt quand l'instance n'en a pas, et celui-là parle de `/appdata`.
## 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.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
#
# Assemble l'arborescence d'exécution à partir d'un dépôt déjà construit.
#
# Le dépôt et l'instance qui tourne n'ont PAS la même forme : le clone contient
# des sources et un `apps/nextjs/.next/standalone` ; l'instance attend
# `apps/nextjs/server.js`, `db/migrations/`, `build/better_sqlite3.node` à sa
# racine. Ce script fait la conversion — les mêmes COPY que le stage `runner`
# du Dockerfile officiel, d'où vient la disposition de /opt/homarr.
#
# Usage : ops/assemble-standalone.sh <destination>
#
# Construire d'abord :
# pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli
#
set -euo pipefail
DEST="${1:?usage: ops/assemble-standalone.sh <destination>}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO"
[ -d apps/nextjs/.next/standalone ] || {
echo "apps/nextjs/.next/standalone absent — construis d'abord :" >&2
echo " pnpm turbo build --filter=@homarr/nextjs... --filter=@homarr/cli" >&2
exit 1
}
mkdir -p "$DEST"
# L'ordre suit le Dockerfile : le standalone d'abord, le reste par-dessus.
cp -a apps/nextjs/.next/standalone/. "$DEST/"
mkdir -p "$DEST/apps/nextjs/.next"
cp -a apps/nextjs/.next/static "$DEST/apps/nextjs/.next/static"
cp -a apps/nextjs/public "$DEST/apps/nextjs/public"
cp -a apps/nextjs/next.config.ts apps/nextjs/package.json pnpm-workspace.yaml "$DEST/"
mkdir -p "$DEST/apps/cli"
cp -a packages/cli/cli.cjs "$DEST/apps/cli/cli.cjs"
# Binaire natif : lié à l'architecture et à l'ABI Node de la machine qui a
# construit. Assembler et exécuter sur la même machine rend la question sans
# objet ; c'est le déploiement à distance qui doit la poser.
mkdir -p "$DEST/build"
cp -a node_modules/better-sqlite3/build/Release/better_sqlite3.node "$DEST/build/"
mkdir -p "$DEST/db"
rm -rf "$DEST/db/migrations"
cp -a packages/db/migrations "$DEST/db/migrations"
# run.sh n'est copié que s'il manque, jamais écrasé. Sur le LXC, l'installateur
# a adapté le sien — il annonce le port 80 là où celui du dépôt annonce 7575 —
# et l'écraser ferait écouter nginx sur deux ports différents selon la famille
# d'adresses. Même raison pour entrypoint.sh, nginx.conf et redis.conf, qui
# appartiennent à l'installation et ne sont pas copiés du tout.
if [ -f "$DEST/run.sh" ]; then
echo "run.sh déjà présent — laissé tel quel (il peut être adapté à cette installation)."
if ! diff -q scripts/run.sh "$DEST/run.sh" > /dev/null; then
echo " il diffère de scripts/run.sh : \`diff scripts/run.sh $DEST/run.sh\` pour voir quoi."
fi
else
cp -a scripts/run.sh "$DEST/run.sh"
chmod +x "$DEST/run.sh"
fi
echo "Assemblé dans $DEST ($(du -sh "$DEST" | cut -f1))"
+163
View File
@@ -0,0 +1,163 @@
#!/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
# Même assemblage que pour une installation construite sur place : un seul
# endroit décrit la disposition d'exécution, et les deux chemins ne peuvent pas
# diverger. `run.sh` en fait partie, mais il est exclu du rsync plus bas —
# celui de l'installation est adapté à son port.
"$REPO/ops/assemble-standalone.sh" "$STAGE"
# ---------------------------------------------------------------------------
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
+87
View File
@@ -0,0 +1,87 @@
#!/usr/bin/env bash
#
# Fait pointer une instance Homarr installée sur /var/lib/homarr au lieu de
# /appdata.
#
# `/appdata` vient de l'image Docker, où c'est un point de montage. Sur une
# machine, il ne suit aucune convention : l'état d'un service se range sous
# /var/lib. Ce script agit sur l'INSTANCE INSTALLÉE — son `run.sh`, son
# `redis.conf` — et jamais sur le dépôt : ni le code source ni le Dockerfile ne
# sont touchés, pour que le fork reste rebasable sur l'amont.
#
# Idempotent : le relancer ne fait rien de plus.
#
# Usage : ops/use-var-lib-homarr.sh <répertoire de l'instance>
# ex. : sudo ops/use-var-lib-homarr.sh /opt/homarr
# sudo ops/use-var-lib-homarr.sh /opt/homarr-dev
#
set -euo pipefail
DIR="${1:?usage: ops/use-var-lib-homarr.sh <dossier-instance>}"
STATE="${HOMARR_STATE_DIR:-/var/lib/homarr}"
stamp="$(date +%F-%H%M%S)"
[ -d "$DIR" ] || { echo "$DIR n'existe pas." >&2; exit 1; }
[ -f "$DIR/run.sh" ] || { echo "$DIR/run.sh introuvable — est-ce bien une instance assemblée ?" >&2; exit 1; }
changed=0
for file in run.sh redis.conf; do
target="$DIR/$file"
[ -f "$target" ] || continue
grep -q '/appdata' "$target" || continue
cp -a "$target" "$target.bak-$stamp"
sed -i "s#/appdata#$STATE#g" "$target"
echo "== $file"
diff -u "$target.bak-$stamp" "$target" | sed -n '4,$p' || true
changed=1
done
# Le chemin des certificats n'est pas lu par run.sh mais par l'application, qui
# retombe sur /appdata/trusted-certificates quand la variable est absente.
# Réécrire les `mkdir` ne suffit donc pas : sans cette ligne, Homarr créerait à
# nouveau /appdata au premier chargement de certificat.
if ! grep -q 'LOCAL_CERTIFICATE_PATH' "$DIR/run.sh"; then
[ -f "$DIR/run.sh.bak-$stamp" ] || cp -a "$DIR/run.sh" "$DIR/run.sh.bak-$stamp"
awk -v value="$STATE/trusted-certificates" '
NR == 1 {
print
print ""
print "# Chemin des certificats de confiance. Sans cette ligne, l'\''application"
print "# retombe sur le dossier de l'\''image et le recrée au premier certificat lu."
print "export LOCAL_CERTIFICATE_PATH=\"${LOCAL_CERTIFICATE_PATH:-" value "}\""
next
}
{ print }
' "$DIR/run.sh" > "$DIR/run.sh.new"
cat "$DIR/run.sh.new" > "$DIR/run.sh" # préserve les droits et le propriétaire
rm -f "$DIR/run.sh.new"
echo "== run.sh : LOCAL_CERTIFICATE_PATH ajouté ($STATE/trusted-certificates)"
changed=1
fi
if [ "$changed" -eq 0 ]; then
echo "Rien à faire : $DIR ne référence déjà plus /appdata."
exit 0
fi
mkdir -p "$STATE/trusted-certificates"
cat <<EOF
Instance $DIR réglée sur $STATE. Sauvegardes : *.bak-$stamp
Il reste les DONNÉES, que ce script ne déplace pas — c'est à faire une fois,
service arrêté, et c'est irréversible sans sauvegarde :
sudo systemctl stop homarr
sudo mv /appdata/trusted-certificates/*.pem $STATE/trusted-certificates/ 2>/dev/null || true
sudo find /appdata -type f # doit ne RIEN afficher avant la suite
sudo rm -rf /appdata
sudo systemctl start homarr
Le contrôle qui compte ensuite : l'intégration UniFi OS doit toujours répondre.
C'est elle qui valide son certificat avec le CA déplacé — si elle fonctionne,
le déplacement a réussi.
EOF
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "homarr",
"version": "1.74.0",
"version": "1.77.1",
"private": true,
"scripts": {
"build": "cross-env CI=true turbo build",
+34 -31
View File
@@ -1,4 +1,4 @@
import { and, like } from "@homarr/db";
import { getImageMatchRank, normalizeImageName } from "@homarr/common";
import { icons } from "@homarr/db/schema";
import { iconsFindSchema } from "@homarr/validation/icons";
@@ -15,38 +15,41 @@ export const iconsRouter = createTRPCRouter({
})
.input(iconsFindSchema)
.query(async ({ ctx, input }) => {
const term = input.searchText?.toLowerCase().trim() ?? "";
const keywords = (input.searchText ?? "").split(" ").filter((keyword) => keyword.length > 0);
let whereCondition = undefined;
if (term.length > 0) {
whereCondition = and(...keywords.map((keyword) => like(icons.name, `%${keyword}%`)));
}
const term = normalizeImageName(input.searchText ?? "");
const repositories = await ctx.db.query.iconRepositories.findMany({
orderBy: (table, { asc, sql }) => [sql`CASE WHEN ${table.slug} = 'local' THEN 0 ELSE 1 END`, asc(table.slug)],
with: {
icons: {
columns: { id: true, name: true, url: true },
orderBy: (table, { asc, sql }) => [
sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`,
asc(table.name),
],
limit: term.length === 0 ? input.limitPerGroup : undefined,
},
},
});
return {
icons: await ctx.db.query.iconRepositories.findMany({
with: {
icons: {
columns: { id: true, name: true, url: true },
where: whereCondition,
orderBy: (table, { asc, sql }) => {
const svgFirst = sql`CASE WHEN ${table.name} LIKE '%.svg' THEN 0 ELSE 1 END`;
const nameAsc = asc(table.name);
if (term.length === 0) {
return [svgFirst, nameAsc];
}
const tier = sql`CASE
WHEN LOWER(${table.name}) = ${term} THEN 0
WHEN LOWER(${table.name}) LIKE ${term + "%"} THEN 1
WHEN LOWER(${table.name}) LIKE ${`%${term}%`} THEN 2
ELSE 3
END`;
return [tier, svgFirst, nameAsc];
},
limit: input.limitPerGroup,
},
},
}),
icons:
term.length === 0
? repositories
: repositories.map((repository) => ({
...repository,
icons: repository.icons
.flatMap((icon) => {
const rank = getImageMatchRank(term, icon.name || icon.url);
return rank === null ? [] : [{ icon, rank }];
})
.toSorted((a, b) =>
a.rank !== b.rank
? a.rank - b.rank
: Number(b.icon.name.toLowerCase().endsWith(".svg")) -
Number(a.icon.name.toLowerCase().endsWith(".svg")) || a.icon.name.localeCompare(b.icon.name),
)
.slice(0, input.limitPerGroup)
.map(({ icon }) => icon),
})),
countIcons: await ctx.db.$count(icons),
};
}),
@@ -0,0 +1,44 @@
import { describe, expect, test } from "vitest";
import { iconRepositories, icons } from "@homarr/db/schema";
import { createDb } from "@homarr/db/test";
import { iconsRouter } from "../icons";
describe("findIcons", () => {
test.each(["homeassistant", "home_assistant", "home assistant.png"])(
"matches normalized search %s before applying the group limit",
async (searchText) => {
const db = createDb();
const caller = iconsRouter.createCaller({ db, deviceType: undefined, session: null });
await db.insert(iconRepositories).values({ id: "repository", slug: "dashboard-icons" });
await db.insert(icons).values([
{
id: "irrelevant",
name: "000-unrelated.svg",
url: "https://example.com/000-unrelated.svg",
checksum: "irrelevant",
iconRepositoryId: "repository",
},
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
checksum: "home-assistant",
iconRepositoryId: "repository",
},
]);
const result = await caller.findIcons({ searchText, limitPerGroup: 1 });
expect(result.icons[0]?.icons).toEqual([
{
id: "home-assistant",
name: "home-assistant.svg",
url: "https://example.com/home-assistant.svg",
},
]);
},
);
});
+3
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)),
@@ -44,4 +46,5 @@ export const widgetRouter = createTRPCRouter({
traefik: lazy(() => import("./traefik").then((mod) => mod.traefikRouter)),
customApi: lazy(() => import("./custom-api").then((mod) => mod.customApiRouter)),
secrets: lazy(() => import("./widget-secrets").then((mod) => mod.widgetSecretsRouter)),
wud: lazy(() => import("./wud").then((mod) => mod.wudRouter)),
});
+16 -1
View File
@@ -1,3 +1,4 @@
import { TRPCError } from "@trpc/server";
import { z } from "zod/v4";
import { getIntegrationKindsByCategory } from "@homarr/definitions";
@@ -41,6 +42,13 @@ export const smartHomeRouter = createTRPCRouter({
const client = await createIntegrationAsync(integration);
const success = await client.triggerToggleAsync(input.entityId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to toggle the entity",
});
}
return success;
}),
executeAutomation: protectedProcedure
@@ -55,6 +63,13 @@ export const smartHomeRouter = createTRPCRouter({
.input(z.object({ automationId: z.string() }))
.mutation(async ({ ctx: { integration }, input }) => {
const client = await createIntegrationAsync(integration);
await client.triggerAutomationAsync(input.automationId);
const success = await client.triggerAutomationAsync(input.automationId);
if (!success) {
throw new TRPCError({
code: "INTERNAL_SERVER_ERROR",
message: "Home Assistant failed to trigger the automation",
});
}
}),
});
@@ -0,0 +1,24 @@
import { unifiOsRequestHandler } from "@homarr/request-handler/unifi-os";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const unifiOsRouter = createTRPCRouter({
summary: publicProcedure.concat(createManyIntegrationMiddleware("query", "unifiOs")).query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = unifiOsRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: {
id: integration.id,
name: integration.name,
kind: integration.kind,
},
summary: data,
updatedAt: timestamp,
};
});
}),
});
@@ -0,0 +1,22 @@
import { unifiSiteManagerRequestHandler } from "@homarr/request-handler/unifi-site-manager";
import { createManyIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const unifiSiteManagerRouter = createTRPCRouter({
overview: publicProcedure
.concat(createManyIntegrationMiddleware("query", "unifiSiteManager"))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const innerHandler = unifiSiteManagerRequestHandler.handler(integration, {});
const { data, timestamp } = await innerHandler.getDataAsync();
return {
integration: { id: integration.id, name: integration.name, kind: integration.kind },
overview: data,
updatedAt: timestamp,
};
});
}),
});
+25
View File
@@ -0,0 +1,25 @@
import { wudStatsRequestHandler } from "@homarr/request-handler/wud";
import { createOneIntegrationMiddleware } from "../../middlewares/integration";
import { createTRPCRouter, publicProcedure } from "../../trpc";
export const wudRouter = createTRPCRouter({
getStats: publicProcedure
.meta({
mcp: {
enabled: true,
description:
"Returns monitored-container counts and available updates from a What's Up Docker integration. REQUIRED: integrationId from integration_all. The caller needs query permission for that integration.",
},
})
.concat(createOneIntegrationMiddleware("query", "wud"))
.query(async ({ ctx }) => {
const handler = wudStatsRequestHandler.handler(ctx.integration, {});
const { data, timestamp } = await handler.getDataAsync();
return {
stats: data,
updatedAt: timestamp,
};
}),
});
+1 -1
View File
@@ -43,7 +43,7 @@ async function importCookiesAsync() {
function getBaseUrl() {
if (typeof window !== "undefined") return window.location.origin;
return `http://${process.env.HOSTNAME ?? "localhost"}:3000`;
return "http://127.0.0.1:3000";
}
export const trpcPath = "/api/trpc";
+31
View File
@@ -28,3 +28,34 @@ export const bestMatch = <TItem extends Record<string, unknown>>(
return best;
}, null);
};
export const normalizeImageName = (value: string) => {
const fileName = value.split(/[?#]/, 1)[0]?.split("/").pop() ?? value;
const withoutExtension = fileName.replace(/\.(?:avif|gif|jpe?g|png|svg|webp)$/i, "");
return withoutExtension
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.replace(/&/g, " and ")
.replace(/[^\p{L}\p{M}\p{N}]+/gu, " ")
.trim()
.toLowerCase();
};
export const getImageMatchRank = (normalizedSearch: string, candidateValue: string): number | null => {
const candidate = normalizeImageName(candidateValue);
if (!candidate) return null;
const compactSearch = normalizedSearch.replaceAll(" ", "");
const compactCandidate = candidate.replaceAll(" ", "");
if (candidate === normalizedSearch) return 0;
if (compactCandidate === compactSearch) return 1;
if (candidate.startsWith(`${normalizedSearch} `)) return 2;
const searchTokens = normalizedSearch.split(" ");
const candidateTokens = candidate.split(" ");
if (searchTokens.every((term) => candidateTokens.some((token) => token === term))) return 3;
if (searchTokens.every((term) => candidateTokens.some((token) => token.startsWith(term)))) return 4;
if (candidate.includes(normalizedSearch)) return 5;
return null;
};
+25 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, test } from "vitest";
import { bestMatch, capitalize } from "../string";
import { bestMatch, capitalize, getImageMatchRank, normalizeImageName } from "../string";
const capitalizeTestCases = [
["hello", "Hello"],
@@ -54,3 +54,27 @@ describe("bestMatch should find the best match in an array of options", () => {
expect(result).toBeNull();
});
});
describe("image name matching", () => {
test.each([
["home-assistant.svg", "home assistant"],
["https://example.com/Home_Assistant.PNG?v=1", "home assistant"],
["jelly.fin.webp", "jelly fin"],
["Caf\u00e9.svg", "cafe"],
["\u6771\u4eac.svg", "\u6771\u4eac"],
["\u0939\u093f\u0928\u094d\u0926\u0940.svg", "\u0939\u093f\u0928\u094d\u0926\u0940"],
])("normalizes %s", (value, expected) => {
expect(normalizeImageName(value)).toBe(expected);
});
test.each([
["homeassistant", "home-assistant.svg", 1],
["home assistant", "home_assistant.png", 0],
["home assistant", "Home Assistant.webp", 0],
["\u6771\u4eac", "\u6771\u4eac.svg", 0],
["\u0939\u093f\u0928\u094d\u0926\u0940", "\u0939\u093f\u0928\u094d\u0926\u0940.png", 0],
["home assistant", "unrelated.svg", null],
])("ranks %s against %s", (search, candidate, expected) => {
expect(getImageMatchRank(normalizeImageName(search), candidate)).toBe(expected);
});
});
@@ -13,7 +13,6 @@ import { UndiciHttpAgent } from "@homarr/core/infrastructure/http";
import type { TrustedCertificateHostname } from "../certificates/hostnames";
import { withTimeoutAsync } from "./timeout";
export const createCustomCheckServerIdentity = (
trustedHostnames: TrustedCertificateHostname[],
): typeof checkServerIdentity => {
@@ -210,6 +210,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/unifi-controller"
| "/docs/integrations/unraid"
| "/docs/integrations/uptime-kuma"
| "/docs/integrations/whats-up-docker"
| "/docs/management/api"
| "/docs/management/apps"
| "/docs/management/backup"
@@ -276,6 +277,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/video"
| "/docs/widgets/vpn"
| "/docs/widgets/weather"
| "/docs/widgets/whats-up-docker"
| ""
| "/sitemap.xml"
| "/docs/integrations/sabnzbd"
@@ -330,6 +332,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/peanut"
| "/docs/integrations/beszel"
| "/docs/integrations/traefik"
| "/docs/integrations/whats-up-docker"
| "/docs/widgets/clock"
| "/docs/widgets/weather"
| "/docs/widgets/app"
@@ -382,4 +385,5 @@ export type HomarrDocumentationPath =
| "/docs/widgets/beszel-alerts"
| "/docs/widgets/beszel-system-stats"
| "/docs/widgets/traefik"
| "/docs/widgets/custom-api";
| "/docs/widgets/custom-api"
| "/docs/widgets/whats-up-docker";
@@ -31,6 +31,8 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
proxmox: "proxmox",
nextcloud: "nextcloud",
unifiController: "unifi-controller",
unifiOs: null,
unifiSiteManager: null,
opnsense: "opnsense",
ntfy: "ntfy",
gotify: "gotify",
@@ -54,5 +56,6 @@ export const integrationDocSlugs: Record<IntegrationKind, string | null> = {
beszel: "beszel",
traefik: "traefik",
archiveTeamWarrior: null,
wud: "whats-up-docker",
mock: null,
};
@@ -23,6 +23,8 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
minecraftServerStatus: "minecraft-server-status",
networkControllerSummary: "network-controller-summary",
networkControllerStatus: "network-controller-status",
unifiOs: null,
unifiSiteManager: null,
rssFeed: "rss-feed",
bookmarks: "bookmarks",
indexerManager: "indexer-manager",
@@ -55,4 +57,5 @@ export const widgetDocSlugs: Record<WidgetKind, string | null> = {
beszelSystemStats: "beszel-system-stats",
traefik: "traefik",
customApi: "custom-api",
wud: "whats-up-docker",
};
+27
View File
@@ -5,6 +5,7 @@ import { createDocumentationLink } from "./docs";
export const integrationSecretKindObject = {
apiKey: { isPublic: false, multiline: false },
siteManagerApiKey: { isPublic: false, multiline: false },
username: { isPublic: true, multiline: false },
password: { isPublic: false, multiline: false },
tokenId: { isPublic: true, multiline: false },
@@ -286,6 +287,22 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/unifi-controller"),
defaultPort: 8443,
},
unifiOs: {
name: "UniFi OS",
secretKinds: [["apiKey"], ["apiKey", "siteManagerApiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["unifiOs"],
documentationUrl: null,
defaultPort: 443,
},
unifiSiteManager: {
name: "UniFi Site Manager",
secretKinds: [["siteManagerApiKey"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/png/unifi.png",
category: ["unifiSiteManager"],
documentationUrl: null,
defaultUrl: "https://api.ui.com",
},
opnsense: {
name: "OPNsense",
secretKinds: [["opnsenseApiKey", "opnsenseApiSecret"]],
@@ -468,6 +485,14 @@ export const integrationDefs = {
category: ["archiving"],
documentationUrl: null,
},
wud: {
name: "What's Up Docker",
secretKinds: [[], ["username", "password"]],
iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/whats-up-docker.svg",
category: ["healthMonitoring"],
documentationUrl: createDocumentationLink("/docs/integrations/whats-up-docker"),
defaultPort: 3000,
},
// This integration only returns mock data, it is used during development (but can also be used in production by directly going to the create page)
mock: {
name: "Mock",
@@ -585,6 +610,8 @@ export const integrationCategories = [
"uptimeMonitoring",
"subtitleManager",
"reverseProxy",
"unifiOs",
"unifiSiteManager",
] as const;
export type IntegrationCategory = (typeof integrationCategories)[number];
@@ -23,8 +23,12 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
mediaMissing: getIntegrationKindsByCategory("mediaOrganizer"),
networkControllerSummary: getIntegrationKindsByCategory("networkController"),
networkControllerStatus: getIntegrationKindsByCategory("networkController"),
unifiOs: ["unifiOs"],
unifiSiteManager: ["unifiSiteManager"],
indexerManager: getIntegrationKindsByCategory("indexerManager"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter((kind) => kind !== "patchmon"),
healthMonitoring: getIntegrationKindsByCategory("healthMonitoring").filter(
(kind) => kind !== "patchmon" && kind !== "wud",
),
firewall: getIntegrationKindsByCategory("firewall"),
notifications: getIntegrationKindsByCategory("notifications"),
mediaReleases: ["mock", "emby", "jellyfin", "plex"],
@@ -50,6 +54,7 @@ export const widgetIntegrationSupport: Partial<Record<WidgetKind, readonly Integ
archiveTeamWarrior: ["archiveTeamWarrior"],
anchorNote: ["anchor"],
traefik: ["traefik"],
wud: ["wud"],
};
export const getWidgetKindsForIntegration = (integrationKind: IntegrationKind): WidgetKind[] => {
+3
View File
@@ -21,6 +21,8 @@ export const widgetKinds = [
"minecraftServerStatus",
"networkControllerSummary",
"networkControllerStatus",
"unifiOs",
"unifiSiteManager",
"rssFeed",
"bookmarks",
"indexerManager",
@@ -53,6 +55,7 @@ export const widgetKinds = [
"beszelSystemStats",
"traefik",
"customApi",
"wud",
] as const;
export type WidgetKind = (typeof widgetKinds)[number];
@@ -1,3 +1,89 @@
[data-combobox-selected="true"] .iconCard {
border-color: var(--mantine-primary-color-6);
.dropdown {
min-width: min(32rem, calc(100vw - 2rem));
}
.options {
max-height: min(28rem, 60vh);
overflow-y: auto;
overscroll-behavior: contain;
}
.iconGrid {
grid-template-columns: repeat(auto-fill, minmax(3.25rem, 1fr));
}
.iconOption {
position: relative;
display: flex;
align-items: center;
justify-content: center;
min-width: 0;
height: 3.25rem;
padding: var(--mantine-spacing-xs);
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-md);
background: var(--mantine-color-body);
transition:
border-color 160ms ease,
background-color 160ms ease,
transform 160ms ease;
}
.iconOption:hover,
.iconOption[data-combobox-selected="true"] {
border-color: var(--mantine-primary-color-6);
background: var(--mantine-primary-color-light);
}
.iconOption:active {
transform: scale(0.96);
}
.formatBadge,
.sectionBadge {
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: var(--mantine-radius-xs);
background: var(--mantine-color-red-9);
color: var(--mantine-color-white);
font-size: 0.625rem;
font-weight: 700;
line-height: 1;
}
.formatBadge {
position: absolute;
top: 0.2rem;
right: 0.2rem;
padding: 0.15rem 0.2rem;
}
.sectionBadge {
padding: 0.2rem 0.3rem;
}
.urlPreview {
background: var(--mantine-color-default-hover);
}
.previewFrame {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3rem;
flex: 0 0 3rem;
}
.previewLoader {
position: absolute;
z-index: 1;
}
@media (prefers-reduced-motion: reduce) {
.iconOption {
transition: none;
}
}
Loaded 100 of 211 files, more files were not shown because too many files have changed in this diff. Show more