From ace02b431167cab0edf4d04658cf1fb0be2dfe56 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Sat, 12 Sep 2026 15:55:48 +0200 Subject: [PATCH] feat(widgets): add UniFi Site Manager overview widget Co-Authored-By: Claude Opus 5 --- packages/translation/src/lang/en.json | 36 ++++ packages/widgets/src/catalog.ts | 2 + packages/widgets/src/index.tsx | 2 + packages/widgets/src/manifest.ts | 2 + packages/widgets/src/refetch-intervals.ts | 1 + .../src/unifi-site-manager/component.tsx | 163 ++++++++++++++++++ .../src/unifi-site-manager/health-bar.tsx | 64 +++++++ .../widgets/src/unifi-site-manager/index.ts | 23 +++ 8 files changed, 293 insertions(+) create mode 100644 packages/widgets/src/unifi-site-manager/component.tsx create mode 100644 packages/widgets/src/unifi-site-manager/health-bar.tsx create mode 100644 packages/widgets/src/unifi-site-manager/index.ts diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index 48a0def08..57eb58a9a 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -4262,6 +4262,42 @@ "internalServerError": "Could not reach the UniFi OS console", "noData": "No data available" } + }, + "unifiSiteManager": { + "name": "UniFi Site Manager", + "description": "Multi-site overview from a UniFi account", + "option": { + "showIsp": { "label": "Show ISP", "description": "Display the ISP name column" }, + "showExternalIp": { "label": "Show public IP", "description": "Display the public IP column" } + }, + "column": { + "site": "Site", + "health": "Health (24h)", + "latency": "Latency", + "isp": "ISP", + "externalIp": "Public IP", + "devices": "Devices", + "updates": "Updates", + "console": "Console", + "sdWan": "SD-WAN" + }, + "sdWan": { + "connected": "Connected", + "disconnected": "Disconnected", + "offline": "Offline" + }, + "health": { + "healthy": "healthy", + "degraded": "degraded", + "down": "down", + "unknown": "not reported" + }, + "latency": "{value} ms · {loss} % loss", + "devicesOnline": "{online} / {total}", + "consoleUpdateAvailable": "Update available", + "consoleUpToDate": "Up to date", + "noSites": "No sites in this account", + "error": { "internalServerError": "Could not reach the UniFi Site Manager API" } } }, "widgetPreview": { diff --git a/packages/widgets/src/catalog.ts b/packages/widgets/src/catalog.ts index c2823e201..3353aa7a7 100644 --- a/packages/widgets/src/catalog.ts +++ b/packages/widgets/src/catalog.ts @@ -13,6 +13,7 @@ import { IconBrandMinecraft, IconBrowser, IconBuildingBank, + IconBuildingBroadcastTower, IconBusStop, IconCalendar, IconChartAreaLine, @@ -77,6 +78,7 @@ export const widgetCatalogIcons: Record = { networkControllerSummary: IconTopologyFull, networkControllerStatus: IconTopologyFull, unifiOs: IconRouter, + unifiSiteManager: IconBuildingBroadcastTower, rssFeed: IconRss, bookmarks: IconBookmark, indexerManager: IconReportSearch, diff --git a/packages/widgets/src/index.tsx b/packages/widgets/src/index.tsx index 6259e1637..04d43443e 100644 --- a/packages/widgets/src/index.tsx +++ b/packages/widgets/src/index.tsx @@ -56,6 +56,7 @@ import * as traefik from "./traefik"; import * as tracearr from "./tracearr"; import * as umami from "./umami"; import * as unifiOs from "./unifi-os"; +import * as unifiSiteManager from "./unifi-site-manager"; import * as ups from "./ups"; import * as vpn from "./vpn"; import * as beszelSystemTable from "./beszel-system-table"; @@ -133,6 +134,7 @@ export const widgetImports = { customApi, wud, unifiOs, + unifiSiteManager, } satisfies WidgetImportRecord; export type WidgetImports = typeof widgetImports; diff --git a/packages/widgets/src/manifest.ts b/packages/widgets/src/manifest.ts index bbc33a3d9..307e35a5a 100644 --- a/packages/widgets/src/manifest.ts +++ b/packages/widgets/src/manifest.ts @@ -44,6 +44,7 @@ const moduleLoaders: Record Promise> = { networkControllerSummary: () => import("./network-controller/summary") as Promise, networkControllerStatus: () => import("./network-controller/network-status") as Promise, unifiOs: () => import("./unifi-os") as Promise, + unifiSiteManager: () => import("./unifi-site-manager") as Promise, rssFeed: () => import("./rssFeed") as Promise, bookmarks: () => import("./bookmarks") as Promise, bazarr: () => import("./bazarr") as Promise, @@ -107,6 +108,7 @@ const componentLoaders: Record Promise> networkControllerStatus: () => import("./network-controller/network-status/component") as Promise, unifiOs: () => import("./unifi-os/component") as Promise, + unifiSiteManager: () => import("./unifi-site-manager/component") as Promise, rssFeed: () => import("./rssFeed/component") as Promise, bookmarks: () => import("./bookmarks/component") as Promise, bazarr: () => import("./bazarr/component") as Promise, diff --git a/packages/widgets/src/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index c3838ba82..4e2bb3cb0 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -26,6 +26,7 @@ export const widgetQueryRefetchIntervals = [ { queryKey: [["widget", "stockPrice"]], intervalSeconds: null }, { queryKey: [["widget", "umami"]], intervalSeconds: null }, { queryKey: [["widget", "unifiOs"]], intervalSeconds: 5 }, + { queryKey: [["widget", "unifiSiteManager"]], intervalSeconds: 300 }, { queryKey: [["widget", "vpn"]], intervalSeconds: null }, { queryKey: [["widget", "wud"]], intervalSeconds: null }, ] as const; diff --git a/packages/widgets/src/unifi-site-manager/component.tsx b/packages/widgets/src/unifi-site-manager/component.tsx new file mode 100644 index 000000000..d56b37f8f --- /dev/null +++ b/packages/widgets/src/unifi-site-manager/component.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { Box, Text } from "@mantine/core"; +import type { DataTableColumn } from "mantine-datatable"; + +import { clientApi } from "@homarr/api/client"; +import type { UnifiSiteHealthState, UnifiSiteManagerSite } from "@homarr/integrations/types"; +import { useI18n } from "@homarr/translation/client"; + +import { HomarrDataTable } from "../common/homarr-data-table"; +import type { WidgetComponentProps } from "../definition"; +import { HealthBar } from "./health-bar"; + +const EMPTY_VALUE = "—"; + +/** En dessous, les colonnes secondaires ne tiennent plus lisiblement. */ +const NARROW_WIDTH = 420; + +const formatHour = (at: Date) => at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + +export default function UnifiSiteManagerWidget({ + integrationIds, + options, + isEditMode, + width, +}: WidgetComponentProps<"unifiSiteManager">) { + const t = useI18n(); + const { data: results = [] } = clientApi.widget.unifiSiteManager.overview.useQuery({ integrationIds }); + + const sites = results[0]?.overview.sites ?? []; + const isNarrow = width < NARROW_WIDTH; + + const labels: Record = { + healthy: t("widget.unifiSiteManager.health.healthy"), + degraded: t("widget.unifiSiteManager.health.degraded"), + down: t("widget.unifiSiteManager.health.down"), + unknown: t("widget.unifiSiteManager.health.unknown"), + }; + + const columns: DataTableColumn[] = [ + { + accessor: "name", + title: t("widget.unifiSiteManager.column.site"), + sortable: true, + ellipsis: true, + }, + { + accessor: "health", + title: t("widget.unifiSiteManager.column.health"), + width: isNarrow ? 140 : 220, + render: (site) => ( + + ), + }, + { + accessor: "latencyMs", + title: t("widget.unifiSiteManager.column.latency"), + sortable: true, + render: (site) => + site.latencyMs === null + ? EMPTY_VALUE + : t("widget.unifiSiteManager.latency", { + value: `${site.latencyMs}`, + loss: `${site.packetLossPct ?? 0}`, + }), + }, + ...(options.showIsp && !isNarrow + ? [ + { + accessor: "ispName", + title: t("widget.unifiSiteManager.column.isp"), + sortable: true, + render: (site: UnifiSiteManagerSite) => site.ispName ?? EMPTY_VALUE, + }, + ] + : []), + ...(options.showExternalIp && !isNarrow + ? [ + { + accessor: "externalIps", + title: t("widget.unifiSiteManager.column.externalIp"), + render: (site: UnifiSiteManagerSite) => + site.externalIps.length === 0 ? EMPTY_VALUE : site.externalIps.join(" · "), + }, + ] + : []), + { + accessor: "deviceCount", + title: t("widget.unifiSiteManager.column.devices"), + sortable: true, + render: (site) => ( + 0 ? "red" : undefined}> + {t("widget.unifiSiteManager.devicesOnline", { + online: `${site.deviceCount - site.offlineDeviceCount}`, + total: `${site.deviceCount}`, + })} + + ), + }, + { + accessor: "pendingUpdateCount", + title: t("widget.unifiSiteManager.column.updates"), + sortable: true, + render: (site) => ( + 0 ? "yellow" : undefined}> + {site.pendingUpdateCount} + + ), + }, + { + accessor: "sdWan", + title: t("widget.unifiSiteManager.column.sdWan"), + render: (site) => { + // `null` couvre DEUX cas que le contrat ne distingue pas : le site + // n'appartient à aucune configuration, ou le cloud n'a pas répondu. + // Le tiret n'affirme donc ni l'un ni l'autre — il dit « on ne sait pas », + // ce qui est vrai dans les deux cas. Ne pas le remplacer par un libellé + // du type « aucun mesh », qui trancherait à tort. + if (site.sdWan === null) return EMPTY_VALUE; + + const { offline, connected, pingMs, errorCount } = site.sdWan; + const color = offline || !connected ? "red" : errorCount > 0 ? "yellow" : undefined; + const label = offline + ? t("widget.unifiSiteManager.sdWan.offline") + : connected + ? t("widget.unifiSiteManager.sdWan.connected") + : t("widget.unifiSiteManager.sdWan.disconnected"); + + return ( + + {/* Le ping n'apparaît que mesuré : le cloud rapporte 0 quand il ne + l'a pas mesuré, et l'afficher se lirait comme une latence nulle. */} + {pingMs === null ? label : `${label} · ${pingMs} ms`} + + ); + }, + }, + { + accessor: "consoleUpdateAvailable", + title: t("widget.unifiSiteManager.column.console"), + // `null` = aucun host correspondant. Ce n'est pas « à jour ». + render: (site) => + site.consoleUpdateAvailable === null + ? EMPTY_VALUE + : site.consoleUpdateAvailable + ? t("widget.unifiSiteManager.consoleUpdateAvailable") + : t("widget.unifiSiteManager.consoleUpToDate"), + }, + ]; + + return ( + + + + ); +} diff --git a/packages/widgets/src/unifi-site-manager/health-bar.tsx b/packages/widgets/src/unifi-site-manager/health-bar.tsx new file mode 100644 index 000000000..11ffe892d --- /dev/null +++ b/packages/widgets/src/unifi-site-manager/health-bar.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { Box, Group, Text, Tooltip } from "@mantine/core"; + +import type { UnifiSiteHealthSegment, UnifiSiteHealthState, UnifiSiteHealthSummary } from "@homarr/integrations/types"; + +/** + * Couleurs de STATUT, pas palette catégorielle — elles sont fixes et non thémées. + * Un vert/orange/rouge « classique » a été mesuré à ΔE 2,9 entre l'orange et le + * vert en vision protanope : indistinguables. La palette de statut ne résout pas + * cela, elle impose une contrepartie — voir le résumé textuel ci-dessous, qui + * n'est pas décoratif. + */ +const STATE_COLOR: Record = { + healthy: "#0ca30c", + degraded: "#fab219", + down: "#d03b3b", + unknown: "var(--mantine-color-default-border)", +}; + +const SEGMENT_HEIGHT = 14; + +export const HealthBar = ({ + segments, + summary, + formatHour, + labels, +}: { + segments: UnifiSiteHealthSegment[]; + summary: UnifiSiteHealthSummary; + formatHour: (at: Date) => string; + labels: Record; +}) => ( + + + {segments.map((segment) => ( + + + + ))} + + + {/* Le canal qui porte l'information quand la couleur ne passe pas. */} + + {summary.down > 0 + ? `${summary.down}h ${labels.down}` + : summary.degraded > 0 + ? `${summary.degraded}h ${labels.degraded}` + : `${summary.healthy}h ${labels.healthy}`} + + +); diff --git a/packages/widgets/src/unifi-site-manager/index.ts b/packages/widgets/src/unifi-site-manager/index.ts new file mode 100644 index 000000000..e2e160eac --- /dev/null +++ b/packages/widgets/src/unifi-site-manager/index.ts @@ -0,0 +1,23 @@ +import { IconBuildingBroadcastTower, IconServerOff } from "@tabler/icons-react"; + +import { createWidgetDefinition } from "../definition"; +import { optionsBuilder } from "../options"; + +export const { definition, componentLoader } = createWidgetDefinition("unifiSiteManager", { + icon: IconBuildingBroadcastTower, + supportedIntegrations: ["unifiSiteManager"], + integrationsRequired: true, + refetchInterval: 300, + createOptions() { + return optionsBuilder.from((factory) => ({ + showIsp: factory.switch({ defaultValue: true, withDescription: true }), + showExternalIp: factory.switch({ defaultValue: true, withDescription: true }), + })); + }, + errors: { + INTERNAL_SERVER_ERROR: { + icon: IconServerOff, + message: (t) => t("widget.unifiSiteManager.error.internalServerError"), + }, + }, +}).withDynamicImport(() => import("./component"));