feat(integrations): add UniFi OS schemas and pure mappers
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a6e4df0460
commit
84498d4e7b
2 files changed
+372
No files matched your search
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import type { UnifiOsClient, UnifiOsDevice } from "../unifi-os-types";
|
||||
import {
|
||||
countClients,
|
||||
findGateway,
|
||||
matchCloudSite,
|
||||
normalizeMacAddress,
|
||||
summarizeDevices,
|
||||
unifiOsClientSchema,
|
||||
} from "../unifi-os-types";
|
||||
|
||||
const client = (type: string, accessType = "DEFAULT"): UnifiOsClient => ({
|
||||
id: `client-${type}-${accessType}-${Math.random()}`,
|
||||
name: "client",
|
||||
type,
|
||||
access: { type: accessType },
|
||||
});
|
||||
|
||||
const device = (overrides: Partial<UnifiOsDevice> = {}): UnifiOsDevice => ({
|
||||
id: "device-1",
|
||||
name: "device",
|
||||
model: "USW Lite 8",
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
state: "ONLINE",
|
||||
firmwareUpdatable: false,
|
||||
features: ["switching"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("normalizeMacAddress", () => {
|
||||
test("lowercases and strips separators so both APIs compare equal", () => {
|
||||
expect(normalizeMacAddress("AA:BB:CC:11:22:33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("aa-bb-cc-11-22-33")).toBe("aabbcc112233");
|
||||
expect(normalizeMacAddress("AABBCC112233")).toBe("aabbcc112233");
|
||||
});
|
||||
});
|
||||
|
||||
describe("countClients", () => {
|
||||
test("counts each connection type separately", () => {
|
||||
const counts = countClients([
|
||||
client("WIRED"),
|
||||
client("WIRED"),
|
||||
client("WIRELESS"),
|
||||
client("VPN"),
|
||||
client("TELEPORT"),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 5, wired: 2, wireless: 1, vpn: 2, guests: 0 });
|
||||
});
|
||||
|
||||
test("folds TELEPORT into vpn because both are remote access", () => {
|
||||
expect(countClients([client("TELEPORT")]).vpn).toBe(1);
|
||||
});
|
||||
|
||||
test("counts guests across every connection type", () => {
|
||||
const counts = countClients([client("WIRELESS", "GUEST"), client("WIRED", "GUEST"), client("WIRED")]);
|
||||
|
||||
expect(counts.guests).toBe(2);
|
||||
expect(counts.wired).toBe(2);
|
||||
});
|
||||
|
||||
test("treats an unknown connection type as counted but unclassified", () => {
|
||||
const counts = countClients([client("SOMETHING_NEW")]);
|
||||
|
||||
expect(counts.total).toBe(1);
|
||||
expect(counts.wired + counts.wireless + counts.vpn).toBe(0);
|
||||
});
|
||||
|
||||
test("tolerates a client without an access block", () => {
|
||||
const counts = countClients([{ id: "x", name: "x", type: "WIRED" }]);
|
||||
|
||||
expect(counts).toEqual({ total: 1, wired: 1, wireless: 0, vpn: 0, guests: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("summarizeDevices", () => {
|
||||
test("splits online from everything else and counts pending firmware", () => {
|
||||
const counts = summarizeDevices([
|
||||
device({ id: "a", state: "ONLINE", firmwareUpdatable: true }),
|
||||
device({ id: "b", state: "OFFLINE" }),
|
||||
device({ id: "c", state: "UPDATING", firmwareUpdatable: true }),
|
||||
]);
|
||||
|
||||
expect(counts).toEqual({ total: 3, online: 1, offline: 2, firmwareUpdatable: 2 });
|
||||
});
|
||||
|
||||
test("returns zeroes for an empty site rather than throwing", () => {
|
||||
expect(summarizeDevices([])).toEqual({ total: 0, online: 0, offline: 0, firmwareUpdatable: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("findGateway", () => {
|
||||
test("prefers the device advertising the gateway feature", () => {
|
||||
const gateway = device({ id: "gw", features: ["gateway"], model: "UXG Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("falls back to a gateway-looking model when features are empty", () => {
|
||||
const gateway = device({ id: "gw", features: [], model: "Gateway Pro" });
|
||||
expect(findGateway([device(), gateway])?.id).toBe("gw");
|
||||
});
|
||||
|
||||
test("returns null when no gateway is present instead of picking any device", () => {
|
||||
expect(findGateway([device()])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("matchCloudSite", () => {
|
||||
const siteA = { siteId: "a", meta: { gatewayMac: "AA:BB:CC:11:22:33" } };
|
||||
const siteB = { siteId: "b", meta: { gatewayMac: "dd:ee:ff:44:55:66" } };
|
||||
|
||||
test("matches on the gateway MAC regardless of formatting", () => {
|
||||
expect(matchCloudSite("aa-bb-cc-11-22-33", [siteB, siteA])?.siteId).toBe("a");
|
||||
});
|
||||
|
||||
test("returns null rather than another account site when nothing matches", () => {
|
||||
expect(matchCloudSite("11:22:33:44:55:66", [siteA, siteB])).toBeNull();
|
||||
});
|
||||
|
||||
test("returns null when the local gateway is unknown", () => {
|
||||
expect(matchCloudSite(null, [siteA, siteB])).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("unifiOsClientSchema", () => {
|
||||
test("keeps fields the published OpenAPI does not document", () => {
|
||||
const parsed = unifiOsClientSchema.parse({
|
||||
id: "1",
|
||||
name: "n",
|
||||
type: "WIRED",
|
||||
access: { type: "DEFAULT" },
|
||||
macAddress: "aa:bb:cc:11:22:33",
|
||||
uplinkDeviceId: "u1",
|
||||
});
|
||||
|
||||
expect(parsed).toMatchObject({ macAddress: "aa:bb:cc:11:22:33", uplinkDeviceId: "u1" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { ParseError } from "@homarr/common/server";
|
||||
import { z } from "zod/v4";
|
||||
|
||||
/**
|
||||
* Ubiquiti ne publie l'OpenAPI que pour la 10.4.57 alors que les consoles sont
|
||||
* en 10.6.x et renvoient déjà des champs absents du spec. Tous les objets sont
|
||||
* donc « loose » : un champ inattendu ne doit jamais faire échouer un widget.
|
||||
*/
|
||||
|
||||
export const unifiOsInfoSchema = z.looseObject({
|
||||
applicationVersion: z.string(),
|
||||
});
|
||||
|
||||
export const unifiOsSiteSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
internalReference: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiOsClientSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
type: z.string(),
|
||||
access: z.looseObject({ type: z.string() }).optional(),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
model: z.string(),
|
||||
macAddress: z.string(),
|
||||
state: z.string(),
|
||||
firmwareUpdatable: z.boolean().optional(),
|
||||
features: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const unifiOsDeviceStatisticsSchema = z.looseObject({
|
||||
uptimeSec: z.number().optional(),
|
||||
cpuUtilizationPct: z.number().optional(),
|
||||
memoryUtilizationPct: z.number().optional(),
|
||||
uplink: z.looseObject({ txRateBps: z.number(), rxRateBps: z.number() }).optional(),
|
||||
});
|
||||
|
||||
export const unifiOsVpnServerSchema = z.looseObject({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
/** Enveloppe de pagination commune à tous les endpoints de liste. */
|
||||
export const createUnifiOsPageSchema = <TItem extends z.ZodType>(item: TItem) =>
|
||||
z.looseObject({
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
count: z.number(),
|
||||
totalCount: z.number(),
|
||||
data: z.array(item),
|
||||
});
|
||||
|
||||
export const unifiSmSiteSchema = z.looseObject({
|
||||
siteId: z.string(),
|
||||
hostId: z.string().optional(),
|
||||
meta: z.looseObject({
|
||||
desc: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
gatewayMac: z.string().optional(),
|
||||
}),
|
||||
statistics: z
|
||||
.looseObject({
|
||||
ispInfo: z.looseObject({ name: z.string().optional() }).optional(),
|
||||
percentages: z.looseObject({ wanUptime: z.number().optional() }).optional(),
|
||||
wans: z.record(z.string(), z.looseObject({ externalIp: z.string().optional() })).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const unifiSmWanMetricsSchema = z.looseObject({
|
||||
avgLatency: z.number().optional(),
|
||||
maxLatency: z.number().optional(),
|
||||
packetLoss: z.number().optional(),
|
||||
uptime: z.number().optional(),
|
||||
ispName: z.string().optional(),
|
||||
});
|
||||
|
||||
export const unifiSmIspMetricsResponseSchema = z.looseObject({
|
||||
data: z.looseObject({
|
||||
metrics: z.array(
|
||||
z.looseObject({
|
||||
periods: z.array(
|
||||
z.looseObject({
|
||||
metricTime: z.string(),
|
||||
data: z.looseObject({ wan: unifiSmWanMetricsSchema.optional() }),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export type UnifiOsClient = z.infer<typeof unifiOsClientSchema>;
|
||||
export type UnifiOsDevice = z.infer<typeof unifiOsDeviceSchema>;
|
||||
export type UnifiOsSite = z.infer<typeof unifiOsSiteSchema>;
|
||||
export type UnifiOsDeviceStatistics = z.infer<typeof unifiOsDeviceStatisticsSchema>;
|
||||
|
||||
export interface UnifiOsClientCounts {
|
||||
total: number;
|
||||
wired: number;
|
||||
wireless: number;
|
||||
vpn: number;
|
||||
guests: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsDeviceCounts {
|
||||
total: number;
|
||||
online: number;
|
||||
offline: number;
|
||||
firmwareUpdatable: number;
|
||||
}
|
||||
|
||||
export interface UnifiOsGateway {
|
||||
name: string;
|
||||
model: string;
|
||||
macAddress: string;
|
||||
uptimeSec: number | null;
|
||||
cpuPct: number | null;
|
||||
memoryPct: number | null;
|
||||
}
|
||||
|
||||
export interface UnifiOsIspMetrics {
|
||||
name: string;
|
||||
externalIp: string | null;
|
||||
latencyMs: number;
|
||||
maxLatencyMs: number;
|
||||
packetLossPct: number;
|
||||
uptimePct: number;
|
||||
/** Horodatage de la mesure : la donnée cloud accuse ~27 min de retard. */
|
||||
measuredAt: Date;
|
||||
}
|
||||
|
||||
export interface UnifiOsSummary {
|
||||
site: {
|
||||
id: string;
|
||||
name: string;
|
||||
applicationVersion: string;
|
||||
};
|
||||
wan: {
|
||||
state: "online" | "offline" | "unknown";
|
||||
throughput: { txBps: number; rxBps: number } | null;
|
||||
isp: UnifiOsIspMetrics | null;
|
||||
};
|
||||
clients: UnifiOsClientCounts;
|
||||
devices: UnifiOsDeviceCounts;
|
||||
gateway: UnifiOsGateway | null;
|
||||
}
|
||||
|
||||
export const parseUnifiOsAsync = 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* L'API locale renvoie `aa:bb:cc:11:22:33`, le cloud peut renvoyer une autre
|
||||
* casse ou d'autres séparateurs. La comparaison se fait sur une forme canonique.
|
||||
*/
|
||||
export const normalizeMacAddress = (mac: string): string => mac.toLowerCase().replace(/[^0-9a-f]/g, "");
|
||||
|
||||
const VPN_CLIENT_TYPES = new Set(["VPN", "TELEPORT"]);
|
||||
|
||||
export const countClients = (clients: UnifiOsClient[]): UnifiOsClientCounts => {
|
||||
const counts: UnifiOsClientCounts = { total: 0, wired: 0, wireless: 0, vpn: 0, guests: 0 };
|
||||
|
||||
for (const client of clients) {
|
||||
counts.total += 1;
|
||||
|
||||
if (client.type === "WIRED") counts.wired += 1;
|
||||
else if (client.type === "WIRELESS") counts.wireless += 1;
|
||||
else if (VPN_CLIENT_TYPES.has(client.type)) counts.vpn += 1;
|
||||
|
||||
if (client.access?.type === "GUEST") counts.guests += 1;
|
||||
}
|
||||
|
||||
return counts;
|
||||
};
|
||||
|
||||
export const summarizeDevices = (devices: UnifiOsDevice[]): UnifiOsDeviceCounts => {
|
||||
const online = devices.filter((device) => device.state === "ONLINE").length;
|
||||
|
||||
return {
|
||||
total: devices.length,
|
||||
online,
|
||||
// Tout ce qui n'est pas ONLINE est indisponible du point de vue du dashboard,
|
||||
// qu'il s'agisse d'OFFLINE, d'UPDATING ou d'un état futur inconnu.
|
||||
offline: devices.length - online,
|
||||
firmwareUpdatable: devices.filter((device) => device.firmwareUpdatable === true).length,
|
||||
};
|
||||
};
|
||||
|
||||
const GATEWAY_MODEL_HINT = /gateway|udm|uxg|ucg|usg/i;
|
||||
|
||||
export const findGateway = (devices: UnifiOsDevice[]): UnifiOsDevice | null =>
|
||||
devices.find((device) => device.features.includes("gateway")) ??
|
||||
devices.find((device) => GATEWAY_MODEL_HINT.test(device.model)) ??
|
||||
null;
|
||||
|
||||
/**
|
||||
* Une clé Site Manager voit tous les sites du compte, y compris ceux d'autres
|
||||
* consoles. Sans correspondance stricte sur le MAC du gateway, on afficherait
|
||||
* la latence d'un autre réseau. Renvoyer null est le seul comportement sûr.
|
||||
*/
|
||||
export const matchCloudSite = <TSite extends { meta: { gatewayMac?: string } }>(
|
||||
gatewayMac: string | null,
|
||||
sites: TSite[],
|
||||
): TSite | null => {
|
||||
if (gatewayMac === null) return null;
|
||||
|
||||
const normalized = normalizeMacAddress(gatewayMac);
|
||||
|
||||
return (
|
||||
sites.find(
|
||||
(site) => site.meta.gatewayMac !== undefined && normalizeMacAddress(site.meta.gatewayMac) === normalized,
|
||||
) ?? null
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user