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 89dc5b357..cbfa13317 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 @@ -9,6 +9,7 @@ import { deriveHealthState, HEALTH_WINDOW_HOURS, mapSites, + resolveHealthWindowEnd, summariseHealth, } from "../unifi-site-manager-types"; @@ -101,6 +102,47 @@ describe("buildHealthWindow", () => { }); }); +/** + * Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur + * le compte réel) : l'heure courante n'est pas encore mesurée. Une fenêtre + * finissant sur elle réservait donc sa dernière tranche à du gris permanent + * pendant la première moitié de chaque heure. + */ +describe("resolveHealthWindowEnd", () => { + test("ends on the freshest reported period instead of the wall clock", () => { + const freshest = hoursBefore(NOW, 1); + const end = resolveHealthWindowEnd( + [{ siteId: "site-1", periods: [period(hoursBefore(NOW, 3), {}), period(freshest, {})] }], + NOW, + ); + + expect(end.getTime()).toBe(freshest.getTime()); + }); + + /** + * Le maximum est GLOBAL. Les lignes du tableau se lisent heure contre heure : + * une fin propre à chaque site ferait glisser sa fenêtre par rapport à celles + * de ses voisines, et la colonne cesserait de désigner la même heure. + */ + test("takes the maximum across every site rather than per site", () => { + const freshest = hoursBefore(NOW, 1); + const end = resolveHealthWindowEnd( + [ + { siteId: "site-1", periods: [period(hoursBefore(NOW, 6), {})] }, + { siteId: "site-2", periods: [period(freshest, {})] }, + ], + NOW, + ); + + expect(end.getTime()).toBe(freshest.getTime()); + }); + + test("falls back to the current time when no site reported any period", () => { + expect(resolveHealthWindowEnd([], NOW)).toBe(NOW); + expect(resolveHealthWindowEnd([{ siteId: "site-1", periods: [] }], NOW)).toBe(NOW); + }); +}); + describe("summariseHealth", () => { test("counts hours per state so the bar is readable without colour", () => { const summary = summariseHealth([ @@ -309,6 +351,47 @@ describe("mapSites", () => { expect(mapped!.latencyMs).toBe(11); expect(mapped!.packetLossPct).toBe(0); }); + + test("ends the health window on the freshest reported hour, not on the wall clock", () => { + const freshest = hoursBefore(NOW, 1); + const metrics = [{ siteId: "site-1", periods: [period(freshest, { downtime: 0, packetLoss: 0 })] }]; + + const [mapped] = mapSites([site], [], metrics, new Map(), resolveHealthWindowEnd(metrics, NOW)); + + expect(mapped!.health.at(-1)!.at.getTime()).toBe(freshest.getTime()); + expect(mapped!.health.at(-1)!.state).toBe("healthy"); + }); + + /** + * Une seule fin pour toutes les lignes : le site à jour remplit sa dernière + * tranche, celui qui retarde garde son gris à droite. C'est une information + * vraie — il n'a rien rapporté depuis — et non un artefact d'horloge. + */ + test("keeps the trailing grey of a site lagging the others, on one shared window", () => { + const metrics = [ + { siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] }, + { siteId: "site-2", periods: [period(hoursBefore(NOW, 4), { downtime: 0, packetLoss: 0 })] }, + ]; + + const [current, lagging] = mapSites( + [site, { ...site, siteId: "site-2" }], + [], + metrics, + new Map(), + resolveHealthWindowEnd(metrics, NOW), + ); + + expect(current!.health.at(-1)!.state).toBe("healthy"); + expect(lagging!.health.at(-1)!.state).toBe("unknown"); + expect(lagging!.health.at(-1)!.at.getTime()).toBe(current!.health.at(-1)!.at.getTime()); + }); + + test("keeps a window ending now when the account reported no period at all", () => { + const [mapped] = mapSites([site], [], [], new Map(), resolveHealthWindowEnd([], NOW)); + + expect(mapped!.summary.unknown).toBe(HEALTH_WINDOW_HOURS); + expect(mapped!.health.at(-1)!.at.getTime()).toBe(NOW.getTime()); + }); }); /** diff --git a/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts b/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts index 1b9b25d52..3a55a93fe 100644 --- a/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-integration.ts @@ -13,7 +13,7 @@ import type { UnifiSiteManagerOverview, UnifiSiteSdWanStatus, } from "./unifi-site-manager-types"; -import { buildSdWanIndex, mapSites } from "./unifi-site-manager-types"; +import { buildSdWanIndex, mapSites, resolveHealthWindowEnd } from "./unifi-site-manager-types"; /** * `api.ui.com` présente un certificat public valide. On n'utilise donc PAS le @@ -82,6 +82,12 @@ export class UnifiSiteManagerIntegration extends Integration implements UnifiSit const metrics = settled(metricsResult, []); const sdWanBySite: Map = buildSdWanIndex(settled(sdWanResult, [])); - return { sites: mapSites(sites, hosts, metrics, sdWanBySite, now) }; + // `now` drives the metrics REQUEST, but not the end of the health window: + // the cloud's ISP metrics lag 25 to 35 minutes, so the current hour has no + // measurement yet for the first half of every hour and its segment would be + // permanently grey. The window therefore ends on the freshest hour any site + // actually reported — one end for every row, so the table stays comparable + // hour against hour. See `resolveHealthWindowEnd`. + return { sites: mapSites(sites, hosts, metrics, sdWanBySite, resolveHealthWindowEnd(metrics, now)) }; } } 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 index e4728ed1f..8fd9eb062 100644 --- a/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts +++ b/packages/integrations/src/unifi-site-manager/unifi-site-manager-types.ts @@ -188,6 +188,10 @@ const floorToHour = (value: number) => Math.floor(value / HOUR_MS) * HOUR_MS; * 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é. + * + * `endingAt` est la dernière heure MESURÉE, pas maintenant : voir + * `resolveHealthWindowEnd`. Les périodes hors fenêtre étant écartées ci-dessous, + * reculer cette fin ne peut pas y faire entrer de données plus anciennes. */ export const buildHealthWindow = (periods: IspMetricsPeriod[], endingAt: Date): UnifiSiteHealthSegment[] => { const lastHour = floorToHour(endingAt.getTime()); @@ -217,6 +221,39 @@ export const summariseHealth = (segments: UnifiSiteHealthSegment[]): UnifiSiteHe return summary; }; +/** + * Fin de la fenêtre de santé : la dernière heure RÉELLEMENT rapportée, et non + * maintenant. + * + * Les métriques ISP du cloud arrivent avec 25 à 35 minutes de retard (mesuré sur + * le compte réel). Terminer la fenêtre sur l'heure courante réservait donc sa + * dernière tranche à une heure qu'aucune mesure ne couvre encore : elle restait + * grise pendant la première moitié de chaque heure. Honnêtement grise, et + * totalement inutile — un vingt-quatrième de la barre ne disait jamais rien. + * + * Le maximum est GLOBAL, pris sur les périodes de TOUS les sites, et non site par + * site : les lignes du tableau se lisent heure contre heure, et une fin propre à + * chaque site ferait glisser sa fenêtre par rapport à celles de ses voisines. + * Avec une fin unique, un site qui retarde vraiment sur les autres garde une + * tranche grise à droite — c'est une information vraie, pas un artefact. + * + * Sans aucune période (compte vide, ou appel de métriques en échec), on retombe + * sur `now` : la fenêtre reste pleine de HEALTH_WINDOW_HOURS tranches inconnues. + */ +export const resolveHealthWindowEnd = (metrics: IspMetricsEntry[], now: Date): Date => { + let newest: number | undefined; + + for (const entry of metrics) { + for (const period of entry.periods) { + const at = new Date(period.metricTime).getTime(); + if (Number.isNaN(at)) continue; + if (newest === undefined || at > newest) newest = at; + } + } + + return newest === undefined ? now : new Date(newest); +}; + const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined => periods.reduce((newest, period) => { if (newest === undefined) return period; @@ -297,7 +334,12 @@ export const mapSites = ( hosts: SiteManagerHost[], metrics: IspMetricsEntry[], sdWanBySite: Map, - now: Date, + /** + * Dernière heure de la fenêtre de santé, commune à toutes les lignes. Ce n'est + * PAS `now` : l'appelant la tient de `resolveHealthWindowEnd`, qui explique + * pourquoi. + */ + healthWindowEndsAt: Date, ): UnifiSiteManagerSite[] => sites.map((site) => { const statistics = site.statistics; @@ -305,7 +347,7 @@ export const mapSites = ( 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 health = buildHealthWindow(periods, healthWindowEndsAt); const latest = latestPeriod(periods)?.data.wan; return {