feat: add Synology integration support for health/system monitoring (#6121)
This commit is contained in:
1 parent
260b167134
commit
65ba866239
35 files changed
+2263
-98
No files matched your search
@@ -45,6 +45,7 @@ import { ProxmoxIntegration } from "../proxmox/proxmox-integration";
|
||||
import { SeerrIntegration } from "../seerr/seerr-integration";
|
||||
import { SpeedtestTrackerIntegration } from "../speedtest-tracker/speedtest-tracker-integration";
|
||||
import { TracearrIntegration } from "../tracearr/tracearr-integration";
|
||||
import { SynologyIntegration } from "../synology/synology-integration";
|
||||
import { TrueNasIntegration } from "../truenas/truenas-integration";
|
||||
import { UmamiIntegration } from "../umami/umami-integration";
|
||||
import { UptimeKumaIntegration } from "../uptime-kuma/uptime-kuma-integration";
|
||||
@@ -110,6 +111,7 @@ export const integrationCreators = {
|
||||
gotify: GotifyIntegration,
|
||||
mock: MockIntegration,
|
||||
truenas: TrueNasIntegration,
|
||||
synology: SynologyIntegration,
|
||||
unraid: UnraidIntegration,
|
||||
coolify: CoolifyIntegration,
|
||||
tracearr: TracearrIntegration,
|
||||
|
||||
@@ -29,6 +29,7 @@ export { PiHoleIntegrationV6 } from "./pi-hole/v6/pi-hole-integration-v6";
|
||||
export { PlexIntegration } from "./plex/plex-integration";
|
||||
export { ProwlarrIntegration } from "./prowlarr/prowlarr-integration";
|
||||
export { TrueNasIntegration } from "./truenas/truenas-integration";
|
||||
export { SynologyIntegration } from "./synology/synology-integration";
|
||||
export { UnraidIntegration } from "./unraid/unraid-integration";
|
||||
export { OPNsenseIntegration } from "./opnsense/opnsense-integration";
|
||||
export { ICalIntegration } from "./ical/ical-integration";
|
||||
|
||||
@@ -0,0 +1,411 @@
|
||||
import type { Headers } from "undici";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import type { SessionStore } from "../base/session-store";
|
||||
import {
|
||||
AUTH_API_NAME,
|
||||
INFO_API_NAME,
|
||||
REQUEST_TIMEOUT_MS,
|
||||
SESSION_ERROR_CODES,
|
||||
synologyApiInfoResponseSchema,
|
||||
synologyAuthResponseSchema,
|
||||
synologyEnvelopeSchema,
|
||||
synologyLegacyStorageDataSchema,
|
||||
synologySmartInfoDataSchema,
|
||||
synologyStorageLoadInfoDataSchema,
|
||||
synologyStorageV2DataSchema,
|
||||
synologySystemInfoDataSchema,
|
||||
synologySystemStatusDataSchema,
|
||||
synologyUpgradeCheckDataSchema,
|
||||
synologyUpgradeStatusDataSchema,
|
||||
synologyUtilizationDataSchema,
|
||||
type SynologyDiskRecord,
|
||||
type SynologyVolumeRecord,
|
||||
} from "./synology-types";
|
||||
|
||||
const logger = createLogger({ module: "synologyClient" });
|
||||
|
||||
const AUTH_SESSION_NAME = "DownloadStation";
|
||||
|
||||
type ApiDefinition = {
|
||||
cgiPath: string;
|
||||
maxVersion: number;
|
||||
};
|
||||
|
||||
type StoredSession = {
|
||||
cookieHeader: string;
|
||||
};
|
||||
|
||||
type SynologyClientOptions = {
|
||||
integrationId: string;
|
||||
baseUrl: string;
|
||||
username: string;
|
||||
password: string;
|
||||
sessionStore: SessionStore<StoredSession>;
|
||||
};
|
||||
|
||||
const apiDefinitionCache = new Map<string, ApiDefinition>();
|
||||
|
||||
export class SynologyClient {
|
||||
private readonly integrationId: string;
|
||||
private readonly baseUrl: string;
|
||||
private readonly username: string;
|
||||
private readonly password: string;
|
||||
private readonly sessionStore: SessionStore<StoredSession>;
|
||||
|
||||
constructor(options: SynologyClientOptions) {
|
||||
this.integrationId = options.integrationId;
|
||||
this.baseUrl = options.baseUrl.replace(/\/$/, "");
|
||||
this.username = options.username;
|
||||
this.password = options.password;
|
||||
this.sessionStore = options.sessionStore;
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync() {
|
||||
const response = await this.requestAsync("SYNO.Core.System", { method: "info" });
|
||||
return synologySystemInfoDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getUtilizationAsync() {
|
||||
const response = await this.requestAsync("SYNO.Core.System.Utilization", { method: "get" });
|
||||
return synologyUtilizationDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getStorageVolumesAsync(): Promise<SynologyVolumeRecord[]> {
|
||||
try {
|
||||
const storageV2Response = await this.requestAsync("SYNO.Core.System", {
|
||||
method: "info",
|
||||
extraParams: { type: "storage_v2" },
|
||||
});
|
||||
const storageV2 = synologyStorageV2DataSchema.parse(storageV2Response.data);
|
||||
const volumesFromV2 = this.mapStorageV2Volumes(storageV2);
|
||||
if (volumesFromV2.length > 0) {
|
||||
return volumesFromV2;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.debug("storage_v2 unavailable, falling back to legacy storage info", {
|
||||
integrationId: this.integrationId,
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
const legacyResponse = await this.requestAsync("SYNO.Core.System", {
|
||||
method: "info",
|
||||
extraParams: { type: "storage" },
|
||||
});
|
||||
const legacyStorage = synologyLegacyStorageDataSchema.parse(legacyResponse.data);
|
||||
return this.mapLegacyVolumes(legacyStorage.vol_info ?? []);
|
||||
}
|
||||
|
||||
public async getStorageLoadInfoAsync() {
|
||||
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Storage", { method: "load_info" });
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return synologyStorageLoadInfoDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getSmartInfoAsync() {
|
||||
const response = await this.requestOptionalAsync("SYNO.Storage.CGI.Smart", { method: "get_smart_info" });
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return synologySmartInfoDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getSystemStatusAsync() {
|
||||
const response = await this.requestOptionalAsync("SYNO.Core.System.Status", { method: "get" });
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return synologySystemStatusDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getUpgradeStatusAsync() {
|
||||
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade", { method: "status" });
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return synologyUpgradeStatusDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async getUpgradeCheckAsync() {
|
||||
const response = await this.requestOptionalAsync("SYNO.Core.Upgrade.Server", { method: "check" });
|
||||
if (!response) {
|
||||
return null;
|
||||
}
|
||||
return synologyUpgradeCheckDataSchema.parse(response.data);
|
||||
}
|
||||
|
||||
public async testConnectionAsync() {
|
||||
await this.getSystemInfoAsync();
|
||||
}
|
||||
|
||||
private mapLegacyVolumes(volumes: z.infer<typeof synologyLegacyStorageDataSchema>["vol_info"]) {
|
||||
return (volumes ?? [])
|
||||
.map((volume) => this.mapVolumeRecord(volume.name, volume.used_size, volume.total_size, volume.status))
|
||||
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
|
||||
}
|
||||
|
||||
private mapStorageV2Volumes(storage: z.infer<typeof synologyStorageV2DataSchema>) {
|
||||
return (storage.volumes ?? [])
|
||||
.map((volume) => {
|
||||
const name = volume.id ?? volume.vol_path ?? volume.display_name;
|
||||
if (!name) {
|
||||
return null;
|
||||
}
|
||||
return this.mapVolumeRecord(
|
||||
name,
|
||||
volume.size?.used,
|
||||
volume.size?.total,
|
||||
volume.status,
|
||||
volume.display_name,
|
||||
);
|
||||
})
|
||||
.filter((volume): volume is SynologyVolumeRecord => volume !== null);
|
||||
}
|
||||
|
||||
private mapVolumeRecord(
|
||||
name: string,
|
||||
usedValue: number | string | undefined,
|
||||
totalValue: number | string | undefined,
|
||||
status: string | undefined,
|
||||
displayName?: string,
|
||||
): SynologyVolumeRecord | null {
|
||||
const usedBytes = parseNumericValue(usedValue);
|
||||
const totalBytes = parseNumericValue(totalValue);
|
||||
if (usedBytes === null || totalBytes === null || totalBytes <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
displayName: displayName && displayName !== name ? displayName : undefined,
|
||||
usedBytes,
|
||||
totalBytes,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
public mapDisksFromLoadInfo(loadInfo: z.infer<typeof synologyStorageLoadInfoDataSchema>): SynologyDiskRecord[] {
|
||||
return (loadInfo.disks ?? []).map((disk) => ({
|
||||
identifier: disk.id ?? disk.name ?? disk.display_name ?? "unknown-disk",
|
||||
name: disk.display_name ?? disk.name ?? disk.id ?? "unknown-disk",
|
||||
status: disk.smart_status ?? disk.status,
|
||||
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
|
||||
volumeName: disk.volume_id ?? disk.vol_path,
|
||||
}));
|
||||
}
|
||||
|
||||
public mapDisksFromSmartInfo(smartInfo: z.infer<typeof synologySmartInfoDataSchema>): SynologyDiskRecord[] {
|
||||
const disks = smartInfo.disks ?? smartInfo.items ?? [];
|
||||
return disks.map((disk) => ({
|
||||
identifier: disk.disk_id ?? disk.id ?? disk.name ?? "unknown-disk",
|
||||
name: disk.name ?? disk.disk_id ?? disk.id ?? "unknown-disk",
|
||||
status: disk.overall_status ?? disk.status,
|
||||
temperature: parseOptionalNumericValue(disk.temp ?? disk.temperature),
|
||||
volumeName: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
private async requestOptionalAsync(
|
||||
apiName: string,
|
||||
options: { method: string; extraParams?: Record<string, string> },
|
||||
) {
|
||||
try {
|
||||
return await this.requestAsync(apiName, options);
|
||||
} catch (error) {
|
||||
logger.debug("Optional Synology API request failed", {
|
||||
integrationId: this.integrationId,
|
||||
apiName,
|
||||
method: options.method,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async requestAsync(
|
||||
apiName: string,
|
||||
options: { method: string; extraParams?: Record<string, string> },
|
||||
): Promise<z.infer<typeof synologyEnvelopeSchema>> {
|
||||
return await this.withAuthenticatedSessionAsync(async (session) => {
|
||||
const apiDefinition = await this.getApiDefinitionAsync(apiName);
|
||||
const requestUrl = new URL(`${this.baseUrl}/webapi/${apiDefinition.cgiPath}`);
|
||||
requestUrl.searchParams.set("api", apiName);
|
||||
requestUrl.searchParams.set("version", String(apiDefinition.maxVersion));
|
||||
requestUrl.searchParams.set("method", options.method);
|
||||
for (const [key, value] of Object.entries(options.extraParams ?? {})) {
|
||||
requestUrl.searchParams.set(key, value);
|
||||
}
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(requestUrl, {
|
||||
headers: {
|
||||
Cookie: session.cookieHeader,
|
||||
"User-Agent": "Homarr",
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const payload = synologyEnvelopeSchema.parse(await response.json());
|
||||
if (!payload.success) {
|
||||
const errorCode = payload.error?.code;
|
||||
if (errorCode !== undefined && SESSION_ERROR_CODES.has(errorCode)) {
|
||||
throw new SynologySessionError(errorCode);
|
||||
}
|
||||
throw new ResponseError({
|
||||
status: 502,
|
||||
url: requestUrl.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
return payload;
|
||||
});
|
||||
}
|
||||
|
||||
private async withAuthenticatedSessionAsync<T>(callback: (session: StoredSession) => Promise<T>): Promise<T> {
|
||||
const storedSession = await this.sessionStore.getAsync();
|
||||
if (storedSession) {
|
||||
try {
|
||||
return await callback(storedSession);
|
||||
} catch (error) {
|
||||
if (!(error instanceof SynologySessionError)) {
|
||||
throw error;
|
||||
}
|
||||
logger.debug("Synology session expired, re-authenticating", {
|
||||
integrationId: this.integrationId,
|
||||
errorCode: error.errorCode,
|
||||
});
|
||||
await this.sessionStore.clearAsync();
|
||||
}
|
||||
}
|
||||
|
||||
const session = await this.loginAsync();
|
||||
await this.sessionStore.setAsync(session);
|
||||
return await callback(session);
|
||||
}
|
||||
|
||||
private async loginAsync(): Promise<StoredSession> {
|
||||
const authDefinition = await this.getApiDefinitionAsync(AUTH_API_NAME);
|
||||
const loginUrl = new URL(`${this.baseUrl}/webapi/${authDefinition.cgiPath}`);
|
||||
loginUrl.searchParams.set("api", AUTH_API_NAME);
|
||||
loginUrl.searchParams.set("version", String(authDefinition.maxVersion));
|
||||
loginUrl.searchParams.set("method", "login");
|
||||
const safeLoginUrl = loginUrl.toString();
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(loginUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"User-Agent": "Homarr",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
account: this.username,
|
||||
passwd: this.password,
|
||||
session: AUTH_SESSION_NAME,
|
||||
format: "cookie",
|
||||
}).toString(),
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError({ status: response.status, url: safeLoginUrl });
|
||||
}
|
||||
|
||||
const payload = synologyAuthResponseSchema.parse(await response.json());
|
||||
if (!payload.success) {
|
||||
throw new ResponseError({
|
||||
status: 401,
|
||||
url: safeLoginUrl,
|
||||
});
|
||||
}
|
||||
|
||||
const cookieHeader = extractCookieHeader(response.headers);
|
||||
if (!cookieHeader) {
|
||||
throw new ResponseError({
|
||||
status: 401,
|
||||
url: safeLoginUrl,
|
||||
});
|
||||
}
|
||||
|
||||
return { cookieHeader };
|
||||
}
|
||||
|
||||
private async getApiDefinitionAsync(apiName: string): Promise<ApiDefinition> {
|
||||
const cacheKey = `${this.integrationId}:${apiName}`;
|
||||
const cachedDefinition = apiDefinitionCache.get(cacheKey);
|
||||
if (cachedDefinition) {
|
||||
return cachedDefinition;
|
||||
}
|
||||
|
||||
const infoUrl = new URL(`${this.baseUrl}/webapi/query.cgi`);
|
||||
infoUrl.searchParams.set("api", INFO_API_NAME);
|
||||
infoUrl.searchParams.set("version", "1");
|
||||
infoUrl.searchParams.set("method", "query");
|
||||
infoUrl.searchParams.set("query", apiName);
|
||||
|
||||
const response = await fetchWithTrustedCertificatesAsync(infoUrl, {
|
||||
headers: {
|
||||
"User-Agent": "Homarr",
|
||||
},
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new ResponseError(response);
|
||||
}
|
||||
|
||||
const payload = synologyApiInfoResponseSchema.parse(await response.json());
|
||||
const apiDefinition = payload.data?.[apiName];
|
||||
if (!payload.success || !apiDefinition) {
|
||||
throw new ResponseError({
|
||||
status: 502,
|
||||
url: infoUrl.toString(),
|
||||
});
|
||||
}
|
||||
|
||||
const definition = {
|
||||
cgiPath: apiDefinition.path,
|
||||
maxVersion: apiDefinition.maxVersion,
|
||||
};
|
||||
apiDefinitionCache.set(cacheKey, definition);
|
||||
return definition;
|
||||
}
|
||||
}
|
||||
|
||||
class SynologySessionError extends Error {
|
||||
constructor(public readonly errorCode: number) {
|
||||
super(`Synology session error ${String(errorCode)}`);
|
||||
this.name = SynologySessionError.name;
|
||||
}
|
||||
}
|
||||
|
||||
function extractCookieHeader(headers: Headers): string | null {
|
||||
const cookies = headers.getSetCookie();
|
||||
if (cookies.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return cookies.map((cookie) => cookie.split(";")[0]).join("; ");
|
||||
}
|
||||
|
||||
function parseNumericValue(value: number | string | undefined): number | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalNumericValue(value: number | string | undefined): number | null {
|
||||
const parsed = parseNumericValue(value);
|
||||
return parsed === null ? null : parsed;
|
||||
}
|
||||
@@ -0,0 +1,323 @@
|
||||
import type { z } from "zod";
|
||||
|
||||
import { createLogger } from "@homarr/core/infrastructure/logs";
|
||||
|
||||
import { HandleIntegrationErrors } from "../base/errors/decorator";
|
||||
import type { IntegrationTestingInput } from "../base/integration";
|
||||
import { Integration } from "../base/integration";
|
||||
import { createSessionStore } from "../base/session-store";
|
||||
import type { TestingResult } from "../base/test-connection/test-connection-service";
|
||||
import type { ISystemHealthMonitoringIntegration } from "../interfaces/health-monitoring/health-monitoring-integration";
|
||||
import type { SystemHealthMonitoring } from "../interfaces/health-monitoring/health-monitoring-types";
|
||||
import { SynologyClient } from "./synology-client";
|
||||
import type { SynologyDiskRecord, SynologyVolumeRecord } from "./synology-types";
|
||||
import type { synologySystemInfoDataSchema, synologyUtilizationDataSchema } from "./synology-types";
|
||||
|
||||
const logger = createLogger({ module: "synologyIntegration" });
|
||||
|
||||
type StoredSession = {
|
||||
cookieHeader: string;
|
||||
};
|
||||
|
||||
@HandleIntegrationErrors([])
|
||||
export class SynologyIntegration extends Integration implements ISystemHealthMonitoringIntegration {
|
||||
private readonly sessionStore = createSessionStore<StoredSession>(this.integration);
|
||||
private client?: SynologyClient;
|
||||
|
||||
protected async testingAsync(_input: IntegrationTestingInput): Promise<TestingResult> {
|
||||
await this.getClient().testConnectionAsync();
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
public async getSystemInfoAsync(): Promise<SystemHealthMonitoring> {
|
||||
const client = this.getClient();
|
||||
|
||||
const [systemInfo, utilization, volumes] = await Promise.all([
|
||||
client.getSystemInfoAsync(),
|
||||
client.getUtilizationAsync(),
|
||||
client.getStorageVolumesAsync(),
|
||||
]);
|
||||
|
||||
const [storageLoadInfoResult, smartInfoResult, systemStatusResult, upgradeStatusResult, upgradeCheckResult] =
|
||||
await Promise.allSettled([
|
||||
client.getStorageLoadInfoAsync(),
|
||||
client.getSmartInfoAsync(),
|
||||
client.getSystemStatusAsync(),
|
||||
client.getUpgradeStatusAsync(),
|
||||
client.getUpgradeCheckAsync(),
|
||||
]);
|
||||
|
||||
const storageLoadInfo = getSettledValue(storageLoadInfoResult);
|
||||
const smartInfo = getSettledValue(smartInfoResult);
|
||||
const systemStatus = getSettledValue(systemStatusResult);
|
||||
const upgradeStatus = getSettledValue(upgradeStatusResult);
|
||||
const upgradeCheck = getSettledValue(upgradeCheckResult);
|
||||
|
||||
const diskRecords = mergeDiskRecords(
|
||||
storageLoadInfo ? client.mapDisksFromLoadInfo(storageLoadInfo) : [],
|
||||
smartInfo ? client.mapDisksFromSmartInfo(smartInfo) : [],
|
||||
);
|
||||
|
||||
const memory = mapMemoryBytes(utilization);
|
||||
const network = mapNetworkTraffic(utilization);
|
||||
const loadAverage = mapLoadAverage(utilization);
|
||||
const smart = mapVolumeSmartEntries(volumes, diskRecords);
|
||||
const rebootRequired = Boolean(systemStatus?.reboot_required ?? upgradeStatus?.reboot_required ?? false);
|
||||
const availablePkgUpdates = upgradeCheck?.update_count ?? (upgradeCheck?.available ? 1 : 0);
|
||||
|
||||
return {
|
||||
version: mapVersion(systemInfo),
|
||||
cpuModelName: systemInfo.model ?? "Unknown CPU",
|
||||
cpuUtilization: mapCpuUtilization(utilization),
|
||||
memUsedInBytes: memory.usedBytes,
|
||||
memAvailableInBytes: memory.availableBytes,
|
||||
uptime: mapUptimeSeconds(systemInfo),
|
||||
network,
|
||||
loadAverage,
|
||||
rebootRequired,
|
||||
availablePkgUpdates,
|
||||
cpuTemp: mapCpuTemperature(systemInfo),
|
||||
fileSystem: mapFileSystemEntries(volumes),
|
||||
smart,
|
||||
gpu: [],
|
||||
};
|
||||
}
|
||||
|
||||
public async listStorageVolumesAsync(): Promise<{ value: string; label: string }[]> {
|
||||
const volumes = await this.getClient().getStorageVolumesAsync();
|
||||
const { id: integrationId, name: integrationName } = this.integration;
|
||||
return volumes.map((volume) => ({
|
||||
value: `${integrationId}:${volume.name}`,
|
||||
label: `${volume.displayName ?? volume.name} (${integrationName})`,
|
||||
}));
|
||||
}
|
||||
|
||||
private getClient(): SynologyClient {
|
||||
return (this.client ??= new SynologyClient({
|
||||
integrationId: this.integration.id,
|
||||
baseUrl: this.integration.url,
|
||||
username: this.getSecretValue("username"),
|
||||
password: this.getSecretValue("password"),
|
||||
sessionStore: this.sessionStore,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
function getSettledValue<T>(result: PromiseSettledResult<T>): T | null {
|
||||
if (result.status === "rejected") {
|
||||
logger.debug("Optional Synology API call failed", { reason: result.reason });
|
||||
return null;
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
function mapVersion(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): string {
|
||||
if (systemInfo.version_string) {
|
||||
return systemInfo.version_string;
|
||||
}
|
||||
if (systemInfo.firmware_ver) {
|
||||
return systemInfo.firmware_ver;
|
||||
}
|
||||
if (systemInfo.version !== undefined) {
|
||||
return String(systemInfo.version);
|
||||
}
|
||||
return "Unknown";
|
||||
}
|
||||
|
||||
function mapCpuUtilization(utilization: z.infer<typeof synologyUtilizationDataSchema>): number {
|
||||
const userLoad = parseNumericValue(utilization.cpu?.user_load) ?? 0;
|
||||
const systemLoad = parseNumericValue(utilization.cpu?.system_load) ?? 0;
|
||||
const totalLoad = userLoad + systemLoad;
|
||||
return Math.min(totalLoad, 100);
|
||||
}
|
||||
|
||||
function mapMemoryBytes(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
||||
const memory = utilization.memory;
|
||||
const totalRealKib = parseNumericValue(memory?.total_real);
|
||||
const realUsagePercent = parseNumericValue(memory?.real_usage);
|
||||
|
||||
// DSM utilization API reports memory sizes in KiB, not bytes.
|
||||
if (totalRealKib !== null && realUsagePercent !== null) {
|
||||
const totalBytes = totalRealKib * 1024;
|
||||
const usedBytes = Math.round((totalBytes * realUsagePercent) / 100);
|
||||
return {
|
||||
usedBytes,
|
||||
availableBytes: Math.max(totalBytes - usedBytes, 0),
|
||||
};
|
||||
}
|
||||
|
||||
const availableKib = parseNumericValue(memory?.avail_real) ?? 0;
|
||||
const cachedKib = parseNumericValue(memory?.cached) ?? 0;
|
||||
const totalKib = totalRealKib ?? availableKib + cachedKib;
|
||||
const usedKib = Math.max(totalKib - availableKib, 0);
|
||||
|
||||
return {
|
||||
usedBytes: usedKib * 1024,
|
||||
availableBytes: availableKib * 1024,
|
||||
};
|
||||
}
|
||||
|
||||
function mapNetworkTraffic(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
||||
const totalNetwork = utilization.network?.find((entry) => entry.device === "total");
|
||||
if (!totalNetwork) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const received = parseNumericValue(totalNetwork.rx);
|
||||
const transmitted = parseNumericValue(totalNetwork.tx);
|
||||
if (received === null || transmitted === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
up: transmitted,
|
||||
down: received,
|
||||
};
|
||||
}
|
||||
|
||||
function mapLoadAverage(utilization: z.infer<typeof synologyUtilizationDataSchema>) {
|
||||
const oneMinute = parseNumericValue(utilization.cpu?.["1min_load"]);
|
||||
const fiveMinute = parseNumericValue(utilization.cpu?.["5min_load"]);
|
||||
const fifteenMinute = parseNumericValue(utilization.cpu?.["15min_load"]);
|
||||
|
||||
if (oneMinute === null && fiveMinute === null && fifteenMinute === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
"1min": oneMinute ?? 0,
|
||||
"5min": fiveMinute ?? 0,
|
||||
"15min": fifteenMinute ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
function mapUptimeSeconds(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number {
|
||||
if (typeof systemInfo.uptime === "number") {
|
||||
return systemInfo.uptime;
|
||||
}
|
||||
|
||||
if (!systemInfo.up_time) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parts = systemInfo.up_time.split(":").map((part) => Number.parseInt(part, 10));
|
||||
if (parts.length !== 3 || parts.some((part) => Number.isNaN(part))) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const [hours = 0, minutes = 0, seconds = 0] = parts;
|
||||
return hours * 3600 + minutes * 60 + seconds;
|
||||
}
|
||||
|
||||
function mapCpuTemperature(systemInfo: z.infer<typeof synologySystemInfoDataSchema>): number | undefined {
|
||||
const temperature = parseNumericValue(systemInfo.sys_temp ?? systemInfo.temperature);
|
||||
return temperature === null ? undefined : temperature;
|
||||
}
|
||||
|
||||
function mapFileSystemEntries(volumes: SynologyVolumeRecord[]) {
|
||||
return volumes.map((volume) => {
|
||||
const availableBytes = Math.max(volume.totalBytes - volume.usedBytes, 0);
|
||||
return {
|
||||
deviceName: volume.name,
|
||||
used: `${volume.usedBytes}`,
|
||||
available: `${availableBytes}`,
|
||||
percentage: (volume.usedBytes / volume.totalBytes) * 100,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapVolumeSmartEntries(volumes: SynologyVolumeRecord[], diskRecords: SynologyDiskRecord[]) {
|
||||
return volumes.map((volume) => {
|
||||
const matchingDisks = diskRecords.filter((disk) => diskBelongsToVolume(disk, volume.name));
|
||||
const temperatures = matchingDisks
|
||||
.map((disk) => disk.temperature)
|
||||
.filter((temperature): temperature is number => temperature !== null);
|
||||
const overallStatus = volume.status ?? pickWorstDiskStatus(matchingDisks);
|
||||
|
||||
return {
|
||||
deviceName: volume.name,
|
||||
healthy: isHealthyStatus(overallStatus),
|
||||
overallStatus,
|
||||
temperature: temperatures.length > 0 ? Math.max(...temperatures) : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mergeDiskRecords(
|
||||
loadInfoDisks: SynologyDiskRecord[],
|
||||
smartInfoDisks: SynologyDiskRecord[],
|
||||
): SynologyDiskRecord[] {
|
||||
const diskMap = new Map<string, SynologyDiskRecord>();
|
||||
|
||||
for (const disk of loadInfoDisks) {
|
||||
diskMap.set(disk.identifier, disk);
|
||||
}
|
||||
|
||||
for (const disk of smartInfoDisks) {
|
||||
const existingDisk = diskMap.get(disk.identifier);
|
||||
diskMap.set(disk.identifier, {
|
||||
identifier: disk.identifier,
|
||||
name: existingDisk?.name ?? disk.name,
|
||||
status: disk.status ?? existingDisk?.status,
|
||||
temperature: disk.temperature ?? existingDisk?.temperature ?? null,
|
||||
volumeName: existingDisk?.volumeName ?? disk.volumeName,
|
||||
});
|
||||
}
|
||||
|
||||
return [...diskMap.values()];
|
||||
}
|
||||
|
||||
function diskBelongsToVolume(disk: SynologyDiskRecord, volumeName: string): boolean {
|
||||
if (!disk.volumeName) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
disk.volumeName === volumeName ||
|
||||
disk.volumeName.endsWith(volumeName) ||
|
||||
volumeName.endsWith(disk.volumeName) ||
|
||||
disk.volumeName.includes(volumeName) ||
|
||||
volumeName.includes(disk.volumeName)
|
||||
);
|
||||
}
|
||||
|
||||
const UNHEALTHY_STATUS_TOKENS = ["critical", "error", "crash", "failed", "fail", "degraded", "warning"] as const;
|
||||
|
||||
function getStatusSeverityIndex(status: string): number {
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
const index = UNHEALTHY_STATUS_TOKENS.findIndex((token) => normalizedStatus.includes(token));
|
||||
return index === -1 ? UNHEALTHY_STATUS_TOKENS.length : index;
|
||||
}
|
||||
|
||||
function pickWorstDiskStatus(disks: SynologyDiskRecord[]): string {
|
||||
const statuses = disks
|
||||
.map((disk) => disk.status)
|
||||
.filter((status): status is string => status !== undefined && status.length > 0);
|
||||
|
||||
if (statuses.length === 0) {
|
||||
return "normal";
|
||||
}
|
||||
|
||||
return statuses.reduce((worstStatus, currentStatus) =>
|
||||
getStatusSeverityIndex(currentStatus) < getStatusSeverityIndex(worstStatus) ? currentStatus : worstStatus,
|
||||
);
|
||||
}
|
||||
|
||||
function isHealthyStatus(status: string | undefined): boolean {
|
||||
if (!status) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const normalizedStatus = status.toLowerCase();
|
||||
return !["crash", "degraded", "fail", "failed", "warning", "error", "critical"].some((unhealthyToken) =>
|
||||
normalizedStatus.includes(unhealthyToken),
|
||||
);
|
||||
}
|
||||
|
||||
function parseNumericValue(value: number | string | undefined): number | null {
|
||||
if (value === undefined) {
|
||||
return null;
|
||||
}
|
||||
const parsed = typeof value === "number" ? value : Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = 5000;
|
||||
|
||||
export const AUTH_API_NAME = "SYNO.API.Auth";
|
||||
export const INFO_API_NAME = "SYNO.API.Info";
|
||||
|
||||
export const SESSION_ERROR_CODES = new Set([106, 107, 119]);
|
||||
|
||||
export const synologyEnvelopeSchema = z.object({
|
||||
success: z.boolean(),
|
||||
data: z.unknown().optional(),
|
||||
error: z
|
||||
.object({
|
||||
code: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
export const synologyApiDefinitionSchema = z.object({
|
||||
path: z.string(),
|
||||
maxVersion: z.number(),
|
||||
});
|
||||
|
||||
export const synologyApiInfoResponseSchema = synologyEnvelopeSchema.extend({
|
||||
data: z.record(z.string(), synologyApiDefinitionSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyAuthResponseSchema = synologyEnvelopeSchema.extend({
|
||||
data: z
|
||||
.object({
|
||||
sid: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
const numericString = z.union([z.number(), z.string()]);
|
||||
|
||||
export const synologySystemInfoDataSchema = z
|
||||
.object({
|
||||
up_time: z.string().optional(),
|
||||
uptime: z.number().optional(),
|
||||
version_string: z.string().optional(),
|
||||
firmware_ver: z.string().optional(),
|
||||
version: z.union([z.string(), z.number()]).optional(),
|
||||
model: z.string().optional(),
|
||||
sys_temp: numericString.optional(),
|
||||
temperature: numericString.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologyVolumeInfoSchema = z.object({
|
||||
name: z.string(),
|
||||
used_size: numericString.optional(),
|
||||
total_size: numericString.optional(),
|
||||
status: z.string().optional(),
|
||||
vol_path: z.string().optional(),
|
||||
});
|
||||
|
||||
export const synologyLegacyStorageDataSchema = z.object({
|
||||
vol_info: z.array(synologyVolumeInfoSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyStorageV2VolumeSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
vol_path: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
size: z
|
||||
.object({
|
||||
total: z.union([z.number(), z.string()]).optional(),
|
||||
used: z.union([z.number(), z.string()]).optional(),
|
||||
})
|
||||
.optional(),
|
||||
status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologyStorageV2DataSchema = z.object({
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationCpuSchema = z.object({
|
||||
user_load: numericString.optional(),
|
||||
system_load: numericString.optional(),
|
||||
other_load: numericString.optional(),
|
||||
"1min_load": numericString.optional(),
|
||||
"5min_load": numericString.optional(),
|
||||
"15min_load": numericString.optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationMemorySchema = z.object({
|
||||
real_usage: numericString.optional(),
|
||||
avail_real: numericString.optional(),
|
||||
cached: numericString.optional(),
|
||||
total_real: numericString.optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationNetworkSchema = z.object({
|
||||
device: z.string(),
|
||||
rx: numericString.optional(),
|
||||
tx: numericString.optional(),
|
||||
});
|
||||
|
||||
export const synologyUtilizationDataSchema = z.object({
|
||||
cpu: synologyUtilizationCpuSchema.optional(),
|
||||
memory: synologyUtilizationMemorySchema.optional(),
|
||||
network: z.array(synologyUtilizationNetworkSchema).optional(),
|
||||
});
|
||||
|
||||
export const synologyStorageDiskSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
display_name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
temp: numericString.optional(),
|
||||
temperature: numericString.optional(),
|
||||
smart_status: z.string().optional(),
|
||||
volume_id: z.string().optional(),
|
||||
vol_path: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologyStorageLoadInfoDataSchema = z
|
||||
.object({
|
||||
disks: z.array(synologyStorageDiskSchema).optional(),
|
||||
volumes: z.array(synologyStorageV2VolumeSchema).optional(),
|
||||
overview_data: z
|
||||
.object({
|
||||
volumes: z.array(synologyVolumeInfoSchema).optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologySmartDiskSchema = z
|
||||
.object({
|
||||
id: z.string().optional(),
|
||||
disk_id: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
status: z.string().optional(),
|
||||
temp: numericString.optional(),
|
||||
temperature: numericString.optional(),
|
||||
overall_status: z.string().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologySmartInfoDataSchema = z
|
||||
.object({
|
||||
disks: z.array(synologySmartDiskSchema).optional(),
|
||||
items: z.array(synologySmartDiskSchema).optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologySystemStatusDataSchema = z
|
||||
.object({
|
||||
reboot_required: z.boolean().optional(),
|
||||
system_need_repair: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologyUpgradeStatusDataSchema = z
|
||||
.object({
|
||||
reboot_required: z.boolean().optional(),
|
||||
upgrade_ready: z.boolean().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export const synologyUpgradeCheckDataSchema = z
|
||||
.object({
|
||||
available: z.boolean().optional(),
|
||||
update_count: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
export type SynologyVolumeRecord = {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
usedBytes: number;
|
||||
totalBytes: number;
|
||||
status: string | undefined;
|
||||
};
|
||||
|
||||
export type SynologyDiskRecord = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
status: string | undefined;
|
||||
temperature: number | null;
|
||||
volumeName: string | undefined;
|
||||
};
|
||||
@@ -0,0 +1,585 @@
|
||||
// @vitest-environment node
|
||||
|
||||
import { Response } from "undici";
|
||||
import { beforeEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
vi.hoisted(() => {
|
||||
process.env.CI = "true";
|
||||
process.env.NODE_ENV = "test";
|
||||
process.env.SECRET_ENCRYPTION_KEY = "0".repeat(64);
|
||||
});
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/http", () => ({
|
||||
fetchWithTrustedCertificatesAsync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@homarr/core/infrastructure/logs", () => ({
|
||||
createLogger: () => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../base/session-store", () => ({
|
||||
createSessionStore: () => ({
|
||||
getAsync: vi.fn().mockResolvedValue(null),
|
||||
setAsync: vi.fn().mockResolvedValue(undefined),
|
||||
clearAsync: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { ResponseError } from "@homarr/common/server";
|
||||
import { fetchWithTrustedCertificatesAsync } from "@homarr/core/infrastructure/http";
|
||||
|
||||
import { IntegrationResponseError } from "../../base/errors/http/integration-response-error";
|
||||
|
||||
import type { IntegrationSecret } from "../../base/types";
|
||||
import { SynologyIntegration } from "../synology-integration";
|
||||
|
||||
const TEST_URL = "https://synology.example.com:5001";
|
||||
const mockFetch = vi.mocked(fetchWithTrustedCertificatesAsync);
|
||||
|
||||
const createIntegration = (secrets: IntegrationSecret[] = []) =>
|
||||
new SynologyIntegration({
|
||||
id: "test-synology",
|
||||
name: "Test Synology",
|
||||
url: TEST_URL,
|
||||
externalUrl: null,
|
||||
decryptedSecrets: secrets.length
|
||||
? secrets
|
||||
: [
|
||||
{ kind: "username", value: "homarr" },
|
||||
{ kind: "password", value: "secret-password" },
|
||||
],
|
||||
});
|
||||
|
||||
const apiDefinitions = {
|
||||
"SYNO.API.Auth": { path: "auth.cgi", maxVersion: 7 },
|
||||
"SYNO.API.Info": { path: "query.cgi", maxVersion: 1 },
|
||||
"SYNO.Core.System": { path: "entry.cgi", maxVersion: 3 },
|
||||
"SYNO.Core.System.Utilization": { path: "entry.cgi", maxVersion: 1 },
|
||||
"SYNO.Storage.CGI.Storage": { path: "entry.cgi", maxVersion: 1 },
|
||||
"SYNO.Storage.CGI.Smart": { path: "entry.cgi", maxVersion: 1 },
|
||||
"SYNO.Core.System.Status": { path: "entry.cgi", maxVersion: 1 },
|
||||
"SYNO.Core.Upgrade": { path: "entry.cgi", maxVersion: 1 },
|
||||
"SYNO.Core.Upgrade.Server": { path: "entry.cgi", maxVersion: 1 },
|
||||
};
|
||||
|
||||
const systemInfoResponse = {
|
||||
success: true as const,
|
||||
data: {
|
||||
up_time: "48:30:15",
|
||||
version_string: "DSM 7.3-12345",
|
||||
model: "DS920+",
|
||||
sys_temp: 42,
|
||||
},
|
||||
};
|
||||
|
||||
const utilizationResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
cpu: {
|
||||
user_load: 12,
|
||||
system_load: 8,
|
||||
"1min_load": 10,
|
||||
"5min_load": 14,
|
||||
"15min_load": 9,
|
||||
},
|
||||
memory: {
|
||||
real_usage: 35,
|
||||
total_real: 8 * 1024 * 1024,
|
||||
avail_real: Math.round(8 * 1024 * 1024 * 0.65),
|
||||
cached: 900_000,
|
||||
},
|
||||
network: [
|
||||
{ device: "total", rx: 4096, tx: 8192 },
|
||||
{ device: "eth0", rx: 4096, tx: 8192 },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const storageV2Response = {
|
||||
success: true,
|
||||
data: {
|
||||
volumes: [
|
||||
{
|
||||
id: "volume_1",
|
||||
display_name: "Data Volume 1",
|
||||
size: { total: 10_000_000_000, used: 4_000_000_000 },
|
||||
status: "normal",
|
||||
},
|
||||
{
|
||||
id: "volume_2",
|
||||
display_name: "Data Volume 2",
|
||||
size: { total: 20_000_000_000, used: 5_000_000_000 },
|
||||
status: "degraded",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const storageLoadInfoResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
disks: [
|
||||
{
|
||||
id: "sda",
|
||||
display_name: "Drive 1",
|
||||
temp: 38,
|
||||
status: "normal",
|
||||
volume_id: "volume_1",
|
||||
},
|
||||
{
|
||||
id: "sdb",
|
||||
display_name: "Drive 2",
|
||||
temp: 44,
|
||||
status: "normal",
|
||||
volume_id: "volume_2",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const smartInfoResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
disks: [
|
||||
{
|
||||
disk_id: "sda",
|
||||
name: "Drive 1",
|
||||
overall_status: "normal",
|
||||
temp: 39,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const systemStatusResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
reboot_required: true,
|
||||
},
|
||||
};
|
||||
|
||||
const upgradeStatusResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
reboot_required: false,
|
||||
},
|
||||
};
|
||||
|
||||
const upgradeCheckResponse = {
|
||||
success: true,
|
||||
data: {
|
||||
available: true,
|
||||
update_count: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const toUrlString = (url: unknown): string => String(url);
|
||||
|
||||
const getApiFromUrl = (url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.searchParams.get("api");
|
||||
};
|
||||
|
||||
const getMethodFromUrl = (url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.searchParams.get("method");
|
||||
};
|
||||
|
||||
const getTypeFromUrl = (url: string) => {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.searchParams.get("type");
|
||||
};
|
||||
|
||||
const mockSuccessfulFlow = (options?: {
|
||||
skipStorageV2?: boolean;
|
||||
skipOptional?: boolean;
|
||||
systemInfo?: {
|
||||
success: boolean;
|
||||
data: Record<string, unknown>;
|
||||
};
|
||||
utilization?: typeof utilizationResponse;
|
||||
storageV2?: typeof storageV2Response;
|
||||
storageLoadInfo?: typeof storageLoadInfoResponse;
|
||||
}) => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = toUrlString(url);
|
||||
const apiName = getApiFromUrl(urlString);
|
||||
const method = getMethodFromUrl(urlString);
|
||||
const type = getTypeFromUrl(urlString);
|
||||
|
||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: { sid: "test-session-id" } }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Set-Cookie": "id=test-session-id; Path=/; HttpOnly",
|
||||
},
|
||||
}),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage_v2") {
|
||||
if (options?.skipStorageV2) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: false, error: { code: 103 } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
return Promise.resolve(new Response(JSON.stringify(options?.storageV2 ?? storageV2Response), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.System" && method === "info" && type === "storage") {
|
||||
return Promise.resolve(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
data: {
|
||||
vol_info: [
|
||||
{
|
||||
name: "volume_1",
|
||||
used_size: 4_000_000_000,
|
||||
total_size: 10_000_000_000,
|
||||
status: "normal",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200 },
|
||||
),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.System" && method === "info" && !type) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (options?.skipOptional) {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Storage.CGI.Storage" && method === "load_info") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(options?.storageLoadInfo ?? storageLoadInfoResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Storage.CGI.Smart" && method === "get_smart_info") {
|
||||
return Promise.resolve(new Response(JSON.stringify(smartInfoResponse), { status: 200 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.System.Status" && method === "get") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(systemStatusResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.Upgrade" && method === "status") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("SynologyIntegration.getSystemInfoAsync", () => {
|
||||
test("maps all supported health monitoring fields", async () => {
|
||||
mockSuccessfulFlow();
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.version).toBe("DSM 7.3-12345");
|
||||
expect(result.cpuModelName).toBe("DS920+");
|
||||
expect(result.cpuUtilization).toBe(20);
|
||||
expect(result.memUsedInBytes).toBe(Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
|
||||
expect(result.memAvailableInBytes).toBe(8 * 1024 * 1024 * 1024 - Math.round((8 * 1024 * 1024 * 1024 * 35) / 100));
|
||||
expect(result.uptime).toBe(48 * 3600 + 30 * 60 + 15);
|
||||
expect(result.cpuTemp).toBe(42);
|
||||
expect(result.network).toEqual({ up: 8192, down: 4096 });
|
||||
expect(result.loadAverage).toEqual({ "1min": 10, "5min": 14, "15min": 9 });
|
||||
expect(result.rebootRequired).toBe(true);
|
||||
expect(result.availablePkgUpdates).toBe(2);
|
||||
expect(result.gpu).toEqual([]);
|
||||
expect(result.fileSystem).toHaveLength(2);
|
||||
expect(result.fileSystem[0]).toEqual({
|
||||
deviceName: "volume_1",
|
||||
used: "4000000000",
|
||||
available: "6000000000",
|
||||
percentage: 40,
|
||||
});
|
||||
expect(result.smart).toEqual([
|
||||
{
|
||||
deviceName: "volume_1",
|
||||
healthy: true,
|
||||
overallStatus: "normal",
|
||||
temperature: 39,
|
||||
},
|
||||
{
|
||||
deviceName: "volume_2",
|
||||
healthy: false,
|
||||
overallStatus: "degraded",
|
||||
temperature: 44,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("picks worst disk status when volume status is missing", async () => {
|
||||
mockSuccessfulFlow({
|
||||
storageV2: {
|
||||
success: true,
|
||||
data: {
|
||||
volumes: [
|
||||
{
|
||||
id: "volume_1",
|
||||
display_name: "Data Volume 1",
|
||||
size: { total: 10_000_000_000, used: 4_000_000_000 },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
storageLoadInfo: {
|
||||
success: true,
|
||||
data: {
|
||||
disks: [
|
||||
{
|
||||
id: "sda",
|
||||
display_name: "Drive 1",
|
||||
temp: 38,
|
||||
status: "normal",
|
||||
volume_id: "volume_1",
|
||||
},
|
||||
{
|
||||
id: "sdb",
|
||||
display_name: "Drive 2",
|
||||
temp: 40,
|
||||
status: "degraded",
|
||||
volume_id: "volume_1",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.smart).toEqual([
|
||||
{
|
||||
deviceName: "volume_1",
|
||||
healthy: false,
|
||||
overallStatus: "degraded",
|
||||
temperature: 40,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("falls back to legacy storage info when storage_v2 is unavailable", async () => {
|
||||
mockSuccessfulFlow({ skipStorageV2: true });
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.fileSystem).toHaveLength(1);
|
||||
expect(result.fileSystem[0]?.deviceName).toBe("volume_1");
|
||||
});
|
||||
|
||||
test("continues when optional tier-2 calls fail", async () => {
|
||||
mockSuccessfulFlow({ skipOptional: true });
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.cpuUtilization).toBe(20);
|
||||
expect(result.rebootRequired).toBe(false);
|
||||
expect(result.availablePkgUpdates).toBe(0);
|
||||
expect(result.smart.every((entry) => entry.temperature === null)).toBe(true);
|
||||
});
|
||||
|
||||
test("converts memory values from KiB to bytes", async () => {
|
||||
mockSuccessfulFlow({
|
||||
utilization: {
|
||||
...utilizationResponse,
|
||||
data: {
|
||||
...utilizationResponse.data,
|
||||
memory: {
|
||||
real_usage: 17,
|
||||
total_real: 16_351_532,
|
||||
avail_real: 426_568,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
const totalBytes = 16_351_532 * 1024;
|
||||
expect(result.memUsedInBytes).toBe(Math.round((totalBytes * 17) / 100));
|
||||
expect(result.memAvailableInBytes).toBe(totalBytes - Math.round((totalBytes * 17) / 100));
|
||||
});
|
||||
|
||||
test("caps cpu utilization at 100 when user and system load exceed 100", async () => {
|
||||
mockSuccessfulFlow({
|
||||
utilization: {
|
||||
...utilizationResponse,
|
||||
data: {
|
||||
...utilizationResponse.data,
|
||||
cpu: {
|
||||
...utilizationResponse.data.cpu,
|
||||
user_load: 80,
|
||||
system_load: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
|
||||
expect(result.cpuUtilization).toBe(100);
|
||||
});
|
||||
|
||||
test("returns undefined cpu temperature when sensor is absent", async () => {
|
||||
mockSuccessfulFlow({
|
||||
systemInfo: {
|
||||
success: true,
|
||||
data: {
|
||||
up_time: "01:00:00",
|
||||
version_string: "DSM 7.3-12345",
|
||||
model: "DS920+",
|
||||
},
|
||||
},
|
||||
});
|
||||
const integration = createIntegration();
|
||||
const result = await integration.getSystemInfoAsync();
|
||||
expect(result.cpuTemp).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SynologyIntegration.listStorageVolumesAsync", () => {
|
||||
test("returns auto-detected volume names", async () => {
|
||||
mockSuccessfulFlow();
|
||||
const integration = createIntegration();
|
||||
const volumes = await integration.listStorageVolumesAsync();
|
||||
|
||||
expect(volumes).toEqual([
|
||||
{ value: "test-synology:volume_1", label: "Data Volume 1 (Test Synology)" },
|
||||
{ value: "test-synology:volume_2", label: "Data Volume 2 (Test Synology)" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SynologyIntegration authentication", () => {
|
||||
test("rejects two-factor authentication during login", async () => {
|
||||
mockFetch.mockImplementation((url) => {
|
||||
const urlString = toUrlString(url);
|
||||
const apiName = getApiFromUrl(urlString);
|
||||
const method = getMethodFromUrl(urlString);
|
||||
|
||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
await expect(integration.getSystemInfoAsync()).rejects.toThrow();
|
||||
});
|
||||
|
||||
test("does not leak credentials in login error URLs", async () => {
|
||||
mockFetch.mockImplementation((url, init) => {
|
||||
const urlString = toUrlString(url);
|
||||
const apiName = getApiFromUrl(urlString);
|
||||
const method = getMethodFromUrl(urlString);
|
||||
|
||||
if (apiName === "SYNO.API.Info" && method === "query") {
|
||||
const query = new URL(urlString).searchParams.get("query") ?? "SYNO.API.Auth";
|
||||
const definition = apiDefinitions[query as keyof typeof apiDefinitions];
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: true, data: { [query]: definition } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
if (apiName === "SYNO.API.Auth" && method === "login") {
|
||||
expect(init?.method).toBe("POST");
|
||||
expect(init?.body).toContain("account=homarr");
|
||||
expect(init?.body).toContain("passwd=secret-password");
|
||||
expect(urlString).not.toContain("account=");
|
||||
expect(urlString).not.toContain("passwd=");
|
||||
|
||||
return Promise.resolve(
|
||||
new Response(JSON.stringify({ success: false, error: { code: 403 } }), { status: 200 }),
|
||||
) as unknown as ReturnType<typeof fetchWithTrustedCertificatesAsync>;
|
||||
}
|
||||
|
||||
return Promise.resolve(new Response("{}", { status: 500 })) as unknown as ReturnType<
|
||||
typeof fetchWithTrustedCertificatesAsync
|
||||
>;
|
||||
});
|
||||
|
||||
const integration = createIntegration();
|
||||
|
||||
try {
|
||||
await integration.getSystemInfoAsync();
|
||||
expect.unreachable("Expected login to fail");
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(IntegrationResponseError);
|
||||
const responseError = (error as IntegrationResponseError).cause;
|
||||
expect(responseError).toBeInstanceOf(ResponseError);
|
||||
expect(responseError.url).not.toContain("secret-password");
|
||||
expect(responseError.url).not.toContain("passwd=");
|
||||
expect(responseError.url).not.toContain("account=homarr");
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user