feat(widgets): add UniFi Site Manager overview widget

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent 74ff68993e
commit b39e27fd3b
8 files changed
+293

No files matched your search

+36
View File
@@ -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": {
+2
View File
@@ -13,6 +13,7 @@ import {
IconBrandMinecraft,
IconBrowser,
IconBuildingBank,
IconBuildingBroadcastTower,
IconBusStop,
IconCalendar,
IconChartAreaLine,
@@ -77,6 +78,7 @@ export const widgetCatalogIcons: Record<WidgetKind, TablerIcon> = {
networkControllerSummary: IconTopologyFull,
networkControllerStatus: IconTopologyFull,
unifiOs: IconRouter,
unifiSiteManager: IconBuildingBroadcastTower,
rssFeed: IconRss,
bookmarks: IconBookmark,
indexerManager: IconReportSearch,
+2
View File
@@ -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;
+2
View File
@@ -44,6 +44,7 @@ const moduleLoaders: Record<WidgetKind, () => Promise<WidgetModule>> = {
networkControllerSummary: () => import("./network-controller/summary") as Promise<WidgetModule>,
networkControllerStatus: () => import("./network-controller/network-status") as Promise<WidgetModule>,
unifiOs: () => import("./unifi-os") as Promise<WidgetModule>,
unifiSiteManager: () => import("./unifi-site-manager") as Promise<WidgetModule>,
rssFeed: () => import("./rssFeed") as Promise<WidgetModule>,
bookmarks: () => import("./bookmarks") as Promise<WidgetModule>,
bazarr: () => import("./bazarr") as Promise<WidgetModule>,
@@ -107,6 +108,7 @@ const componentLoaders: Record<WidgetKind, () => Promise<WidgetComponentModule>>
networkControllerStatus: () =>
import("./network-controller/network-status/component") as Promise<WidgetComponentModule>,
unifiOs: () => import("./unifi-os/component") as Promise<WidgetComponentModule>,
unifiSiteManager: () => import("./unifi-site-manager/component") as Promise<WidgetComponentModule>,
rssFeed: () => import("./rssFeed/component") as Promise<WidgetComponentModule>,
bookmarks: () => import("./bookmarks/component") as Promise<WidgetComponentModule>,
bazarr: () => import("./bazarr/component") as Promise<WidgetComponentModule>,
@@ -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;
@@ -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<UnifiSiteHealthState, string> = {
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<UnifiSiteManagerSite>[] = [
{
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) => (
<HealthBar segments={site.health} summary={site.summary} formatHour={formatHour} labels={labels} />
),
},
{
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) => (
<Text size="sm" c={site.offlineDeviceCount > 0 ? "red" : undefined}>
{t("widget.unifiSiteManager.devicesOnline", {
online: `${site.deviceCount - site.offlineDeviceCount}`,
total: `${site.deviceCount}`,
})}
</Text>
),
},
{
accessor: "pendingUpdateCount",
title: t("widget.unifiSiteManager.column.updates"),
sortable: true,
render: (site) => (
<Text size="sm" c={site.pendingUpdateCount > 0 ? "yellow" : undefined}>
{site.pendingUpdateCount}
</Text>
),
},
{
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 (
<Text size="sm" c={color}>
{/* 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`}
</Text>
);
},
},
{
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 (
<Box h="100%">
<HomarrDataTable
isEditMode={isEditMode}
cellPadding={isNarrow ? "2px 6px" : "4px 8px"}
records={sites}
columns={columns}
idAccessor="siteId"
noRecordsText={t("widget.unifiSiteManager.noSites")}
/>
</Box>
);
}
@@ -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<UnifiSiteHealthState, string> = {
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<UnifiSiteHealthState, string>;
}) => (
<Group gap={6} wrap="nowrap">
<Group gap={1} wrap="nowrap" style={{ flex: 1, minWidth: 0 }}>
{segments.map((segment) => (
<Tooltip
key={segment.at.toISOString()}
label={`${formatHour(segment.at)} — ${labels[segment.state]}`}
openDelay={150}
>
<Box
style={{
flex: 1,
minWidth: 2,
height: SEGMENT_HEIGHT,
borderRadius: 1,
background: STATE_COLOR[segment.state],
}}
/>
</Tooltip>
))}
</Group>
{/* Le canal qui porte l'information quand la couleur ne passe pas. */}
<Text size="xs" c="dimmed" ff="monospace" style={{ whiteSpace: "nowrap" }}>
{summary.down > 0
? `${summary.down}h ${labels.down}`
: summary.degraded > 0
? `${summary.degraded}h ${labels.degraded}`
: `${summary.healthy}h ${labels.healthy}`}
</Text>
</Group>
);
@@ -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"));