From 65ba8662390f1959e82f7ce6f0da9cd6791038ab Mon Sep 17 00:00:00 2001 From: Josh Preston Date: Thu, 9 Jul 2026 12:23:21 -0400 Subject: [PATCH] feat: add Synology integration support for health/system monitoring (#6121) --- CODEOWNERS | 4 + .../docs/docs/integrations/synology/index.mdx | 96 +++ apps/docs/docs/integrations/synology/index.ts | 8 + .../docs/widgets/health-monitoring/index.mdx | 84 +-- .../docs/widgets/health-monitoring/index.ts | 7 + apps/docs/docs/widgets/system-disks/index.mdx | 51 ++ apps/docs/docs/widgets/system-disks/index.ts | 41 ++ .../docs/widgets/system-resources/index.mdx | 82 +-- .../src/router/widgets/health-monitoring.ts | 35 +- .../src/docker-integration-match.ts | 1 + .../src/docs/homarr-docs-sitemap.ts | 3 + .../src/docs/integration-doc-slugs.ts | 1 + .../definitions/src/docs/widget-doc-slugs.ts | 2 +- packages/definitions/src/integration.ts | 8 + .../definitions/src/widget-integration-map.ts | 4 +- packages/integrations/src/base/creator.ts | 2 + packages/integrations/src/index.ts | 1 + .../src/synology/synology-client.ts | 411 ++++++++++++ .../src/synology/synology-integration.ts | 323 ++++++++++ .../src/synology/synology-types.ts | 191 ++++++ .../test/synology-integration.spec.ts | 585 ++++++++++++++++++ packages/old-import/src/widgets/options.ts | 1 + packages/translation/src/lang/en.json | 10 + packages/widgets/src/_inputs/common.tsx | 2 +- packages/widgets/src/_inputs/index.ts | 2 + .../widget-integration-multi-select-input.tsx | 60 ++ .../src/filter-storage-volumes.spec.ts | 146 +++++ .../widgets/src/filter-storage-volumes.ts | 67 ++ .../widgets/src/health-monitoring/index.ts | 7 + .../src/health-monitoring/system-health.tsx | 19 +- packages/widgets/src/options.ts | 15 + .../widgets/src/storage-volume-options.ts | 39 ++ .../widgets/src/system-disks/component.tsx | 18 +- packages/widgets/src/system-disks/index.ts | 33 +- .../widgets/src/system-resources/index.ts | 2 +- 35 files changed, 2263 insertions(+), 98 deletions(-) create mode 100644 apps/docs/docs/integrations/synology/index.mdx create mode 100644 apps/docs/docs/integrations/synology/index.ts create mode 100644 apps/docs/docs/widgets/system-disks/index.mdx create mode 100644 apps/docs/docs/widgets/system-disks/index.ts create mode 100644 packages/integrations/src/synology/synology-client.ts create mode 100644 packages/integrations/src/synology/synology-integration.ts create mode 100644 packages/integrations/src/synology/synology-types.ts create mode 100644 packages/integrations/src/synology/test/synology-integration.spec.ts create mode 100644 packages/widgets/src/_inputs/widget-integration-multi-select-input.tsx create mode 100644 packages/widgets/src/filter-storage-volumes.spec.ts create mode 100644 packages/widgets/src/filter-storage-volumes.ts create mode 100644 packages/widgets/src/storage-volume-options.ts diff --git a/CODEOWNERS b/CODEOWNERS index ad494df91..fc790a889 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -159,6 +159,10 @@ packages/integrations/src/search-ch @Meierschlumpf apps/docs/docs/integrations/speedtest-tracker @kylegrieder packages/integrations/src/speedtest-tracker @kylegrieder +# Synology - introduced in https://github.com/homarr-labs/homarr/pull/6121 by @mrjoshuap +apps/docs/docs/integrations/synology @mrjoshuap +packages/integrations/src/synology @mrjoshuap + # Technitium DNS - introduced in https://github.com/homarr-labs/homarr/pull/5832 by @lopsch apps/docs/docs/integrations/technitium-dns @lopsch packages/integrations/src/technitium @lopsch diff --git a/apps/docs/docs/integrations/synology/index.mdx b/apps/docs/docs/integrations/synology/index.mdx new file mode 100644 index 000000000..4d836513e --- /dev/null +++ b/apps/docs/docs/integrations/synology/index.mdx @@ -0,0 +1,96 @@ +--- +title: "Synology DiskStation" +description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM." +hide_title: true +--- + +import { IntegrationHeader } from "@site/src/components/integrations/header"; +import { IntegrationCapabilites } from "@site/src/components/integrations/widgets"; +import { AddingIntegration } from "@site/src/components/integrations/adding"; +import { IntegrationSecrets } from "@site/src/components/integrations/secrets"; +import { synologyIntegration } from "."; +import { systemResourcesWidget } from "@site/docs/widgets/system-resources"; +import { systemDisksWidget } from "@site/docs/widgets/system-disks"; +import { healthMonitoringWidget } from "@site/docs/widgets/health-monitoring"; +import Alert from "@theme/Admonition"; + + + +### Widgets & Capabilities + + + +### Supported metrics + +| Widget section | DSM API source | May be unavailable | +| ---------------------------- | ----------------------------------------------------- | ------------------------------------------------------- | +| CPU ring / chart | `SYNO.Core.System.Utilization` | — | +| Memory ring / chart | `SYNO.Core.System.Utilization` | — | +| CPU temperature ring | `SYNO.Core.System` system info | Models without a temperature sensor | +| Network charts | `SYNO.Core.System.Utilization` network totals | Some DSM builds or permission configurations | +| Load average (info modal) | `SYNO.Core.System.Utilization` CPU load fields | — | +| Storage volume usage | `SYNO.Core.System` storage info / Storage Manager API | — | +| SMART status and temperature | Storage Manager and SMART APIs | Disk-to-volume mapping may be incomplete on some setups | +| Reboot / update indicator | `SYNO.Core.System.Status` / upgrade APIs | Permission-dependent | +| GPU | Not available | Typical DiskStation models | + +### Adding the integration + + + + + Enter the HTTP or HTTPS URL of your Synology DSM web interface, for example http://diskstation.local:5000{" "} + or https://diskstation.local:5001. If DSM uses a self-signed certificate, add it under{" "} + Management > Tools > Certificates so Homarr can connect securely. + + +### Secrets + + + + Homarr reads system, storage, and utilization data from APIs that require membership in the default + Administrators group. + + + Accounts with 2-step verification enabled cannot authenticate with this integration. Use a dedicated service + account without 2FA. + + + If login fails during testing, temporarily disable automatic blocking for invalid logins under Control Panel + > Security > Protection, then re-enable it after Homarr connects successfully. + + + ), + }, + ]} +/> + +### Storage volume selection + +Health Monitoring and System Disks widgets can filter Synology storage volumes when **every** selected integration is a Synology DiskStation. Leave **Visible storage volumes** empty to show all volumes, or pick specific volumes from the list. When multiple Synology integrations are connected, volumes are labeled with the integration name. + + + The volume filter is hidden when the widget also uses non-Synology integrations such as TrueNAS or Unraid. + + + + When no volume filter is configured, the first volume returned by DSM is not guaranteed to be `volume_1`. + diff --git a/apps/docs/docs/integrations/synology/index.ts b/apps/docs/docs/integrations/synology/index.ts new file mode 100644 index 000000000..6ccb2aa9e --- /dev/null +++ b/apps/docs/docs/integrations/synology/index.ts @@ -0,0 +1,8 @@ +import { IntegrationDefinition } from "@site/src/types"; + +export const synologyIntegration: IntegrationDefinition = { + name: "Synology DiskStation", + description: "Monitor CPU, memory, storage volumes, and system health from Synology DSM.", + iconUrl: "https://cdn.jsdelivr.net/gh/homarr-labs/dashboard-icons/svg/synology.svg", + path: "../../integrations/synology", +}; diff --git a/apps/docs/docs/widgets/health-monitoring/index.mdx b/apps/docs/docs/widgets/health-monitoring/index.mdx index 9e41a5fcc..603207451 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.mdx +++ b/apps/docs/docs/widgets/health-monitoring/index.mdx @@ -1,61 +1,71 @@ --- -title: 'System Health Monitoring' -description: 'Displays information showing the health and status of your system(s).' +title: "System Health Monitoring" +description: "Displays information showing the health and status of your system(s)." hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; import { IconAd } from "@tabler/icons-react"; -import Tabs from '@theme/Tabs'; -import TabItem from '@theme/TabItem'; -import { healthMonitoringWidget } from '.'; -import { dashDotIntegration } from '@site/docs/integrations/dash-dot'; -import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault'; -import { proxmoxIntegration } from '@site/docs/integrations/proxmox'; -import { glancesIntegration } from '@site/docs/integrations/glances'; +import Tabs from "@theme/Tabs"; +import TabItem from "@theme/TabItem"; +import { healthMonitoringWidget } from "."; +import { dashDotIntegration } from "@site/docs/integrations/dash-dot"; +import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault"; +import { proxmoxIntegration } from "@site/docs/integrations/proxmox"; +import { glancesIntegration } from "@site/docs/integrations/glances"; - - + This widget displays information about the health and status of your system(s), including CPU, memory, disk usage, and more. ### Screenshots -import systemView from './img/system-view.png'; -import clusterView from './img/cluster-view.png'; -import { truenasIntegration } from '@site/docs/integrations/truenas'; +import systemView from "./img/system-view.png"; +import clusterView from "./img/cluster-view.png"; +import { truenasIntegration } from "@site/docs/integrations/truenas"; +import { synologyIntegration } from "@site/docs/integrations/synology"; health-monitoring system-view health-monitoring cluster-view ### Supported Integrations - + ### Adding the widget ### Configuration + diff --git a/apps/docs/docs/widgets/health-monitoring/index.ts b/apps/docs/docs/widgets/health-monitoring/index.ts index 2a3cc6a58..6e28a9026 100644 --- a/apps/docs/docs/widgets/health-monitoring/index.ts +++ b/apps/docs/docs/widgets/health-monitoring/index.ts @@ -38,6 +38,13 @@ export const healthMonitoringWidget: WidgetDefinition = { values: { type: "boolean" }, defaultValue: "yes", }, + { + name: "Visible storage volumes", + description: + "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.", + values: "List of storage volumes", + defaultValue: "Empty list", + }, { name: "Default tab", description: "Tab that should be visible by default. Only used when multiple integrations are available.", diff --git a/apps/docs/docs/widgets/system-disks/index.mdx b/apps/docs/docs/widgets/system-disks/index.mdx new file mode 100644 index 000000000..b3be87e1d --- /dev/null +++ b/apps/docs/docs/widgets/system-disks/index.mdx @@ -0,0 +1,51 @@ +--- +title: "System disks" +description: "Disk usage of your system" +hide_title: true +--- + +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { systemDisksWidget } from "."; +import { dashDotIntegration } from "@site/docs/integrations/dash-dot"; +import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault"; +import { truenasIntegration } from "@site/docs/integrations/truenas"; +import { unraidIntegration } from "@site/docs/integrations/unraid"; +import { synologyIntegration } from "@site/docs/integrations/synology"; + + + +This widget displays filesystem and SMART disk usage for supported server and NAS integrations. + +### Supported Integrations + + + +### Adding the widget + + + +### Configuration + + diff --git a/apps/docs/docs/widgets/system-disks/index.ts b/apps/docs/docs/widgets/system-disks/index.ts new file mode 100644 index 000000000..8dd68f057 --- /dev/null +++ b/apps/docs/docs/widgets/system-disks/index.ts @@ -0,0 +1,41 @@ +import { WidgetDefinition } from "@site/src/types"; +import { IconServer2 } from "@tabler/icons-react"; + +export const systemDisksWidget: WidgetDefinition = { + icon: IconServer2, + name: "System disks", + description: "Disk usage of your system", + path: "../../widgets/system-disks", + configuration: { + items: [ + { + name: "Show temperature if available", + description: "Display disk temperature when the integration provides it", + values: { type: "boolean" }, + defaultValue: "Yes", + }, + { + name: "Display mode", + description: "How disk usage values are shown", + values: { + type: "select", + options: ["Percentage (e.g., 76%)", "Absolute values (e.g., 800GB / 1TB)", "Free space percentage (e.g., 24% free)"], + }, + defaultValue: "Percentage (e.g., 76%)", + }, + { + name: "Show background usage bar", + description: "Fill the area below the usage value with a background bar", + values: { type: "boolean" }, + defaultValue: "Yes", + }, + { + name: "Visible storage volumes", + description: + "Only available when all selected integrations are Synology. Leave empty to show all detected storage volumes.", + values: "List of storage volumes", + defaultValue: "All volumes", + }, + ], + }, +}; diff --git a/apps/docs/docs/widgets/system-resources/index.mdx b/apps/docs/docs/widgets/system-resources/index.mdx index 143e2a9cf..16ad19750 100644 --- a/apps/docs/docs/widgets/system-resources/index.mdx +++ b/apps/docs/docs/widgets/system-resources/index.mdx @@ -1,57 +1,69 @@ --- -title: 'System Resources' -description: 'Displays CPU, RAM and network of your host in real time' +title: "System Resources" +description: "Displays CPU, RAM and network of your host in real time" hide_title: true --- -import { WidgetHeader } from '@site/src/components/widgets/header'; -import { AddingWidget } from '@site/src/components/widgets/adding'; -import { WidgetConfig } from '@site/src/components/widgets/configuration'; -import { WidgetIntegrations } from '@site/src/components/widgets/integrations'; -import { systemResourcesWidget } from '.'; -import { dashDotIntegration } from '@site/docs/integrations/dash-dot'; -import { glancesIntegration } from '@site/docs/integrations/glances'; -import { openMediaVaultIntegration } from '@site/docs/integrations/open-media-vault'; -import { proxmoxIntegration } from '@site/docs/integrations/proxmox'; +import { WidgetHeader } from "@site/src/components/widgets/header"; +import { AddingWidget } from "@site/src/components/widgets/adding"; +import { WidgetConfig } from "@site/src/components/widgets/configuration"; +import { WidgetIntegrations } from "@site/src/components/widgets/integrations"; +import { systemResourcesWidget } from "."; +import { dashDotIntegration } from "@site/docs/integrations/dash-dot"; +import { glancesIntegration } from "@site/docs/integrations/glances"; +import { openMediaVaultIntegration } from "@site/docs/integrations/open-media-vault"; +import { proxmoxIntegration } from "@site/docs/integrations/proxmox"; - - + Displays CPU, RAM and network of your host in real time ### Screenshots -import network from './img/network.png'; -import cpu from './img/cpu.png'; -import memory from './img/memory.png'; -import cpuAndMemory from './img/cpu-and-memory.png'; -import { truenasIntegration } from '@site/docs/integrations/truenas'; +import network from "./img/network.png"; +import cpu from "./img/cpu.png"; +import memory from "./img/memory.png"; +import cpuAndMemory from "./img/cpu-and-memory.png"; +import { truenasIntegration } from "@site/docs/integrations/truenas"; +import { synologyIntegration } from "@site/docs/integrations/synology"; -
-
+
+
system-resources-widget network-only icon-label -
+
system-resources-widget cpu-only no-label item-title system-resources-widget memory-only text-label
- system-resources-widget cpu-and-memory icon-text-label + system-resources-widget cpu-and-memory icon-text-label
### 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 }),