From e505e94644b74130b408eaf3d67759a5d2f7a902 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zachary=20Gu=C3=A9not?= Date: Sun, 13 Sep 2026 15:52:05 +0200 Subject: [PATCH] feat(integrations): fill the recent hours from the 5-minute ISP metrics Co-Authored-By: Claude Opus 5 --- .../test/unifi-site-manager-api.spec.ts | 152 ++++++++++++++++-- .../test/unifi-site-manager-types.spec.ts | 21 +++ .../unifi-site-manager-api.ts | 80 ++++++++- 3 files changed, 236 insertions(+), 17 deletions(-) diff --git a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts index 57b729fe5..cfc9db851 100644 --- a/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts +++ b/packages/integrations/src/unifi-site-manager/test/unifi-site-manager-api.spec.ts @@ -11,7 +11,7 @@ vi.hoisted(() => { import { ResponseError } from "@homarr/common/server"; -import { MAX_SD_WAN_CONFIGS, UnifiSiteManagerApi } from "../unifi-site-manager-api"; +import { MAX_SD_WAN_CONFIGS, RECENT_METRICS_WINDOW_HOURS, UnifiSiteManagerApi } from "../unifi-site-manager-api"; const API_KEY = "cloud-key"; const NOW = new Date("2026-09-09T20:00:00Z"); @@ -109,13 +109,32 @@ describe("UnifiSiteManagerApi getSdWanStatusesAsync", () => { }); }); +interface IspMetricsRequestBody { + sites: { siteId: string; hostId?: string; beginTimestamp: string; endTimestamp: string }[]; +} + +/** Les corps envoyés, indexés par granularité — l'URL seule ne dit rien de la fenêtre. */ +const collectIspMetricsRequests = (handler?: (granularity: string) => Promise) => { + const bodies = new Map(); + const { api, fetchAsync } = createApi(async (url, init) => { + const granularity = url.includes("/5m/") ? "5m" : "1h"; + bodies.set(granularity, JSON.parse(String(init?.body)) as IspMetricsRequestBody); + return handler === undefined ? jsonResponse({ data: { metrics: [] } }) : await handler(granularity); + }); + return { api, fetchAsync, bodies }; +}; + +const spanHoursOf = (site: { beginTimestamp: string; endTimestamp: string }) => + (new Date(site.endTimestamp).getTime() - new Date(site.beginTimestamp).getTime()) / 3_600_000; + +const metricsEntry = (siteId: string, metricTimes: string[]) => ({ + siteId, + periods: metricTimes.map((metricTime) => ({ metricTime, data: { wan: { downtime: 0, packetLoss: 0 } } })), +}); + describe("UnifiSiteManagerApi getIspMetricsAsync", () => { test("asks for every site in a single bounded query", async () => { - let body: { sites: { siteId: string; beginTimestamp: string; endTimestamp: string }[] } | null = null; - const { api } = createApi(async (_url, init) => { - body = JSON.parse(String(init?.body)) as typeof body; - return jsonResponse({ data: { metrics: [] } }); - }); + const { api, bodies } = collectIspMetricsRequests(); await api.getIspMetricsAsync( [ @@ -125,10 +144,9 @@ describe("UnifiSiteManagerApi getIspMetricsAsync", () => { NOW, ); - expect(body!.sites).toHaveLength(2); - const [first] = body!.sites; - const spanHours = (new Date(first!.endTimestamp).getTime() - new Date(first!.beginTimestamp).getTime()) / 3_600_000; - expect(spanHours).toBeGreaterThanOrEqual(24); + const hourly = bodies.get("1h"); + expect(hourly!.sites).toHaveLength(2); + expect(spanHoursOf(hourly!.sites[0]!)).toBeGreaterThanOrEqual(24); }); test("returns an empty list rather than calling the cloud when there is no site", async () => { @@ -138,11 +156,119 @@ describe("UnifiSiteManagerApi getIspMetricsAsync", () => { expect(fetchAsync).not.toHaveBeenCalled(); }); - test("uses the hourly granularity, not the five-minute one", async () => { - const { api, fetchAsync } = createApi(async () => jsonResponse({ data: { metrics: [] } })); + /** + * La granularité horaire est en retard de près de quatre heures sur le compte + * réel : la requête à 5 minutes remplit ces heures-là, et rien qu'elles. + * Vérifier les CORPS et pas seulement les chemins — deux fenêtres identiques + * passeraient un test qui ne regarde que l'URL. + */ + test("queries both granularities, the five-minute one only over the recent hours", async () => { + const { api, fetchAsync, bodies } = collectIspMetricsRequests(); + + await api.getIspMetricsAsync([{ siteId: "a", hostId: "h1" }], NOW); + + const requested = fetchAsync.mock.calls.map((call) => String(call[0])); + expect(requested).toContain("https://api.ui.com/v1/isp-metrics/1h/query"); + expect(requested).toContain("https://api.ui.com/v1/isp-metrics/5m/query"); + + const hourly = bodies.get("1h")!.sites[0]!; + const recent = bodies.get("5m")!.sites[0]!; + expect(spanHoursOf(hourly)).toBe(24); + expect(spanHoursOf(recent)).toBe(RECENT_METRICS_WINDOW_HOURS); + // Même bord droit : les deux fenêtres s'arrêtent à l'instant demandé. + expect(recent.endTimestamp).toBe(hourly.endTimestamp); + expect(new Date(recent.beginTimestamp).getTime()).toBeGreaterThan(new Date(hourly.beginTimestamp).getTime()); + // Le site est demandé à l'identique aux deux granularités. + expect(recent.siteId).toBe("a"); + expect(recent.hostId).toBe("h1"); + }); + + test("issues the two queries concurrently rather than one after the other", async () => { + const started: string[] = []; + let releaseHourly: (() => void) | undefined; + const { api } = collectIspMetricsRequests(async (granularity) => { + started.push(granularity); + // La requête horaire ne rend la main qu'une fois la seconde partie : en + // séquence, ce test se bloquerait au lieu de passer. + if (granularity === "1h") { + await new Promise((resolve) => { + releaseHourly = resolve; + }); + } else { + releaseHourly?.(); + } + return jsonResponse({ data: { metrics: [] } }); + }); await api.getIspMetricsAsync([{ siteId: "a" }], NOW); - expect(String(fetchAsync.mock.calls[0]?.[0])).toBe("https://api.ui.com/v1/isp-metrics/1h/query"); + expect(started).toEqual(["1h", "5m"]); + }); + + test("merges the periods of both granularities into one entry per site", async () => { + const { api } = collectIspMetricsRequests(async (granularity) => + jsonResponse({ + data: { + metrics: [ + granularity === "1h" + ? metricsEntry("a", ["2026-09-09T17:00:00Z", "2026-09-09T18:00:00Z"]) + : metricsEntry("a", ["2026-09-09T19:30:00Z", "2026-09-09T19:35:00Z"]), + ], + }, + }), + ); + + const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW); + + expect(metrics).toHaveLength(1); + expect(metrics[0]!.periods.map((period) => period.metricTime)).toEqual([ + "2026-09-09T17:00:00Z", + "2026-09-09T18:00:00Z", + "2026-09-09T19:30:00Z", + "2026-09-09T19:35:00Z", + ]); + }); + + test("keeps a site reported by only one of the two granularities", async () => { + const { api } = collectIspMetricsRequests(async (granularity) => + jsonResponse({ + data: { + metrics: + granularity === "1h" + ? [metricsEntry("only-hourly", ["2026-09-09T17:00:00Z"])] + : [metricsEntry("only-recent", ["2026-09-09T19:30:00Z"])], + }, + }), + ); + + const metrics = await api.getIspMetricsAsync([{ siteId: "only-hourly" }, { siteId: "only-recent" }], NOW); + + expect(metrics.map((metric) => metric.siteId).toSorted()).toEqual(["only-hourly", "only-recent"]); + expect(metrics.find((metric) => metric.siteId === "only-recent")!.periods).toHaveLength(1); + }); + + /** La fraîcheur est un enrichissement : la perdre ne doit pas emporter la bande. */ + test("still returns the hourly window when the five-minute query fails", async () => { + const { api } = collectIspMetricsRequests(async (granularity) => + granularity === "5m" + ? jsonResponse({ message: "rate limited" }, 429) + : jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T18:00:00Z"])] } }), + ); + + const metrics = await api.getIspMetricsAsync([{ siteId: "a" }], NOW); + + expect(metrics.map((metric) => metric.siteId)).toEqual(["a"]); + expect(metrics[0]!.periods).toHaveLength(1); + }); + + /** L'inverse n'est pas vrai : sans la fenêtre horaire, il n'y a pas de bande. */ + test("throws when the hourly query fails, even if the five-minute one succeeded", async () => { + const { api } = collectIspMetricsRequests(async (granularity) => + granularity === "1h" + ? jsonResponse({ message: "unauthorized" }, 401) + : jsonResponse({ data: { metrics: [metricsEntry("a", ["2026-09-09T19:30:00Z"])] } }), + ); + + await expect(api.getIspMetricsAsync([{ siteId: "a" }], NOW)).rejects.toBeInstanceOf(ResponseError); }); }); 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 index c213cc115..89dc5b357 100644 --- 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 @@ -78,6 +78,27 @@ describe("buildHealthWindow", () => { expect(window.filter((segment) => segment.state === "down")).toHaveLength(1); }); + + /** + * Ce qu'affirme la fusion des deux granularités du côté de l'API : les points + * de 5 minutes n'ont besoin d'aucun traitement propre. Douze points d'une même + * heure se replient sur UNE tranche, et le pire l'emporte — une coupure de + * cinq minutes ne se dilue pas dans onze relevés sains. + */ + test("collapses five-minute points onto the hour they belong to, worst first", () => { + const hour = hoursBefore(NOW, 1); + const fiveMinutePoints = Array.from({ length: 12 }, (_, index) => + period( + new Date(hour.getTime() + index * 300_000), + index === 7 ? { downtime: 30 } : { downtime: 0, packetLoss: 0 }, + ), + ); + + const window = buildHealthWindow(fiveMinutePoints, NOW); + + expect(window.filter((segment) => segment.state !== "unknown")).toHaveLength(1); + expect(window.filter((segment) => segment.state === "down")).toHaveLength(1); + }); }); describe("summariseHealth", () => { diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts index 068759798..e9cf1a987 100644 --- a/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-api.ts @@ -24,6 +24,46 @@ const REQUEST_TIMEOUT_MS = 15_000; */ export const MAX_SD_WAN_CONFIGS = 5; +/** + * Fenêtre de la requête à 5 minutes. + * + * Mesuré sur un compte réel : la dernière heure que rapporte `/1h/query` a 2 h 43 + * et 3 h 43 de retard selon le site, là où `/5m/query` rapporte des points de 24 + * et 34 minutes. Le bout droit de la bande était donc structurellement gris et la + * latence affichée vieille de trois heures. Quatre heures couvrent le pire retard + * observé sans rapatrier une fenêtre fine de 24 h. + */ +export const RECENT_METRICS_WINDOW_HOURS = 4; + +/** + * Rassemble les deux granularités en une entrée par site. + * + * Les périodes des deux réponses sont concaténées sans déduplication : en aval, + * `buildHealthWindow` plaque chaque période sur son heure et garde le pire état + * quand plusieurs y tombent, et `latestPeriod` prend le maximum par + * `metricTime`. Les points de 5 minutes se replient donc d'eux-mêmes sur les + * tranches horaires, et la latence vient du point le plus frais. + * + * Un site présent dans une seule des deux réponses doit ressortir : la fusion + * part des deux listes, jamais de l'intersection. + */ +const mergeIspMetrics = (hourly: IspMetricsEntry[], recent: IspMetricsEntry[]): IspMetricsEntry[] => { + const bySite = new Map(); + + for (const entry of [...hourly, ...recent]) { + const existing = bySite.get(entry.siteId); + // Copie des périodes : les tableaux viennent de la réponse analysée, et les + // concaténer en place muterait celle-ci sous les autres lecteurs. + if (existing === undefined) { + bySite.set(entry.siteId, { ...entry, periods: [...entry.periods] }); + continue; + } + existing.periods.push(...entry.periods); + } + + return Array.from(bySite.values()); +}; + /** * Déclaré sur les types d'undici, pas sur ceux du DOM : le dépôt n'utilise que * `undici` côté serveur. Aligner les deux évite les `as unknown as` qui @@ -56,8 +96,15 @@ export class UnifiSiteManagerApi { } /** - * Granularité horaire sur la fenêtre de la bande de santé, tous les sites dans - * une seule requête bornée. La variante non bornée pèse 216 Ko (mesuré). + * Deux granularités pour une seule fenêtre. + * + * La requête horaire couvre les HEALTH_WINDOW_HOURS heures de la bande, tous + * les sites en une requête bornée — la variante non bornée pèse 216 Ko + * (mesuré). Elle rapporte cependant jusqu'à près de quatre heures de retard, + * d'où la seconde requête, à 5 minutes, sur les seules heures récentes. + * + * Les deux partent en parallèle : enchaînées, elles ajouteraient la latence de + * l'une à celle de l'autre à chaque rafraîchissement. */ public async getIspMetricsAsync( sites: { siteId: string; hostId?: string }[], @@ -65,10 +112,33 @@ export class UnifiSiteManagerApi { ): Promise { if (sites.length === 0) return []; - const beginTimestamp = new Date(endingAt.getTime() - HEALTH_WINDOW_HOURS * 3_600_000).toISOString(); const endTimestamp = endingAt.toISOString(); + const beginTimestampOf = (hours: number) => new Date(endingAt.getTime() - hours * 3_600_000).toISOString(); - const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/1h/query`, { + const [hourlyResult, recentResult] = await Promise.allSettled([ + this.queryIspMetricsAsync("1h", sites, beginTimestampOf(HEALTH_WINDOW_HOURS), endTimestamp), + this.queryIspMetricsAsync("5m", sites, beginTimestampOf(RECENT_METRICS_WINDOW_HOURS), endTimestamp), + ]); + + // La fenêtre horaire porte toute la bande : son échec doit remonter. + if (hourlyResult.status === "rejected") throw hourlyResult.reason; + + // L'échec de la requête à 5 minutes est avalé À DESSEIN : elle n'apporte que + // la fraîcheur des dernières heures. Sans elle, ces heures retombent en gris + // — « non rapporté », ce qui est vrai — au lieu de perdre tout le widget + // pour un enrichissement. Ne pas propager ce rejet. + const recent = recentResult.status === "fulfilled" ? recentResult.value : []; + + return mergeIspMetrics(hourlyResult.value, recent); + } + + private async queryIspMetricsAsync( + granularity: "1h" | "5m", + sites: { siteId: string; hostId?: string }[], + beginTimestamp: string, + endTimestamp: string, + ): Promise { + const response = await this.fetchAsync(`${BASE_URL}/v1/isp-metrics/${granularity}/query`, { method: "POST", headers: { "X-API-KEY": this.apiKey, Accept: "application/json", "Content-Type": "application/json" }, timeout: REQUEST_TIMEOUT_MS, @@ -79,6 +149,8 @@ export class UnifiSiteManagerApi { if (!response.ok) throw new ResponseError(response); + // Le schéma est identique aux deux granularités : `data.metrics[].periods[]` + // avec `metricTime` et `data.wan` — vérifié sur la réponse réelle à 5 min. const parsed = await parseSiteManagerAsync( await response.json(), ispMetricsResponseSchema,