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:
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 {
|
||||
|
||||
Reference in new issue
Block a user