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