feat(integrations): add UniFi Site Manager schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
09869aa69b
commit
52b8385adb
3 files changed
+568
No files matched your search
@@ -0,0 +1,265 @@
|
||||
// @vitest-environment node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import {
|
||||
buildHealthWindow,
|
||||
buildSdWanIndex,
|
||||
deriveHealthState,
|
||||
HEALTH_WINDOW_HOURS,
|
||||
mapSites,
|
||||
summariseHealth,
|
||||
} from "../unifi-site-manager-types";
|
||||
|
||||
const hoursBefore = (reference: Date, hours: number) => new Date(reference.getTime() - hours * 3_600_000);
|
||||
|
||||
const NOW = new Date("2026-09-09T20:00:00Z");
|
||||
|
||||
const period = (at: Date, wan: Record<string, number>) => ({
|
||||
metricTime: at.toISOString(),
|
||||
data: { wan },
|
||||
});
|
||||
|
||||
describe("deriveHealthState", () => {
|
||||
test("ranks an outage above packet loss", () => {
|
||||
expect(deriveHealthState({ downtime: 60, packetLoss: 5 })).toBe("down");
|
||||
});
|
||||
|
||||
test("reports packet loss as degraded", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 2 })).toBe("degraded");
|
||||
});
|
||||
|
||||
test("reports a clean period as healthy", () => {
|
||||
expect(deriveHealthState({ downtime: 0, packetLoss: 0 })).toBe("healthy");
|
||||
});
|
||||
|
||||
/** Champs absents : le cloud les omet parfois. Absent n'est pas zéro. */
|
||||
test("treats a period with no measurements as unknown", () => {
|
||||
expect(deriveHealthState({})).toBe("unknown");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildHealthWindow", () => {
|
||||
test("always returns one segment per hour of the window", () => {
|
||||
expect(buildHealthWindow([], NOW)).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
});
|
||||
|
||||
/**
|
||||
* Le cas central. Une fenêtre de 24 h dont le cloud ne rapporte que 16 heures
|
||||
* a été observée sur l'API réelle : les trous sont au milieu, pas seulement au
|
||||
* début. Les peindre en vert affirmerait un bon fonctionnement jamais mesuré.
|
||||
*/
|
||||
test("fills hours the cloud did not report with unknown, never healthy", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 2), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "healthy")).toHaveLength(1);
|
||||
expect(window.filter((segment) => segment.state === "unknown")).toHaveLength(HEALTH_WINDOW_HOURS - 1);
|
||||
});
|
||||
|
||||
test("orders segments oldest first", () => {
|
||||
const window = buildHealthWindow([], NOW);
|
||||
|
||||
expect(window.at(0)!.at.getTime()).toBeLessThan(window.at(-1)!.at.getTime());
|
||||
});
|
||||
|
||||
test("ignores periods that fall outside the window", () => {
|
||||
const window = buildHealthWindow([period(hoursBefore(NOW, 100), { downtime: 0, packetLoss: 0 })], NOW);
|
||||
|
||||
expect(window.every((segment) => segment.state === "unknown")).toBe(true);
|
||||
});
|
||||
|
||||
test("keeps the worst state when several readings share an hour", () => {
|
||||
const at = hoursBefore(NOW, 3);
|
||||
const window = buildHealthWindow(
|
||||
[period(at, { downtime: 0, packetLoss: 0 }), period(at, { downtime: 30, packetLoss: 0 })],
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(window.filter((segment) => segment.state === "down")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("summariseHealth", () => {
|
||||
test("counts hours per state so the bar is readable without colour", () => {
|
||||
const summary = summariseHealth([
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "healthy" },
|
||||
{ at: NOW, state: "degraded" },
|
||||
{ at: NOW, state: "unknown" },
|
||||
]);
|
||||
|
||||
expect(summary).toEqual({ healthy: 2, degraded: 1, down: 0, unknown: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildSdWanIndex", () => {
|
||||
const status = (siteId: string, overrides: Record<string, unknown> = {}) => ({
|
||||
status: {
|
||||
connections: [
|
||||
{ sites: [{ siteId, status: { connected: true, offline: false, ping: 0, errors: [], ...overrides } }] },
|
||||
],
|
||||
sites: [{ siteId, errors: [], warnings: [] }],
|
||||
},
|
||||
});
|
||||
|
||||
test("indexes a site by its identifier", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ configName: "Mesh", connected: true, offline: false });
|
||||
});
|
||||
|
||||
/**
|
||||
* Relevé sur un tunnel connecté entre deux sites distants : `ping: 0`. Une
|
||||
* latence nulle y est physiquement impossible — le champ veut dire « non
|
||||
* mesuré », et propager le zéro l'afficherait comme une performance.
|
||||
*/
|
||||
test("reads a ping of zero as unmeasured, not as zero milliseconds", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1").status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBeNull();
|
||||
});
|
||||
|
||||
test("keeps a measured ping", () => {
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: status("site-1", { ping: 12 }).status }]);
|
||||
|
||||
expect(index.get("site-1")!.pingMs).toBe(12);
|
||||
});
|
||||
|
||||
test("counts errors and warnings", () => {
|
||||
const raw = status("site-1", { errors: ["a", "b"] }).status;
|
||||
raw.sites[0]!.warnings = ["w"];
|
||||
|
||||
const index = buildSdWanIndex([{ configName: "Mesh", status: raw }]);
|
||||
|
||||
expect(index.get("site-1")).toMatchObject({ errorCount: 2, warningCount: 1 });
|
||||
});
|
||||
|
||||
test("keeps the first configuration when a site appears in several", () => {
|
||||
const index = buildSdWanIndex([
|
||||
{ configName: "First", status: status("site-1").status },
|
||||
{ configName: "Second", status: status("site-1").status },
|
||||
]);
|
||||
|
||||
expect(index.get("site-1")!.configName).toBe("First");
|
||||
});
|
||||
|
||||
test("leaves a site absent from every configuration out of the index", () => {
|
||||
expect(buildSdWanIndex([]).get("site-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("mapSites", () => {
|
||||
const site = {
|
||||
siteId: "site-1",
|
||||
hostId: "host-1",
|
||||
meta: { desc: "Example site", name: "default" },
|
||||
statistics: {
|
||||
percentages: { wanUptime: 99.5 },
|
||||
ispInfo: { name: "Example ISP" },
|
||||
wans: { WAN: { externalIp: "203.0.113.10" }, WAN2: { externalIp: "203.0.113.11" } },
|
||||
counts: { totalDevice: 6, offlineDevice: 1, pendingUpdateDevice: 3 },
|
||||
},
|
||||
};
|
||||
|
||||
const host = { id: "host-1", reportedState: { deviceState: "updateAvailable" } };
|
||||
|
||||
test("maps a complete site", () => {
|
||||
const [mapped] = mapSites([site], [host], [], new Map(), NOW);
|
||||
|
||||
expect(mapped).toMatchObject({
|
||||
siteId: "site-1",
|
||||
name: "Example site",
|
||||
wanUptimePct: 99.5,
|
||||
ispName: "Example ISP",
|
||||
externalIps: ["203.0.113.10", "203.0.113.11"],
|
||||
deviceCount: 6,
|
||||
offlineDeviceCount: 1,
|
||||
pendingUpdateCount: 3,
|
||||
consoleUpdateAvailable: true,
|
||||
});
|
||||
});
|
||||
|
||||
test("falls back to meta.name when there is no description", () => {
|
||||
const [mapped] = mapSites([{ ...site, meta: { name: "default" } }], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.name).toBe("default");
|
||||
});
|
||||
|
||||
/**
|
||||
* `null` veut dire « aucun host ne correspond », ce qui n'est pas « la console
|
||||
* est à jour ». Le widget doit pouvoir les distinguer.
|
||||
*/
|
||||
test("leaves consoleUpdateAvailable null when no host matches", () => {
|
||||
const [mapped] = mapSites([site], [], [], new Map(), NOW);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBeNull();
|
||||
});
|
||||
|
||||
test("reports a console with no pending update as false, not null", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[{ id: "host-1", reportedState: { deviceState: "connected" } }],
|
||||
[],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.consoleUpdateAvailable).toBe(false);
|
||||
});
|
||||
|
||||
test("yields an empty list for an account with no sites", () => {
|
||||
expect(mapSites([], [], [], new Map(), NOW)).toEqual([]);
|
||||
});
|
||||
|
||||
test("attaches the health window of the matching metrics entry", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[{ siteId: "site-1", periods: [period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0 })] }],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.health).toHaveLength(HEALTH_WINDOW_HOURS);
|
||||
expect(mapped!.summary.healthy).toBe(1);
|
||||
expect(mapped!.latencyMs).toBeNull();
|
||||
});
|
||||
|
||||
test("takes latency and loss from the most recent period", () => {
|
||||
const [mapped] = mapSites(
|
||||
[site],
|
||||
[],
|
||||
[
|
||||
{
|
||||
siteId: "site-1",
|
||||
periods: [
|
||||
period(hoursBefore(NOW, 5), { downtime: 0, packetLoss: 9, avgLatency: 99 }),
|
||||
period(hoursBefore(NOW, 1), { downtime: 0, packetLoss: 0, avgLatency: 11 }),
|
||||
],
|
||||
},
|
||||
],
|
||||
new Map(),
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(mapped!.latencyMs).toBe(11);
|
||||
expect(mapped!.packetLossPct).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Ce module est réexporté par packages/integrations/src/types.ts, lui-même
|
||||
* importé par des composants client. Un import de @homarr/common/server y
|
||||
* traîne ioredis puis node:dns dans le bundle navigateur et empêche
|
||||
* l'application de démarrer — défaut réellement survenu sur l'intégration
|
||||
* précédente, invisible au typecheck comme aux tests unitaires.
|
||||
*/
|
||||
describe("client bundle safety", () => {
|
||||
test("the types module does not import server-only code", () => {
|
||||
// `import.meta.url` plutôt que `__dirname` : ce dernier n'existe pas en ESM,
|
||||
// et aucun autre spec du dépôt ne s'y fie.
|
||||
const source = readFileSync(new URL("../unifi-site-manager-types.ts", import.meta.url), "utf8");
|
||||
|
||||
expect(source).not.toContain("@homarr/common/server");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import type { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Séparé de `unifi-site-manager-types.ts` à dessein : ce module importe
|
||||
* `@homarr/common/server`, qui atteint `ioredis` puis `node:dns`. Le module de
|
||||
* types, lui, est réexporté vers des composants client et doit rester pur.
|
||||
*/
|
||||
export const parseSiteManagerAsync = async <TOutput>(
|
||||
value: unknown,
|
||||
schema: z.ZodType<TOutput>,
|
||||
message: string,
|
||||
): Promise<TOutput> => {
|
||||
const result = await schema.safeParseAsync(value);
|
||||
if (!result.success) {
|
||||
throw new ParseError(message, { cause: result.error });
|
||||
}
|
||||
return result.data;
|
||||
};
|
||||
@@ -0,0 +1,284 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/** Ubiquiti ajoute des champs sans prévenir : tous les objets d'API sont tolérants. */
|
||||
|
||||
const wanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
downtime: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ispMetricsPeriodSchema = z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: wanMetricsSchema.optional() }),
|
||||
});
|
||||
|
||||
export const ispMetricsEntrySchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
periods: z.array(ispMetricsPeriodSchema).default([]),
|
||||
});
|
||||
|
||||
export const ispMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({ metrics: z.array(ispMetricsEntrySchema).default([]) }),
|
||||
});
|
||||
|
||||
export const siteManagerSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({ desc: z.string().optional(), name: z.string().optional() }),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
counts: z
|
||||
.looseObject({
|
||||
totalDevice: z.number().optional(),
|
||||
offlineDevice: z.number().optional(),
|
||||
pendingUpdateDevice: z.number().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const siteManagerSiteListSchema = z.looseObject({ data: z.array(siteManagerSiteSchema).default([]) });
|
||||
|
||||
export const siteManagerHostSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
reportedState: z.looseObject({ deviceState: z.string().optional() }).optional(),
|
||||
});
|
||||
|
||||
export const siteManagerHostListSchema = z.looseObject({ data: z.array(siteManagerHostSchema).default([]) });
|
||||
|
||||
export const sdWanConfigSchema = z.looseObject({ id: z.string(), name: z.string().optional() });
|
||||
|
||||
export const sdWanConfigListSchema = z.looseObject({ data: z.array(sdWanConfigSchema).default([]) });
|
||||
|
||||
const sdWanConnectionSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
status: z
|
||||
.looseObject({
|
||||
connected: z.boolean().optional(),
|
||||
offline: z.boolean().optional(),
|
||||
ping: z.number().optional(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const sdWanStatusSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
status: z.looseObject({
|
||||
connections: z.array(z.looseObject({ sites: z.array(sdWanConnectionSiteSchema).default([]) })).default([]),
|
||||
sites: z
|
||||
.array(
|
||||
z.looseObject({
|
||||
siteId: z.string(),
|
||||
errors: z.array(z.unknown()).default([]),
|
||||
warnings: z.array(z.unknown()).default([]),
|
||||
}),
|
||||
)
|
||||
.default([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
export type SdWanStatus = z.infer<typeof sdWanStatusSchema>;
|
||||
|
||||
export type SiteManagerSite = z.infer<typeof siteManagerSiteSchema>;
|
||||
export type SiteManagerHost = z.infer<typeof siteManagerHostSchema>;
|
||||
export type IspMetricsEntry = z.infer<typeof ispMetricsEntrySchema>;
|
||||
export type IspMetricsPeriod = z.infer<typeof ispMetricsPeriodSchema>;
|
||||
|
||||
export type UnifiSiteHealthState = "healthy" | "degraded" | "down" | "unknown";
|
||||
|
||||
export interface UnifiSiteHealthSegment {
|
||||
/** Début de la tranche horaire. */
|
||||
at: Date;
|
||||
state: UnifiSiteHealthState;
|
||||
}
|
||||
|
||||
export type UnifiSiteHealthSummary = Record<UnifiSiteHealthState, number>;
|
||||
|
||||
export interface UnifiSiteManagerSite {
|
||||
siteId: string;
|
||||
name: string;
|
||||
wanUptimePct: number | null;
|
||||
ispName: string | null;
|
||||
externalIps: string[];
|
||||
deviceCount: number;
|
||||
offlineDeviceCount: number;
|
||||
/** Firmwares d'ÉQUIPEMENTS en attente — distinct de la mise à jour de console. */
|
||||
pendingUpdateCount: number;
|
||||
/** Mise à jour de la CONSOLE. `null` si aucun host ne correspond. */
|
||||
consoleUpdateAvailable: boolean | null;
|
||||
/** Toujours HEALTH_WINDOW_HOURS entrées, la plus ancienne en premier. */
|
||||
health: UnifiSiteHealthSegment[];
|
||||
summary: UnifiSiteHealthSummary;
|
||||
latencyMs: number | null;
|
||||
packetLossPct: number | null;
|
||||
/** `null` si le site n'appartient à aucune configuration SD-WAN. */
|
||||
sdWan: UnifiSiteSdWanStatus | null;
|
||||
}
|
||||
|
||||
export interface UnifiSiteSdWanStatus {
|
||||
configName: string;
|
||||
connected: boolean;
|
||||
offline: boolean;
|
||||
/** `null` quand le cloud rapporte `0` : « non mesuré », pas « zéro milliseconde ». */
|
||||
pingMs: number | null;
|
||||
errorCount: number;
|
||||
warningCount: number;
|
||||
}
|
||||
|
||||
export interface UnifiSiteManagerOverview {
|
||||
sites: UnifiSiteManagerSite[];
|
||||
}
|
||||
|
||||
export const HEALTH_WINDOW_HOURS = 24;
|
||||
|
||||
const HOUR_MS = 3_600_000;
|
||||
|
||||
/** Gravité décroissante. L'ordre compte : une coupure prime sur une perte. */
|
||||
export const deriveHealthState = (
|
||||
wan:
|
||||
| {
|
||||
downtime?: number;
|
||||
packetLoss?: number;
|
||||
}
|
||||
| undefined,
|
||||
): UnifiSiteHealthState => {
|
||||
if (wan === undefined) return "unknown";
|
||||
if (wan.downtime === undefined && wan.packetLoss === undefined) return "unknown";
|
||||
if ((wan.downtime ?? 0) > 0) return "down";
|
||||
if ((wan.packetLoss ?? 0) > 0) return "degraded";
|
||||
return "healthy";
|
||||
};
|
||||
|
||||
const SEVERITY: Record<UnifiSiteHealthState, number> = { unknown: 0, healthy: 1, degraded: 2, down: 3 };
|
||||
|
||||
const floorToHour = (value: number) => Math.floor(value / HOUR_MS) * HOUR_MS;
|
||||
|
||||
/**
|
||||
* Reconstitue une fenêtre pleine de HEALTH_WINDOW_HOURS tranches.
|
||||
*
|
||||
* Le cloud ne rapporte pas toutes les heures : une fenêtre de 24 h n'en a
|
||||
* 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é.
|
||||
*/
|
||||
export const buildHealthWindow = (periods: IspMetricsPeriod[], endingAt: Date): UnifiSiteHealthSegment[] => {
|
||||
const lastHour = floorToHour(endingAt.getTime());
|
||||
const firstHour = lastHour - (HEALTH_WINDOW_HOURS - 1) * HOUR_MS;
|
||||
|
||||
const byHour = new Map<number, UnifiSiteHealthState>();
|
||||
for (const period of periods) {
|
||||
const at = floorToHour(new Date(period.metricTime).getTime());
|
||||
if (Number.isNaN(at) || at < firstHour || at > lastHour) continue;
|
||||
|
||||
const state = deriveHealthState(period.data.wan);
|
||||
const existing = byHour.get(at);
|
||||
// Plusieurs mesures dans la même heure : on garde la pire.
|
||||
if (existing === undefined || SEVERITY[state] > SEVERITY[existing]) byHour.set(at, state);
|
||||
}
|
||||
|
||||
return Array.from({ length: HEALTH_WINDOW_HOURS }, (_, offset) => {
|
||||
const at = firstHour + offset * HOUR_MS;
|
||||
return { at: new Date(at), state: byHour.get(at) ?? "unknown" };
|
||||
});
|
||||
};
|
||||
|
||||
/** Le canal qui porte l'information quand la couleur ne passe pas. */
|
||||
export const summariseHealth = (segments: UnifiSiteHealthSegment[]): UnifiSiteHealthSummary => {
|
||||
const summary: UnifiSiteHealthSummary = { healthy: 0, degraded: 0, down: 0, unknown: 0 };
|
||||
for (const segment of segments) summary[segment.state] += 1;
|
||||
return summary;
|
||||
};
|
||||
|
||||
const latestPeriod = (periods: IspMetricsPeriod[]): IspMetricsPeriod | undefined =>
|
||||
periods.reduce<IspMetricsPeriod | undefined>((newest, period) => {
|
||||
if (newest === undefined) return period;
|
||||
return new Date(period.metricTime).getTime() > new Date(newest.metricTime).getTime() ? period : newest;
|
||||
}, undefined);
|
||||
|
||||
/**
|
||||
* Indexe l'état des tunnels par site.
|
||||
*
|
||||
* `ping: 0` a été relevé sur un tunnel `connected` reliant deux sites
|
||||
* géographiquement distants — une latence nulle y est physiquement impossible.
|
||||
* Le champ signifie donc « non mesuré », et il devient `null` plutôt que d'être
|
||||
* propagé comme une performance.
|
||||
*
|
||||
* Un site peut apparaître dans plusieurs configurations : la première l'emporte,
|
||||
* et son nom accompagne l'état pour que la ligne reste interprétable.
|
||||
*/
|
||||
export const buildSdWanIndex = (
|
||||
configs: { configName: string; status: SdWanStatus["data"]["status"] }[],
|
||||
): Map<string, UnifiSiteSdWanStatus> => {
|
||||
const index = new Map<string, UnifiSiteSdWanStatus>();
|
||||
|
||||
for (const { configName, status } of configs) {
|
||||
const warningsBySite = new Map(status.sites.map((site) => [site.siteId, site]));
|
||||
|
||||
for (const connection of status.connections) {
|
||||
for (const site of connection.sites) {
|
||||
if (index.has(site.siteId)) continue;
|
||||
|
||||
const reported = site.status;
|
||||
const ping = reported?.ping ?? 0;
|
||||
|
||||
index.set(site.siteId, {
|
||||
configName,
|
||||
connected: reported?.connected ?? false,
|
||||
offline: reported?.offline ?? false,
|
||||
pingMs: ping > 0 ? ping : null,
|
||||
errorCount: reported?.errors.length ?? 0,
|
||||
warningCount: warningsBySite.get(site.siteId)?.warnings.length ?? 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return index;
|
||||
};
|
||||
|
||||
export const mapSites = (
|
||||
sites: SiteManagerSite[],
|
||||
hosts: SiteManagerHost[],
|
||||
metrics: IspMetricsEntry[],
|
||||
sdWanBySite: Map<string, UnifiSiteSdWanStatus>,
|
||||
now: Date,
|
||||
): UnifiSiteManagerSite[] =>
|
||||
sites.map((site) => {
|
||||
const statistics = site.statistics;
|
||||
const counts = statistics?.counts;
|
||||
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 latest = latestPeriod(periods)?.data.wan;
|
||||
|
||||
return {
|
||||
siteId: site.siteId,
|
||||
name: site.meta.desc ?? site.meta.name ?? site.siteId,
|
||||
wanUptimePct: statistics?.percentages?.wanUptime ?? null,
|
||||
ispName: statistics?.ispInfo?.name ?? null,
|
||||
externalIps: Object.values(statistics?.wans ?? {})
|
||||
.map((wan) => wan.externalIp)
|
||||
.filter((ip): ip is string => ip !== undefined),
|
||||
deviceCount: counts?.totalDevice ?? 0,
|
||||
offlineDeviceCount: counts?.offlineDevice ?? 0,
|
||||
pendingUpdateCount: counts?.pendingUpdateDevice ?? 0,
|
||||
consoleUpdateAvailable: host === undefined ? null : host.reportedState?.deviceState === "updateAvailable",
|
||||
health,
|
||||
summary: summariseHealth(health),
|
||||
latencyMs: latest?.avgLatency ?? null,
|
||||
packetLossPct: latest?.packetLoss ?? null,
|
||||
sdWan: sdWanBySite.get(site.siteId) ?? null,
|
||||
};
|
||||
});
|
||||
Reference in new issue
Block a user