feat(integrations): fill the recent hours from the 5-minute ISP metrics

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 e32b956385
commit e505e94644
3 files changed
+236 -17

No files matched your search

@@ -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<Response>) => {
const bodies = new Map<string, IspMetricsRequestBody>();
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<void>((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);
});
});
@@ -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", () => {
@@ -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<string, IspMetricsEntry>();
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<IspMetricsEntry[]> {
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<IspMetricsEntry[]> {
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,