+
### Supported Integrations
-
+
### Adding the widget
@@ -59,4 +71,4 @@ import { truenasIntegration } from '@site/docs/integrations/truenas';
### Configuration
-
\ No newline at end of file
+
diff --git a/packages/api/src/router/widgets/health-monitoring.ts b/packages/api/src/router/widgets/health-monitoring.ts
index de49a1083..1957f459c 100644
--- a/packages/api/src/router/widgets/health-monitoring.ts
+++ b/packages/api/src/router/widgets/health-monitoring.ts
@@ -1,21 +1,31 @@
+import { createIntegrationAsync } from "@homarr/integrations";
+import { SynologyIntegration } from "@homarr/integrations";
import { clusterInfoRequestHandler, systemInfoRequestHandler } from "@homarr/request-handler/health-monitoring";
import { createManyIntegrationMiddleware, createOneIntegrationMiddleware } from "../../middlewares/integration";
import { settleIntegrationQueries } from "../../settle-integrations";
import { createTRPCRouter, publicProcedure } from "../../trpc";
+const healthMonitoringIntegrationKinds = [
+ "openmediavault",
+ "dashDot",
+ "truenas",
+ "unraid",
+ "glances",
+ "synology",
+ "mock",
+] as const;
+
export const healthMonitoringRouter = createTRPCRouter({
getSystemHealthStatus: publicProcedure
.meta({
mcp: {
enabled: true,
description:
- "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
+ "Get system health status (CPU, memory, disk, network) from NAS/server monitoring integrations. REQUIRED: integrationIds (array of TrueNAS/Synology/Unraid/Glances/OpenMediaVault/DashDot integration IDs from integration_all)",
},
})
- .concat(
- createManyIntegrationMiddleware("query", "openmediavault", "dashDot", "truenas", "unraid", "glances", "mock"),
- )
+ .concat(createManyIntegrationMiddleware("query", ...healthMonitoringIntegrationKinds))
.query(async ({ ctx }) => {
return await settleIntegrationQueries(ctx.integrations, async (integration) => {
const { data, timestamp } = await systemInfoRequestHandler.handler(integration, {}).getDataAsync();
@@ -27,6 +37,23 @@ export const healthMonitoringRouter = createTRPCRouter({
};
});
}),
+ listStorageVolumes: publicProcedure
+ .meta({
+ mcp: {
+ enabled: true,
+ description:
+ "List storage volumes from a Synology DiskStation integration for widget configuration. REQUIRED: integrationId from integration_all (Synology integration only)",
+ },
+ })
+ .concat(createOneIntegrationMiddleware("query", "synology"))
+ .query(async ({ ctx }) => {
+ const integrationInstance = await createIntegrationAsync(ctx.integration);
+ if (!(integrationInstance instanceof SynologyIntegration)) {
+ throw new Error("Expected Synology integration");
+ }
+
+ return await integrationInstance.listStorageVolumesAsync();
+ }),
getClusterHealthStatus: publicProcedure
.meta({
mcp: {
diff --git a/packages/definitions/src/docker-integration-match.ts b/packages/definitions/src/docker-integration-match.ts
index e7e10809b..eb6476bc8 100644
--- a/packages/definitions/src/docker-integration-match.ts
+++ b/packages/definitions/src/docker-integration-match.ts
@@ -33,6 +33,7 @@ const integrationAliases: Partial
> =
paperlessNgx: ["paperless-ngx", "paperless"],
coolify: ["coolify"],
truenas: ["truenas"],
+ synology: ["synology", "diskstation"],
};
export const matchIntegrationKind = (search: string): IntegrationKind | null => {
diff --git a/packages/definitions/src/docs/homarr-docs-sitemap.ts b/packages/definitions/src/docs/homarr-docs-sitemap.ts
index 5c0cb511c..3edb403d0 100644
--- a/packages/definitions/src/docs/homarr-docs-sitemap.ts
+++ b/packages/definitions/src/docs/homarr-docs-sitemap.ts
@@ -258,6 +258,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/speedtest-tracker"
| "/docs/widgets/stock-price"
| "/docs/widgets/system-resources"
+ | "/docs/widgets/system-disks"
| "/docs/widgets/timetable"
| "/docs/widgets/tracearr"
| "/docs/widgets/umami"
@@ -303,6 +304,7 @@ export type HomarrDocumentationPath =
| "/docs/integrations/ical"
| "/docs/integrations/anchor"
| "/docs/integrations/truenas"
+ | "/docs/integrations/synology"
| "/docs/integrations/unraid"
| "/docs/integrations/coolify"
| "/docs/integrations/immich"
@@ -347,6 +349,7 @@ export type HomarrDocumentationPath =
| "/docs/widgets/firewall"
| "/docs/widgets/notifications"
| "/docs/widgets/system-resources"
+ | "/docs/widgets/system-disks"
| "/docs/widgets/coolify"
| "/docs/widgets/timetable"
| "/docs/widgets/immich-server-stats"
diff --git a/packages/definitions/src/docs/integration-doc-slugs.ts b/packages/definitions/src/docs/integration-doc-slugs.ts
index ca19d4de6..3d8287383 100644
--- a/packages/definitions/src/docs/integration-doc-slugs.ts
+++ b/packages/definitions/src/docs/integration-doc-slugs.ts
@@ -36,6 +36,7 @@ export const integrationDocSlugs: Record = {
ical: "ical",
anchor: "anchor",
truenas: "truenas",
+ synology: "synology",
unraid: "unraid",
coolify: "coolify",
immich: "immich",
diff --git a/packages/definitions/src/docs/widget-doc-slugs.ts b/packages/definitions/src/docs/widget-doc-slugs.ts
index 2e34a4843..58079787a 100644
--- a/packages/definitions/src/docs/widget-doc-slugs.ts
+++ b/packages/definitions/src/docs/widget-doc-slugs.ts
@@ -33,7 +33,7 @@ export const widgetDocSlugs: Record = {
notifications: "notifications",
systemResources: "system-resources",
coolify: "coolify",
- systemDisks: null,
+ systemDisks: "system-disks",
timetable: "timetable",
"immich-serverStats": "immich-server-stats",
"immich-albumCarousel": "immich-album-carousel",
diff --git a/packages/definitions/src/integration.ts b/packages/definitions/src/integration.ts
index 15c94f80d..c15644c2c 100644
--- a/packages/definitions/src/integration.ts
+++ b/packages/definitions/src/integration.ts
@@ -321,6 +321,14 @@ export const integrationDefs = {
documentationUrl: createDocumentationLink("/docs/integrations/truenas"),
defaultPort: 80,
},
+ synology: {
+ name: "Synology DiskStation",
+ secretKinds: [["username", "password"]],
+ iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons@master/svg/synology.svg",
+ category: ["healthMonitoring"],
+ documentationUrl: createDocumentationLink("/docs/integrations/synology"),
+ defaultPort: 5000,
+ },
unraid: {
name: "Unraid",
secretKinds: [["apiKey"]],
diff --git a/packages/definitions/src/widget-integration-map.ts b/packages/definitions/src/widget-integration-map.ts
index deb953781..a69473393 100644
--- a/packages/definitions/src/widget-integration-map.ts
+++ b/packages/definitions/src/widget-integration-map.ts
@@ -27,8 +27,8 @@ export const widgetIntegrationSupport: Partial;
+};
+
+const apiDefinitionCache = new Map();
+
+export class SynologyClient {
+ private readonly integrationId: string;
+ private readonly baseUrl: string;
+ private readonly username: string;
+ private readonly password: string;
+ private readonly sessionStore: SessionStore;
+
+ 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 {
+ 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["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) {
+ 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): 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): 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 },
+ ) {
+ 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 },
+ ): Promise> {
+ 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(callback: (session: StoredSession) => Promise): Promise {
+ 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 {
+ 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 {
+ 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;
+}
diff --git a/packages/integrations/src/synology/synology-integration.ts b/packages/integrations/src/synology/synology-integration.ts
new file mode 100644
index 000000000..90c49f4bf
--- /dev/null
+++ b/packages/integrations/src/synology/synology-integration.ts
@@ -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(this.integration);
+ private client?: SynologyClient;
+
+ protected async testingAsync(_input: IntegrationTestingInput): Promise {
+ await this.getClient().testConnectionAsync();
+ return { success: true };
+ }
+
+ public async getSystemInfoAsync(): Promise {
+ 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(result: PromiseSettledResult): 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): 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): 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) {
+ 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) {
+ 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) {
+ 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): 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): 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();
+
+ 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;
+}
diff --git a/packages/integrations/src/synology/synology-types.ts b/packages/integrations/src/synology/synology-types.ts
new file mode 100644
index 000000000..c1f73b52e
--- /dev/null
+++ b/packages/integrations/src/synology/synology-types.ts
@@ -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;
+};
diff --git a/packages/integrations/src/synology/test/synology-integration.spec.ts b/packages/integrations/src/synology/test/synology-integration.spec.ts
new file mode 100644
index 000000000..07153b1e4
--- /dev/null
+++ b/packages/integrations/src/synology/test/synology-integration.spec.ts
@@ -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;
+ };
+ 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;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+ 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;
+ }
+
+ if (apiName === "SYNO.Core.System" && method === "info" && !type) {
+ return Promise.resolve(
+ new Response(JSON.stringify(options?.systemInfo ?? systemInfoResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (apiName === "SYNO.Core.System.Utilization" && method === "get") {
+ return Promise.resolve(
+ new Response(JSON.stringify(options?.utilization ?? utilizationResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (options?.skipOptional) {
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: false, error: { code: 105 } }), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ if (apiName === "SYNO.Core.Upgrade" && method === "status") {
+ return Promise.resolve(
+ new Response(JSON.stringify(upgradeStatusResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ if (apiName === "SYNO.Core.Upgrade.Server" && method === "check") {
+ return Promise.resolve(
+ new Response(JSON.stringify(upgradeCheckResponse), { status: 200 }),
+ ) as unknown as ReturnType;
+ }
+
+ return Promise.resolve(
+ new Response(JSON.stringify({ success: false, error: { code: 102 } }), { status: 200 }),
+ ) as unknown as ReturnType;
+ });
+};
+
+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;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ 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;
+ }
+
+ 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");
+ }
+ });
+});
diff --git a/packages/old-import/src/widgets/options.ts b/packages/old-import/src/widgets/options.ts
index b407d0e45..a4ae66bb0 100644
--- a/packages/old-import/src/widgets/options.ts
+++ b/packages/old-import/src/widgets/options.ts
@@ -165,6 +165,7 @@ const optionMapping: OptionMapping = {
"sectionIndicatorColor" in oldOptions ? oldOptions.sectionIndicatorColor : undefined,
showUptime: () => undefined,
gpu: () => undefined,
+ visibleStorageVolumes: () => undefined,
visibleClusterSections: (oldOptions) => {
if (!("showNode" in oldOptions)) return undefined;
diff --git a/packages/translation/src/lang/en.json b/packages/translation/src/lang/en.json
index f4cbf6f98..1d910c41a 100644
--- a/packages/translation/src/lang/en.json
+++ b/packages/translation/src/lang/en.json
@@ -2284,6 +2284,11 @@
"fileSystem": {
"label": "Show Filesystem Info"
},
+ "visibleStorageVolumes": {
+ "label": "Visible storage volumes",
+ "description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
+ "placeholder": "Select storage volumes"
+ },
"defaultTab": {
"label": "Default tab"
},
@@ -3538,6 +3543,11 @@
},
"showBackgroundBar": {
"label": "Show background usage bar"
+ },
+ "visibleStorageVolumes": {
+ "label": "Visible storage volumes",
+ "description": "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.",
+ "placeholder": "Select storage volumes"
}
},
"status": {
diff --git a/packages/widgets/src/_inputs/common.tsx b/packages/widgets/src/_inputs/common.tsx
index c7be65262..3d575bfb0 100644
--- a/packages/widgets/src/_inputs/common.tsx
+++ b/packages/widgets/src/_inputs/common.tsx
@@ -11,7 +11,7 @@ export interface CommonWidgetInputProps {
itemId?: string;
}
-type UseWidgetInputTranslationReturnType = (key: "label" | "description") => string;
+type UseWidgetInputTranslationReturnType = (key: "label" | "description" | "placeholder") => string;
/**
* Short description why as and unknown convertions are used below:
diff --git a/packages/widgets/src/_inputs/index.ts b/packages/widgets/src/_inputs/index.ts
index ac9ef1a67..2e6943ac0 100644
--- a/packages/widgets/src/_inputs/index.ts
+++ b/packages/widgets/src/_inputs/index.ts
@@ -17,6 +17,7 @@ import { WidgetUmamiEventInput } from "./widget-umami-event-input";
import { WidgetUmamiEventNamesInput } from "./widget-umami-event-names-input";
import { WidgetUmamiWebsiteInput } from "./widget-umami-website-input";
import { WidgetIntegrationSelectInput } from "./widget-integration-select-input";
+import { WidgetIntegrationMultiSelectInput } from "./widget-integration-multi-select-input";
const mapping = {
anchorNote: WidgetAnchorNoteInput,
@@ -37,6 +38,7 @@ const mapping = {
umamiWebsite: WidgetUmamiWebsiteInput,
customWidgetSelect: WidgetCustomWidgetSelectInput,
integrationSelect: WidgetIntegrationSelectInput,
+ integrationMultiSelect: WidgetIntegrationMultiSelectInput,
} satisfies Record;
export const getInputForType = (type: TType) => {
diff --git a/packages/widgets/src/_inputs/widget-integration-multi-select-input.tsx b/packages/widgets/src/_inputs/widget-integration-multi-select-input.tsx
new file mode 100644
index 000000000..f2fa0b542
--- /dev/null
+++ b/packages/widgets/src/_inputs/widget-integration-multi-select-input.tsx
@@ -0,0 +1,60 @@
+"use client";
+
+import { MultiSelect, Text } from "@mantine/core";
+
+import { useScopedI18n } from "@homarr/translation/client";
+
+import type { CommonWidgetInputProps } from "./common";
+import { useWidgetInputTranslation } from "./common";
+import { useFormContext } from "./form";
+
+const useIntegrationSelectTranslation = () => useScopedI18n("widget.integrationSelect");
+
+export const WidgetIntegrationMultiSelectInput = ({
+ property,
+ kind,
+ options,
+}: CommonWidgetInputProps<"integrationMultiSelect">) => {
+ const t = useIntegrationSelectTranslation();
+ const tInput = useWidgetInputTranslation(kind, property);
+ const form = useFormContext();
+
+ const integrationIds = form.values.integrationIds as string[];
+ const inputProps = form.getInputProps(`options.${property}`);
+ const selectedValues = (inputProps.value as string[] | undefined) ?? [];
+
+ const { data: selectData = [], isPending, isError } = options.useOptions(integrationIds);
+ const data = [
+ ...selectData,
+ ...selectedValues
+ .filter((selectedValue) => !selectData.some((option) => option.value === selectedValue))
+ .map((selectedValue) => ({ value: selectedValue, label: selectedValue })),
+ ];
+
+ if (integrationIds.length === 0) {
+ return (
+
+ {t("configureIntegrationFirst")}
+
+ );
+ }
+
+ const description = options.withDescription ? tInput("description") : undefined;
+ const placeholder = isPending ? t("loading") : tInput("placeholder");
+ const loadError = isError ? t("loadError") : undefined;
+
+ return (
+
+ );
+};
diff --git a/packages/widgets/src/filter-storage-volumes.spec.ts b/packages/widgets/src/filter-storage-volumes.spec.ts
new file mode 100644
index 000000000..51d84169a
--- /dev/null
+++ b/packages/widgets/src/filter-storage-volumes.spec.ts
@@ -0,0 +1,146 @@
+import { describe, expect, test } from "vitest";
+
+import {
+ filterStorageVolumes,
+ normalizeStorageDeviceName,
+ toScopedStorageVolumeValue,
+} from "./filter-storage-volumes";
+import { matchFileSystemAndSmart } from "./health-monitoring/system-health";
+
+describe("normalizeStorageDeviceName", () => {
+ test("strips partition suffixes from block device paths", () => {
+ expect(normalizeStorageDeviceName("/dev/sda1")).toBe("/dev/sda");
+ expect(normalizeStorageDeviceName("/dev/sda")).toBe("/dev/sda");
+ });
+
+ test("preserves Synology-style volume names", () => {
+ expect(normalizeStorageDeviceName("volume_1")).toBe("volume_1");
+ expect(normalizeStorageDeviceName("volume_2")).toBe("volume_2");
+ });
+
+ test("strips NVMe partition suffixes without collapsing the namespace id", () => {
+ expect(normalizeStorageDeviceName("/dev/nvme0n1")).toBe("/dev/nvme0n1");
+ expect(normalizeStorageDeviceName("/dev/nvme0n1p2")).toBe("/dev/nvme0n1");
+ expect(normalizeStorageDeviceName("/dev/nvme0n2p1")).toBe("/dev/nvme0n2");
+ });
+
+ test("does not collapse unrelated md devices", () => {
+ expect(normalizeStorageDeviceName("/dev/md0")).toBe("/dev/md0");
+ expect(normalizeStorageDeviceName("/dev/md1")).toBe("/dev/md1");
+ });
+});
+
+describe("toScopedStorageVolumeValue", () => {
+ test("scopes unscoped volume names to the query integration", () => {
+ expect(toScopedStorageVolumeValue("nas-a", "volume_1")).toBe("nas-a:volume_1");
+ });
+
+ test("re-scopes already-prefixed values using the query integration", () => {
+ expect(toScopedStorageVolumeValue("nas-b", "nas-a:volume_1")).toBe("nas-b:volume_1");
+ });
+});
+
+describe("filterStorageVolumes", () => {
+ const integrationId = "nas-1";
+
+ test("matches SMART entries using normalized partition identifiers", () => {
+ const entries = [
+ { deviceName: "/dev/sda1", used: "1", available: "2", percentage: 50 },
+ { deviceName: "/dev/sdb1", used: "1", available: "2", percentage: 50 },
+ ];
+ const smartEntries = [
+ { deviceName: "/dev/sda", temperature: 35, overallStatus: "GOOD" },
+ { deviceName: "/dev/sdb", temperature: 40, overallStatus: "GOOD" },
+ ];
+
+ const visibleVolumes = [`${integrationId}:/dev/sda1`];
+
+ expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toHaveLength(1);
+ expect(filterStorageVolumes(smartEntries, visibleVolumes, integrationId)).toHaveLength(1);
+ });
+
+ test("keeps exact Synology volume matches", () => {
+ const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
+ const visibleVolumes = [`${integrationId}:volume_1`];
+
+ expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "volume_1" }]);
+ });
+
+ test("does not apply another integration's scoped selection", () => {
+ const entries = [{ deviceName: "volume_1" }, { deviceName: "volume_2" }];
+ const visibleVolumes = ["nas-b:volume_1"];
+
+ expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([]);
+ });
+
+ test("does not treat different md devices as the same volume", () => {
+ const entries = [{ deviceName: "/dev/md0" }, { deviceName: "/dev/md1" }];
+ const visibleVolumes = [`${integrationId}:/dev/md0`];
+
+ expect(filterStorageVolumes(entries, visibleVolumes, integrationId)).toEqual([{ deviceName: "/dev/md0" }]);
+ });
+});
+
+describe("matchFileSystemAndSmart", () => {
+ test("joins partitioned filesystem entries with base SMART device names", () => {
+ const result = matchFileSystemAndSmart(
+ [{ deviceName: "/dev/sda1", used: "100", available: "900", percentage: 10 }],
+ [{ deviceName: "/dev/sda", temperature: 42, overallStatus: "GOOD" }],
+ );
+
+ expect(result).toEqual([
+ {
+ deviceName: "/dev/sda",
+ used: "100",
+ available: "900",
+ percentage: 10,
+ temperature: 42,
+ overallStatus: "GOOD",
+ },
+ ]);
+ });
+
+ test("joins Synology volume names without stripping volume suffixes", () => {
+ const result = matchFileSystemAndSmart(
+ [{ deviceName: "volume_1", used: "100", available: "900", percentage: 10 }],
+ [{ deviceName: "volume_1", temperature: 39, overallStatus: "normal" }],
+ );
+
+ expect(result[0]).toMatchObject({
+ deviceName: "volume_1",
+ temperature: 39,
+ overallStatus: "normal",
+ });
+ });
+
+ test("joins NVMe partitions with their base device SMART data", () => {
+ const result = matchFileSystemAndSmart(
+ [{ deviceName: "/dev/nvme0n1p2", used: "100", available: "900", percentage: 10 }],
+ [{ deviceName: "/dev/nvme0n1", temperature: 41, overallStatus: "GOOD" }],
+ );
+
+ expect(result).toEqual([
+ {
+ deviceName: "/dev/nvme0n1",
+ used: "100",
+ available: "900",
+ percentage: 10,
+ temperature: 41,
+ overallStatus: "GOOD",
+ },
+ ]);
+ });
+
+ test("does not join SMART data from a different md device", () => {
+ const result = matchFileSystemAndSmart(
+ [{ deviceName: "/dev/md0", used: "100", available: "900", percentage: 10 }],
+ [{ deviceName: "/dev/md1", temperature: 41, overallStatus: "GOOD" }],
+ );
+
+ expect(result[0]).toMatchObject({
+ deviceName: "/dev/md0",
+ temperature: null,
+ overallStatus: "",
+ });
+ });
+});
diff --git a/packages/widgets/src/filter-storage-volumes.ts b/packages/widgets/src/filter-storage-volumes.ts
new file mode 100644
index 000000000..5e88584be
--- /dev/null
+++ b/packages/widgets/src/filter-storage-volumes.ts
@@ -0,0 +1,67 @@
+interface StorageVolumeEntry {
+ deviceName: string;
+}
+
+const partitionSuffixPatterns: ReadonlyArray<{ pattern: RegExp; baseGroupIndex: number }> = [
+ // SCSI/SATA/VirtIO: /dev/sda1 -> /dev/sda
+ { pattern: /^(\/dev\/(?:sd|vd|hd|xvd)[a-z]+)[0-9]+$/, baseGroupIndex: 1 },
+ // NVMe: /dev/nvme0n1p2 -> /dev/nvme0n1
+ { pattern: /^(\/dev\/nvme[0-9]+n[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
+ // eMMC: /dev/mmcblk0p1 -> /dev/mmcblk0
+ { pattern: /^(\/dev\/mmcblk[0-9]+)p[0-9]+$/, baseGroupIndex: 1 },
+];
+
+export const normalizeStorageDeviceName = (deviceName: string): string => {
+ for (const { pattern, baseGroupIndex } of partitionSuffixPatterns) {
+ const match = deviceName.match(pattern);
+ if (match) {
+ return match[baseGroupIndex] ?? deviceName;
+ }
+ }
+
+ return deviceName;
+};
+
+export const toScopedStorageVolumeValue = (integrationId: string, value: string): string => {
+ const separatorIndex = value.indexOf(":");
+ const volumeName = separatorIndex === -1 ? value : value.slice(separatorIndex + 1);
+ return `${integrationId}:${volumeName}`;
+};
+
+const storageDeviceNamesMatch = (leftDeviceName: string, rightDeviceName: string): boolean => {
+ return (
+ leftDeviceName === rightDeviceName ||
+ normalizeStorageDeviceName(leftDeviceName) === normalizeStorageDeviceName(rightDeviceName)
+ );
+};
+
+const matchesVisibleStorageVolume = (
+ visibleVolume: string,
+ integrationId: string,
+ deviceName: string,
+): boolean => {
+ const separatorIndex = visibleVolume.indexOf(":");
+ if (separatorIndex === -1) {
+ return storageDeviceNamesMatch(visibleVolume, deviceName);
+ }
+
+ const visibleIntegrationId = visibleVolume.slice(0, separatorIndex);
+ const visibleVolumeName = visibleVolume.slice(separatorIndex + 1);
+ return visibleIntegrationId === integrationId && storageDeviceNamesMatch(visibleVolumeName, deviceName);
+};
+
+export const filterStorageVolumes = (
+ entries: TEntry[],
+ visibleStorageVolumes: string[] | undefined,
+ integrationId: string,
+): TEntry[] => {
+ if (!visibleStorageVolumes || visibleStorageVolumes.length === 0) {
+ return entries;
+ }
+
+ return entries.filter((entry) =>
+ visibleStorageVolumes.some((visibleVolume) =>
+ matchesVisibleStorageVolume(visibleVolume, integrationId, entry.deviceName),
+ ),
+ );
+};
diff --git a/packages/widgets/src/health-monitoring/index.ts b/packages/widgets/src/health-monitoring/index.ts
index dd176a189..efb0cb40b 100644
--- a/packages/widgets/src/health-monitoring/index.ts
+++ b/packages/widgets/src/health-monitoring/index.ts
@@ -4,6 +4,7 @@ import { getIntegrationKindsByCategory } from "@homarr/definitions";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
+import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("healthMonitoring", {
icon: IconHeartRateMonitor,
@@ -28,6 +29,7 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
fileSystem: factory.switch({
defaultValue: true,
}),
+ visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
visibleClusterSections: factory.multiSelect({
options: [
{
@@ -77,6 +79,11 @@ export const { definition, componentLoader } = createWidgetDefinition("healthMon
return integrationKinds.every((kind) => kind === "proxmox") || integrationKinds.length === 0;
},
},
+ visibleStorageVolumes: {
+ shouldHide(_, integrationKinds) {
+ return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
+ },
+ },
showUptime: {
shouldHide(_, integrationKinds) {
// Uptime is only shown on cluster health tab
diff --git a/packages/widgets/src/health-monitoring/system-health.tsx b/packages/widgets/src/health-monitoring/system-health.tsx
index 8b77b1d2f..3119adead 100644
--- a/packages/widgets/src/health-monitoring/system-health.tsx
+++ b/packages/widgets/src/health-monitoring/system-health.tsx
@@ -36,8 +36,9 @@ import { humanFileSize } from "@homarr/common";
import type { TranslationFunction } from "@homarr/translation";
import { useI18n } from "@homarr/translation/client";
-import type { WidgetComponentProps } from "../definition";
+import { filterStorageVolumes, normalizeStorageDeviceName } from "../filter-storage-volumes";
import { WidgetEmptyState } from "../common/empty-state";
+import type { WidgetComponentProps } from "../definition";
import { CpuRing } from "./rings/cpu-ring";
import { CpuTempRing } from "./rings/cpu-temp-ring";
import { GpuRing } from "./rings/gpu-ring";
@@ -65,7 +66,13 @@ export const SystemHealthMonitoring = ({
return (
{healthData.map(({ integrationId, integrationName, healthInfo }) => {
- const disksData = matchFileSystemAndSmart(healthInfo.fileSystem, healthInfo.smart);
+ const filteredFileSystem = filterStorageVolumes(
+ healthInfo.fileSystem,
+ options.visibleStorageVolumes,
+ integrationId,
+ );
+ const filteredSmart = filterStorageVolumes(healthInfo.smart, options.visibleStorageVolumes, integrationId);
+ const disksData = matchFileSystemAndSmart(filteredFileSystem, filteredSmart);
const memoryUsage = formatMemoryUsage(healthInfo.memAvailableInBytes, healthInfo.memUsedInBytes);
return (
{
return fileSystems
.map((fileSystem) => {
- const baseDeviceName = fileSystem.deviceName.replace(/[0-9]+$/, "");
- const smartDisk = smartData.find((smart) => smart.deviceName === baseDeviceName);
+ const normalizedFileSystemName = normalizeStorageDeviceName(fileSystem.deviceName);
+ const smartDisk = smartData.find(
+ (smart) =>
+ smart.deviceName === fileSystem.deviceName ||
+ normalizeStorageDeviceName(smart.deviceName) === normalizedFileSystemName,
+ );
return {
deviceName: smartDisk?.deviceName ?? fileSystem.deviceName,
diff --git a/packages/widgets/src/options.ts b/packages/widgets/src/options.ts
index bf82ef226..e4a937e52 100644
--- a/packages/widgets/src/options.ts
+++ b/packages/widgets/src/options.ts
@@ -72,6 +72,15 @@ interface IntegrationSelectInput extends CommonInput {
};
}
+interface IntegrationMultiSelectInput extends CommonInput {
+ withDescription?: boolean;
+ useOptions: (integrationIds: string[]) => {
+ data: { value: string; label: string }[];
+ isPending: boolean;
+ isError: boolean;
+ };
+}
+
interface NumberInput extends CommonInput {
validate: z.ZodNumber;
step?: number;
@@ -196,6 +205,12 @@ const optionsFactory = {
searchable: input.searchable ?? true,
useOptions: input.useOptions,
}),
+ integrationMultiSelect: (input: IntegrationMultiSelectInput) => ({
+ type: "integrationMultiSelect" as const,
+ defaultValue: input.defaultValue ?? [],
+ withDescription: input.withDescription ?? false,
+ useOptions: input.useOptions,
+ }),
customWidgetSelect: (input?: CommonInput) => ({
type: "customWidgetSelect" as const,
defaultValue: input?.defaultValue ?? "",
diff --git a/packages/widgets/src/storage-volume-options.ts b/packages/widgets/src/storage-volume-options.ts
new file mode 100644
index 000000000..202da2c8a
--- /dev/null
+++ b/packages/widgets/src/storage-volume-options.ts
@@ -0,0 +1,39 @@
+import { clientApi } from "@homarr/api/client";
+
+import { toScopedStorageVolumeValue } from "./filter-storage-volumes";
+
+export const createStorageVolumeMultiSelectOptions = () => ({
+ defaultValue: [] as string[],
+ withDescription: true,
+ useOptions: (integrationIds: string[]) => {
+ const queryResults = clientApi.useQueries((queryBuilder) =>
+ integrationIds.map((integrationId) =>
+ queryBuilder.widget.healthMonitoring.listStorageVolumes({
+ integrationId,
+ }),
+ ),
+ );
+
+ const isPending = queryResults.some((queryResult) => queryResult.isPending);
+ const isError = queryResults.every((queryResult) => queryResult.isError);
+ const options = queryResults.flatMap((queryResult, index) => {
+ const integrationId = integrationIds[index];
+ if (!integrationId) {
+ return [];
+ }
+
+ return (queryResult.data ?? []).map((option) => ({
+ label: option.label,
+ value: toScopedStorageVolumeValue(integrationId, option.value),
+ }));
+ });
+
+ const uniqueOptions = [...new Map(options.map((option) => [option.value, option])).values()];
+
+ return {
+ data: uniqueOptions,
+ isPending,
+ isError: isError && uniqueOptions.length === 0,
+ };
+ },
+});
diff --git a/packages/widgets/src/system-disks/component.tsx b/packages/widgets/src/system-disks/component.tsx
index d24a68c37..cabc03ba5 100644
--- a/packages/widgets/src/system-disks/component.tsx
+++ b/packages/widgets/src/system-disks/component.tsx
@@ -10,6 +10,7 @@ import { useI18n } from "@homarr/translation/client";
import { WidgetEmptyState } from "../common/empty-state";
import type { WidgetComponentProps } from "../definition";
+import { filterStorageVolumes } from "../filter-storage-volumes";
import { NoIntegrationDataError } from "../errors/no-data-integration";
type DisplayMode = WidgetComponentProps<"systemDisks">["options"]["displayMode"];
@@ -123,12 +124,25 @@ export default function SystemResources({ integrationIds, options }: WidgetCompo
const lastItem = data.at(-1);
if (!lastItem) return ;
- const { fileSystem, smart } = lastItem.healthInfo;
- if (fileSystem.length === 0) {
+ const rawFileSystem = lastItem.healthInfo.fileSystem;
+ if (rawFileSystem.length === 0) {
throw new NoIntegrationDataError();
}
+ const fileSystem = filterStorageVolumes(
+ rawFileSystem,
+ options.visibleStorageVolumes,
+ lastItem.integrationId,
+ );
+ const smart = filterStorageVolumes(
+ lastItem.healthInfo.smart,
+ options.visibleStorageVolumes,
+ lastItem.integrationId,
+ );
+
+ if (fileSystem.length === 0) return ;
+
return (
{fileSystem.map((item) => {
diff --git a/packages/widgets/src/system-disks/index.ts b/packages/widgets/src/system-disks/index.ts
index 99ea9a70a..61e4a5c05 100644
--- a/packages/widgets/src/system-disks/index.ts
+++ b/packages/widgets/src/system-disks/index.ts
@@ -2,21 +2,32 @@ import { IconServer2 } from "@tabler/icons-react";
import { createWidgetDefinition } from "../definition";
import { optionsBuilder } from "../options";
+import { createStorageVolumeMultiSelectOptions } from "../storage-volume-options";
export const { definition, componentLoader } = createWidgetDefinition("systemDisks", {
icon: IconServer2,
- supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid"],
+ supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "synology"],
createOptions() {
- return optionsBuilder.from((factory) => ({
- showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
- displayMode: factory.select({
- options: (["percentage", "absolute", "free"] as const).map((value) => ({
- value,
- label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
- })),
- defaultValue: "percentage",
+ return optionsBuilder.from(
+ (factory) => ({
+ showTemperatureIfAvailable: factory.switch({ defaultValue: true }),
+ displayMode: factory.select({
+ options: (["percentage", "absolute", "free"] as const).map((value) => ({
+ value,
+ label: (t) => t(`widget.systemDisks.option.displayMode.option.${value}.label`),
+ })),
+ defaultValue: "percentage",
+ }),
+ showBackgroundBar: factory.switch({ defaultValue: true }),
+ visibleStorageVolumes: factory.integrationMultiSelect(createStorageVolumeMultiSelectOptions()),
}),
- showBackgroundBar: factory.switch({ defaultValue: true }),
- }));
+ {
+ visibleStorageVolumes: {
+ shouldHide(_, integrationKinds) {
+ return integrationKinds.length === 0 || !integrationKinds.every((kind) => kind === "synology");
+ },
+ },
+ },
+ );
},
}).withDynamicImport(() => import("./component"));
diff --git a/packages/widgets/src/system-resources/index.ts b/packages/widgets/src/system-resources/index.ts
index 68fffd4ed..8d4837187 100644
--- a/packages/widgets/src/system-resources/index.ts
+++ b/packages/widgets/src/system-resources/index.ts
@@ -14,7 +14,7 @@ const labelDisplayModeOptions = {
export const { definition, componentLoader } = createWidgetDefinition("systemResources", {
icon: IconGraphFilled,
- supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances"],
+ supportedIntegrations: ["dashDot", "openmediavault", "truenas", "unraid", "glances", "synology"],
createOptions() {
return optionsBuilder.from((factory) => ({
hasShadow: factory.switch({ defaultValue: true }),