feat(integrations): add UniFi Site Manager schemas and pure mappers

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-10 10:24:50 +02:00
1 parent 09869aa69b
commit 52b8385adb
3 files changed
+568

No files matched your search

@@ -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<string, number>) => ({
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<string, unknown> = {}) => ({
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");
});
});
@@ -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 <TOutput>(
value: unknown,
schema: z.ZodType<TOutput>,
message: string,
): Promise<TOutput> => {
const result = await schema.safeParseAsync(value);
if (!result.success) {
throw new ParseError(message, { cause: result.error });
}
return result.data;
};
@@ -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<typeof sdWanStatusSchema>;
export type SiteManagerSite = z.infer<typeof siteManagerSiteSchema>;
export type SiteManagerHost = z.infer<typeof siteManagerHostSchema>;
export type IspMetricsEntry = z.infer<typeof ispMetricsEntrySchema>;
export type IspMetricsPeriod = z.infer<typeof ispMetricsPeriodSchema>;
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
export interface UnifiSiteHealthSegment {
/** Début de la tranche horaire. */
at: Date;
state: UnifiSiteHealthState;
}
export type UnifiSiteHealthSummary = Record<UnifiSiteHealthState, number>;
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<UnifiSiteHealthState, number> = { 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<number, UnifiSiteHealthState>();
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<IspMetricsPeriod | undefined>((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<string, UnifiSiteSdWanStatus> => {
const index = new Map<string, UnifiSiteSdWanStatus>();
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<string, UnifiSiteSdWanStatus>,
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,
};
});