diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts new file mode 100644 index 000000000..f70985fc9 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-types.spec.ts @@ -0,0 +1,265 @@ +// @vitest-environment node +import { readFileSync } from "node:fs"; +import { describe, expect, test } from "vitest"; + +import { + buildHealthWindow, + buildSdWanIndex, + deriveHealthState, + HEALTH_WINDOW_HOURS, + mapSites, + summariseHealth, +} from "../unifi-site-manager-types"; + +const hoursBefore = (reference: Date, hours: number) => new Date(reference.getTime() - hours * 3_600_000); + +const NOW = new Date("2026-09-09T20:00:00Z"); + +const period = (at: Date, wan: Record) => ({ + metricTime: at.toISOString(), + data: { wan }, +}); + +describe("deriveHealthState", () => { + test("ranks an outage above packet loss", () => { + expect(deriveHealthState({ downtime: 60, packetLoss: 5 })).toBe("down"); + }); + + test("reports packet loss as degraded", () => { + expect(deriveHealthState({ downtime: 0, packetLoss: 2 })).toBe("degraded"); + }); + + test("reports a clean period as healthy", () => { + expect(deriveHealthState({ downtime: 0, packetLoss: 0 })).toBe("healthy"); + }); + + /** Champs absents : le cloud les omet parfois. Absent n'est pas zéro. */ + test("treats a period with no measurements as unknown", () => { + expect(deriveHealthState({})).toBe("unknown"); + }); +}); + +describe("buildHealthWindow", () => { + test("always returns one segment per hour of the window", () => { + expect(buildHealthWindow([], NOW)).toHaveLength(HEALTH_WINDOW_HOURS); + }); + + /** + * Le cas central. Une fenêtre de 24 h dont le cloud ne rapporte que 16 heures + * a été observée sur l'API réelle : les trous sont au milieu, pas seulement au + * début. Les peindre en vert affirmerait un bon fonctionnement jamais mesuré. + */ + test("fills hours the cloud did not report with unknown, never healthy", () => { + const window = buildHealthWindow([period(hoursBefore(NOW, 2), { downtime: 0, packetLoss: 0 })], NOW); + + expect(window.filter((segment) => segment.state === "healthy")).toHaveLength(1); + expect(window.filter((segment) => segment.state === "unknown")).toHaveLength(HEALTH_WINDOW_HOURS - 1); + }); + + test("orders segments oldest first", () => { + const window = buildHealthWindow([], NOW); + + expect(window.at(0)!.at.getTime()).toBeLessThan(window.at(-1)!.at.getTime()); + }); + + test("ignores periods that fall outside the window", () => { + const window = buildHealthWindow([period(hoursBefore(NOW, 100), { downtime: 0, packetLoss: 0 })], NOW); + + expect(window.every((segment) => segment.state === "unknown")).toBe(true); + }); + + test("keeps the worst state when several readings share an hour", () => { + const at = hoursBefore(NOW, 3); + const window = buildHealthWindow( + [period(at, { downtime: 0, packetLoss: 0 }), period(at, { downtime: 30, packetLoss: 0 })], + NOW, + ); + + expect(window.filter((segment) => segment.state === "down")).toHaveLength(1); + }); +}); + +describe("summariseHealth", () => { + test("counts hours per state so the bar is readable without colour", () => { + const summary = summariseHealth([ + { at: NOW, state: "healthy" }, + { at: NOW, state: "healthy" }, + { at: NOW, state: "degraded" }, + { at: NOW, state: "unknown" }, + ]); + + expect(summary).toEqual({ healthy: 2, degraded: 1, down: 0, unknown: 1 }); + }); +}); + +describe("buildSdWanIndex", () => { + const status = (siteId: string, overrides: Record = {}) => ({ + status: { + connections: [ + { sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] }, + ], + sites: [{ siteId, errors: [], warnings: [] }], + }, + }); + + test("indexes a site by its identifier", () => { + const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]); + + expect(index.get("site-1")).toMatchObject({ configName: "Mesh", connected: true, offline: false }); + }); + + /** + * Relevé sur un tunnel connecté entre deux sites distants : `ping: 0`. Une + * latence nulle y est physiquement impossible — le champ veut dire « non + * mesuré », et propager le zéro l'afficherait comme une performance. + */ + test("reads a ping of zero as unmeasured, not as zero milliseconds", () => { + const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]); + + expect(index.get("site-1")!.pingMs).toBeNull(); + }); + + test("keeps a measured ping", () => { + const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1", { ping: 12 }).status }]); + + expect(index.get("site-1")!.pingMs).toBe(12); + }); + + test("counts errors and warnings", () => { + const raw = status("site-1", { errors: ["a", "b"] }).status; + raw.sites[0]!.warnings = ["w"]; + + const index = buildSdWanIndex([{ configName: "Mesh", status: raw }]); + + expect(index.get("site-1")).toMatchObject({ errorCount: 2, warningCount: 1 }); + }); + + test("keeps the first configuration when a site appears in several", () => { + const index = buildSdWanIndex([ + { configName: "First", status: status("site-1").status }, + { configName: "Second", status: status("site-1").status }, + ]); + + expect(index.get("site-1")!.configName).toBe("First"); + }); + + test("leaves a site absent from every configuration out of the index", () => { + expect(buildSdWanIndex([]).get("site-1")).toBeUndefined(); + }); +}); + +describe("mapSites", () => { + const site = { + siteId: "site-1", + hostId: "host-1", + meta: { desc: "Example site", name: "default" }, + statistics: { + percentages: { wanUptime: 99.5 }, + ispInfo: { name: "Example ISP" }, + wans: { WAN: { externalIp: "203.0.113.10" }, WAN2: { externalIp: "203.0.113.11" } }, + counts: { totalDevice: 6, offlineDevice: 1, pendingUpdateDevice: 3 }, + }, + }; + + const host = { id: "host-1", reportedState: { deviceState: "updateAvailable" } }; + + test("maps a complete site", () => { + const [mapped] = mapSites([site], [host], [], new Map(), NOW); + + expect(mapped).toMatchObject({ + siteId: "site-1", + name: "Example site", + wanUptimePct: 99.5, + ispName: "Example ISP", + externalIps: ["203.0.113.10", "203.0.113.11"], + deviceCount: 6, + offlineDeviceCount: 1, + pendingUpdateCount: 3, + consoleUpdateAvailable: true, + }); + }); + + test("falls back to meta.name when there is no description", () => { + const [mapped] = mapSites([{ ...site, meta: { name: "default" } }], [], [], new Map(), NOW); + + expect(mapped!.name).toBe("default"); + }); + + /** + * `null` veut dire « aucun host ne correspond », ce qui n'est pas « la console + * est à jour ». Le widget doit pouvoir les distinguer. + */ + test("leaves consoleUpdateAvailable null when no host matches", () => { + const [mapped] = mapSites([site], [], [], new Map(), NOW); + + expect(mapped!.consoleUpdateAvailable).toBeNull(); + }); + + test("reports a console with no pending update as false, not null", () => { + const [mapped] = mapSites( + [site], + [{ id: "host-1", reportedState: { deviceState: "connected" } }], + [], + new Map(), + NOW, + ); + + expect(mapped!.consoleUpdateAvailable).toBe(false); + }); + + test("yields an empty list for an account with no sites", () => { + expect(mapSites([], [], [], new Map(), NOW)).toEqual([]); + }); + + test("attaches the health window of the matching metrics entry", () => { + const [mapped] = mapSites( + [site], + [], + [{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] }], + new Map(), + NOW, + ); + + expect(mapped!.health).toHaveLength(HEALTH_WINDOW_HOURS); + expect(mapped!.summary.healthy).toBe(1); + expect(mapped!.latencyMs).toBeNull(); + }); + + test("takes latency and loss from the most recent period", () => { + const [mapped] = mapSites( + [site], + [], + [ + { + siteId: "site-1", + periods: [ + period(hoursBefore(NOW, 5), { downtime: 0, packetLoss: 9, avgLatency: 99 }), + period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0, avgLatency: 11 }), + ], + }, + ], + new Map(), + NOW, + ); + + expect(mapped!.latencyMs).toBe(11); + expect(mapped!.packetLossPct).toBe(0); + }); +}); + +/** + * Ce module est réexporté par packages/integrations/src/types.ts, lui-même + * importé par des composants client. Un import de @homarr/common/server y + * traîne ioredis puis node:dns dans le bundle navigateur et empêche + * l'application de démarrer — défaut réellement survenu sur l'intégration + * précédente, invisible au typecheck comme aux tests unitaires. + */ +describe("client bundle safety", () => { + test("the types module does not import server-only code", () => { + // `import.meta.url` plutôt que `__dirname` : ce dernier n'existe pas en ESM, + // et aucun autre spec du dépôt ne s'y fie. + const source = readFileSync(new URL("../unifi-site-manager-types.ts", import.meta.url), "utf8"); + + expect(source).not.toContain("@homarr/common/server"); + }); +}); diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-parse.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-parse.ts new file mode 100644 index 000000000..0856f61d4 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-parse.ts @@ -0,0 +1,19 @@ +import { ParseError } from "@homarr/common/server"; +import type { z } from "zod/v4"; + +/** + * Séparé de `unifi-site-manager-types.ts` à dessein : ce module importe + * `@homarr/common/server`, qui atteint `ioredis` puis `node:dns`. Le module de + * types, lui, est réexporté vers des composants client et doit rester pur. + */ +export const parseSiteManagerAsync = async ( + value: unknown, + schema: z.ZodType, + message: string, +): Promise => { + const result = await schema.safeParseAsync(value); + if (!result.success) { + throw new ParseError(message, { cause: result.error }); + } + return result.data; +}; diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts new file mode 100644 index 000000000..9303af7d9 --- /dev/null +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts @@ -0,0 +1,284 @@ +import { z } from "zod/v4"; + +/** Ubiquiti ajoute des champs sans prévenir : tous les objets d'API sont tolérants. */ + +const wanMetricsSchema = z.looseObject({ + avgLatency: z.number().optional(), + maxLatency: z.number().optional(), + packetLoss: z.number().optional(), + downtime: z.number().optional(), + uptime: z.number().optional(), + ispName: z.string().optional(), +}); + +export const ispMetricsPeriodSchema = z.looseObject({ + metricTime: z.string(), + data: z.looseObject({ wan: wanMetricsSchema.optional() }), +}); + +export const ispMetricsEntrySchema = z.looseObject({ + siteId: z.string(), + hostId: z.string().optional(), + periods: z.array(ispMetricsPeriodSchema).default([]), +}); + +export const ispMetricsResponseSchema = z.looseObject({ + data: z.looseObject({ metrics: z.array(ispMetricsEntrySchema).default([]) }), +}); + +export const siteManagerSiteSchema = z.looseObject({ + siteId: z.string(), + hostId: z.string().optional(), + meta: z.looseObject({ desc: z.string().optional(), name: z.string().optional() }), + statistics: z + .looseObject({ + percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(), + ispInfo: z.looseObject({ name: z.string().optional() }).optional(), + wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(), + counts: z + .looseObject({ + totalDevice: z.number().optional(), + offlineDevice: z.number().optional(), + pendingUpdateDevice: z.number().optional(), + }) + .optional(), + }) + .optional(), +}); + +export const siteManagerSiteListSchema = z.looseObject({ data: z.array(siteManagerSiteSchema).default([]) }); + +export const siteManagerHostSchema = z.looseObject({ + id: z.string(), + reportedState: z.looseObject({ deviceState: z.string().optional() }).optional(), +}); + +export const siteManagerHostListSchema = z.looseObject({ data: z.array(siteManagerHostSchema).default([]) }); + +export const sdWanConfigSchema = z.looseObject({ id: z.string(), name: z.string().optional() }); + +export const sdWanConfigListSchema = z.looseObject({ data: z.array(sdWanConfigSchema).default([]) }); + +const sdWanConnectionSiteSchema = z.looseObject({ + siteId: z.string(), + status: z + .looseObject({ + connected: z.boolean().optional(), + offline: z.boolean().optional(), + ping: z.number().optional(), + errors: z.array(z.unknown()).default([]), + }) + .optional(), +}); + +export const sdWanStatusSchema = z.looseObject({ + data: z.looseObject({ + status: z.looseObject({ + connections: z.array(z.looseObject({ sites: z.array(sdWanConnectionSiteSchema).default([]) })).default([]), + sites: z + .array( + z.looseObject({ + siteId: z.string(), + errors: z.array(z.unknown()).default([]), + warnings: z.array(z.unknown()).default([]), + }), + ) + .default([]), + }), + }), +}); + +export type SdWanStatus = z.infer; + +export type SiteManagerSite = z.infer; +export type SiteManagerHost = z.infer; +export type IspMetricsEntry = z.infer; +export type IspMetricsPeriod = z.infer; + +export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown"; + +export interface UnifiSiteHealthSegment { + /** Début de la tranche horaire. */ + at: Date; + state: UnifiSiteHealthState; +} + +export type UnifiSiteHealthSummary = Record; + +export interface UnifiSiteManagerSite { + siteId: string; + name: string; + wanUptimePct: number | null; + ispName: string | null; + 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. */ + consoleUpdateAvailable: boolean | null; + /** Toujours HEALTH_WINDOW_HOURS entrées, la plus ancienne en premier. */ + health: UnifiSiteHealthSegment[]; + summary: UnifiSiteHealthSummary; + 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` : « non mesuré », pas « zéro milliseconde ». */ + pingMs: number | null; + errorCount: number; + warningCount: number; +} + +export interface UnifiSiteManagerOverview { + sites: UnifiSiteManagerSite[]; +} + +export const HEALTH_WINDOW_HOURS = 24; + +const HOUR_MS = 3_600_000; + +/** Gravité décroissante. L'ordre compte : une coupure prime sur une perte. */ +export const deriveHealthState = ( + wan: + | { + downtime?: number; + packetLoss?: number; + } + | undefined, +): UnifiSiteHealthState => { + if (wan === undefined) return "unknown"; + if (wan.downtime === undefined && wan.packetLoss === undefined) return "unknown"; + if ((wan.downtime ?? 0) > 0) return "down"; + if ((wan.packetLoss ?? 0) > 0) return "degraded"; + return "healthy"; +}; + +const SEVERITY: Record = { unknown: 0, healthy: 1, degraded: 2, down: 3 }; + +const floorToHour = (value: number) => Math.floor(value / HOUR_MS) * HOUR_MS; + +/** + * Reconstitue une fenêtre pleine de HEALTH_WINDOW_HOURS tranches. + * + * Le cloud ne rapporte pas toutes les heures : une fenêtre de 24 h n'en a + * renvoyé que 16 sur l'API réelle, avec des trous au milieu. Une tranche non + * rapportée vaut `"unknown"` et jamais `"healthy"` — la peindre en vert + * affirmerait un bon fonctionnement qui n'a pas été mesuré. + */ +export const buildHealthWindow = (periods: IspMetricsPeriod[], endingAt: Date): UnifiSiteHealthSegment[] => { + const lastHour = floorToHour(endingAt.getTime()); + const firstHour = lastHour - (HEALTH_WINDOW_HOURS - 1) * HOUR_MS; + + const byHour = new Map(); + for (const period of periods) { + const at = floorToHour(new Date(period.metricTime).getTime()); + if (Number.isNaN(at) || at < firstHour || at > lastHour) continue; + + const state = deriveHealthState(period.data.wan); + const existing = byHour.get(at); + // Plusieurs mesures dans la même heure : on garde la pire. + if (existing === undefined || SEVERITY[state] > SEVERITY[existing]) byHour.set(at, state); + } + + return Array.from({ length: HEALTH_WINDOW_HOURS }, (_, offset) => { + const at = firstHour + offset * HOUR_MS; + return { at: new Date(at), state: byHour.get(at) ?? "unknown" }; + }); +}; + +/** Le canal qui porte l'information quand la couleur ne passe pas. */ +export const summariseHealth = (segments: UnifiSiteHealthSegment[]): UnifiSiteHealthSummary => { + const summary: UnifiSiteHealthSummary = { healthy: 0, degraded: 0, down: 0, unknown: 0 }; + for (const segment of segments) summary[segment.state] += 1; + return summary; +}; + +const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined => + periods.reduce((newest, period) => { + if (newest === undefined) return period; + return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest; + }, undefined); + +/** + * Indexe l'état des tunnels par site. + * + * `ping: 0` a été relevé sur un tunnel `connected` reliant deux sites + * géographiquement distants — une latence nulle y est physiquement impossible. + * Le champ signifie donc « non mesuré », et il devient `null` plutôt que d'être + * propagé comme une performance. + * + * Un site peut apparaître dans plusieurs configurations : la première l'emporte, + * et son nom accompagne l'état pour que la ligne reste interprétable. + */ +export const buildSdWanIndex = ( + configs: { configName: string; status: SdWanStatus["data"]["status"] }[], +): Map => { + const index = new Map(); + + for (const { configName, status } of configs) { + const warningsBySite = new Map(status.sites.map((site) => [site.siteId, site])); + + for (const connection of status.connections) { + for (const site of connection.sites) { + if (index.has(site.siteId)) continue; + + const reported = site.status; + const ping = reported?.ping ?? 0; + + index.set(site.siteId, { + configName, + connected: reported?.connected ?? false, + offline: reported?.offline ?? false, + pingMs: ping > 0 ? ping : null, + errorCount: reported?.errors.length ?? 0, + warningCount: warningsBySite.get(site.siteId)?.warnings.length ?? 0, + }); + } + } + } + + return index; +}; + +export const mapSites = ( + sites: SiteManagerSite[], + hosts: SiteManagerHost[], + metrics: IspMetricsEntry[], + sdWanBySite: Map, + now: Date, +): UnifiSiteManagerSite[] => + sites.map((site) => { + const statistics = site.statistics; + const counts = statistics?.counts; + const host = site.hostId === undefined ? undefined : hosts.find((candidate) => candidate.id === site.hostId); + const entry = metrics.find((candidate) => candidate.siteId === site.siteId); + const periods = entry?.periods ?? []; + const health = buildHealthWindow(periods, now); + const latest = latestPeriod(periods)?.data.wan; + + return { + siteId: site.siteId, + name: site.meta.desc ?? site.meta.name ?? site.siteId, + wanUptimePct: statistics?.percentages?.wanUptime ?? null, + ispName: statistics?.ispInfo?.name ?? null, + externalIps: Object.values(statistics?.wans ?? {}) + .map((wan) => wan.externalIp) + .filter((ip): ip is string => ip !== undefined), + deviceCount: counts?.totalDevice ?? 0, + offlineDeviceCount: counts?.offlineDevice ?? 0, + pendingUpdateCount: counts?.pendingUpdateDevice ?? 0, + consoleUpdateAvailable: host === undefined ? null : host.reportedState?.deviceState === "updateAvailable", + health, + summary: summariseHealth(health), + latencyMs: latest?.avgLatency ?? null, + packetLossPct: latest?.packetLoss ?? null, + sdWan: sdWanBySite.get(site.siteId) ?? null, + }; + });