fix(integrations): end the health window on the last measured hour

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
zacharyandClaude Opus 5 committed 2026-09-13 22:21:43 +02:00
1 parent d08bc942a2
commit f2572bdeef
3 files changed
+135 -4

No files matched your search

@@ -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());
});
});
/**
@@ -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<IspMetricsEntry[]>(metricsResult, []);
const sdWanBySite: Map<string, UnifiSiteSdWanStatus> = 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)) };
}
}
@@ -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<IspMetricsPeriod | undefined>((newest, period) => {
if (newest === undefined) return period;
@@ -297,7 +334,12 @@ export const mapSites = (
hosts: SiteManagerHost[],
metrics: IspMetricsEntry[],
sdWanBySite: Map<string, UnifiSiteSdWanStatus>,
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 {