feat(widgets): add UniFi Site Manager overview widget
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
74ff68993e
commit
b39e27fd3b
8 files changed
+293
No files matched your search
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"));
|
||||
Reference in new issue
Block a user