diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json index a2853a752..c769845f0 100644 --- a/packages/translation/src/lang/en.json +++ b/packages/translation/src/lang/en.json @@ -5793,6 +5793,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/ui/src/widget-icons.ts b/packages/ui/src/widget-icons.ts index 820cfa989..4da10d1f6 100644 --- a/packages/ui/src/widget-icons.ts +++ b/packages/ui/src/widget-icons.ts @@ -13,6 +13,7 @@ import { IconBrandMinecraft, IconBrowser, IconBuildingBank, + IconBuildingBroadcastTower, IconBusStop, IconCalendar, IconChartAreaLine, @@ -94,6 +95,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/refetch-intervals.ts b/packages/widgets/src/refetch-intervals.ts index 1b149268e..fc6f914e2 100644 --- a/packages/widgets/src/refetch-intervals.ts +++ b/packages/widgets/src/refetch-intervals.ts @@ -24,6 +24,7 @@ export const widgetQueryRefetchIntervals = [ staleTimeSeconds: 300, }, { queryKey: [["widget", "airQuality", "atLocation"]], intervalSeconds: 900 }, + { queryKey: [["widget", "unifiSiteManager"]], intervalSeconds: 300 }, { queryKey: [["widget", "immich", "getAlbum"]], intervalSeconds: null }, { queryKey: [["widget", "immich", "getServerStats"]], intervalSeconds: null }, { queryKey: [["widget", "immich", "getAlbums"]], intervalSeconds: null }, diff --git a/packages/widgets/src/registry.ts b/packages/widgets/src/registry.ts index 818e01e29..6ec4628ba 100644 --- a/packages/widgets/src/registry.ts +++ b/packages/widgets/src/registry.ts @@ -55,6 +55,7 @@ import type * as tracearr from "./tracearr"; import type * as traefik from "./traefik"; import type * as umami from "./umami"; import type * as unifiOs from "./unifi-os"; +import type * as unifiSiteManager from "./unifi-site-manager"; import type * as ups from "./ups"; import type * as uptimeKuma from "./uptime-kuma"; import type * as video from "./video"; @@ -125,6 +126,7 @@ export const widgetModuleLoaders = { audioStats: () => import("./audio-stats"), umami: () => import("./umami"), unifiOs: () => import("./unifi-os"), + unifiSiteManager: () => import("./unifi-site-manager"), vpn: () => import("./vpn"), ups: () => import("./ups"), beszelSystemTable: () => import("./beszel-system-table"), @@ -198,6 +200,7 @@ export type WidgetImports = { audioStats: typeof audioStats; umami: typeof umami; unifiOs: typeof unifiOs; + unifiSiteManager: typeof unifiSiteManager; vpn: typeof vpn; ups: typeof ups; beszelSystemTable: typeof beszelSystemTable; @@ -278,6 +281,7 @@ export const widgetComponentLoaders = { audioStats: () => import("./audio-stats/component"), umami: () => import("./umami/component"), unifiOs: () => import("./unifi-os/component"), + unifiSiteManager: () => import("./unifi-site-manager/component"), vpn: () => import("./vpn/component"), ups: () => import("./ups/component"), beszelSystemTable: () => import("./beszel-system-table/component"), 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"));