From a83a4b3b11db9c5ecb48d330d48545c6c9b74d01 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Tue, 15 Sep 2026 00:35:21 +0200 Subject: [PATCH] =?UTF-8?q?Add=20Synology=20integration=20=E2=80=94=20comp?= =?UTF-8?q?letes=20all=20six=20planned=20live=20integrations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth and final live integration: volume and disk health across the NAS, read-only per the delivery plan (DSM write actions are riskier and stayed out of scope). Adapter built against the same discover-then-call pattern used by hacf-fr/synologydsm-api (the library behind Home Assistant's Synology integration) since DSM's API paths/versions vary by release and the user's NAS isn't reachable from here to check directly: - GET query.cgi?api=SYNO.API.Info&method=query&query=all once per adapter instance, to learn the real path/version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than hardcoding them - SYNO.API.Auth login (account/passwd/format=sid) for a session id, re-used across calls and refreshed on session-related error codes (105/106/119) - SYNO.Storage.CGI.Storage's `load_info` method, which returns disks, volumes, and pools in one call — verified against that library's storage.py field mapping (size.total/used, status, smart_status, temp, exceed_bad_sector_thr, below_remain_life_thr) Uses node:https directly (like Proxmox and the cPanel DNS adapter) for an "allow self-signed certificate" option, since DSM ships one by default. No 2FA support — login surfaces a clear error if the account requires it rather than failing silently. Verified: full build passes. Unreachable from here, so ran an 11-check HTTP test against the live server: confirms all six integration types are now registered, role gating, credential (password) non-leakage, disabled- integration blocking, and the wrong_type crash-safety check — server stays up throughout. Real volume/disk data still needs verification once this app can reach the user's NAS. This completes the integrations phase from the original plan: Tailscale, Gitea, Dockhand, Semaphore, Proxmox, Synology are all built, each following the same config-in-UI + encrypted-credentials pattern. Combined with the foundation, Secrets, IPAM, DNS, and Servers & Tasks modules from earlier, Homelab Manager now covers every feature area from the user's original request. Co-Authored-By: Claude Sonnet 5 --- server/src/integrations/fieldSchemas.ts | 6 + server/src/integrations/registry.ts | 3 + server/src/integrations/synology/adapter.ts | 214 ++++++++++++++++++++ server/src/routes/integrations.ts | 42 ++++ web/src/api/client.ts | 28 +++ web/src/pages/Dashboard.tsx | 40 +++- web/src/pages/Integrations.tsx | 133 ++++++++++++ 7 files changed, 465 insertions(+), 1 deletion(-) create mode 100644 server/src/integrations/synology/adapter.ts diff --git a/server/src/integrations/fieldSchemas.ts b/server/src/integrations/fieldSchemas.ts index c9cd9b4..f166ea1 100644 --- a/server/src/integrations/fieldSchemas.ts +++ b/server/src/integrations/fieldSchemas.ts @@ -33,6 +33,12 @@ export const INTEGRATION_FIELDS: Partial; @@ -29,6 +30,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat return createSemaphoreAdapter(config as any); case "proxmox": return createProxmoxAdapter(config as any); + case "synology": + return createSynologyAdapter(config as any); default: throw new Error(`Integration type "${type}" is not implemented yet`); } diff --git a/server/src/integrations/synology/adapter.ts b/server/src/integrations/synology/adapter.ts new file mode 100644 index 0000000..c1d6bb1 --- /dev/null +++ b/server/src/integrations/synology/adapter.ts @@ -0,0 +1,214 @@ +/** + * Synology DSM adapter — uses the DSM Web API (webapi/*.cgi). + * Requires config: url, username, password; optional: insecure + * + * Read-only by design (per the delivery plan — DSM write actions are riskier + * and out of scope for v1): reports volume/pool usage and disk health only. + * + * DSM's API paths and versions vary by DSM release, so — like every + * well-behaved DSM client (this follows the same discover-then-call pattern + * as hacf-fr/synologydsm-api, the library behind Home Assistant's Synology + * integration) — this first queries SYNO.API.Info to learn the real path and + * version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than + * hardcoding them. SYNO.Storage.CGI.Storage's `load_info` method returns + * disks, volumes, and storage pools in one call (verified against that + * library's storage.py wrapper). + * + * DSM ships with a self-signed certificate unless the admin configured a + * real one, so (like the cPanel/Proxmox adapters) this uses node:https + * directly to support an "insecure" opt-out of certificate verification. + */ +import * as https from "node:https"; + +export interface SynologyConfig { + url: string; + username: string; + password: string; + insecure?: boolean; +} + +export interface SynologyVolume { + id: string; + status: string; // "normal" | "degraded" | "crashed" | ... + deviceType: string; + sizeTotal: number | null; + sizeUsed: number | null; +} + +export interface SynologyDisk { + id: string; + name: string; + device: string; + status: string; // "normal" | "system_partition_failed" | "crashed" | ... + smartStatus: string; + temp: number | null; + exceedBadSectorThreshold: boolean; + belowRemainLifeThreshold: boolean; +} + +export interface SynologyStorageInfo { + volumes: SynologyVolume[]; + disks: SynologyDisk[]; +} + +export interface SynologyAdapter { + ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; + getStorageInfo(): Promise; +} + +interface RawResponse { + status: number; + text: () => string; +} + +function request(url: string, insecure: boolean): Promise { + return new Promise((resolve, reject) => { + const parsed = new URL(url); + const req = https.request( + { + hostname: parsed.hostname, + port: parsed.port || 5001, + path: parsed.pathname + parsed.search, + method: "GET", + rejectUnauthorized: !insecure, + }, + (res) => { + let body = ""; + res.setEncoding("utf8"); + res.on("data", (chunk) => { + body += chunk; + }); + res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body })); + }, + ); + req.on("error", reject); + req.end(); + }); +} + +interface ApiInfo { + path: string; + maxVersion: number; +} + +export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter { + const insecure = config.insecure === true; + let apiMap: Record | null = null; + let sid: string | null = null; + + function base() { + return config.url.replace(/\/$/, ""); + } + + async function rawGet(path: string, params: Record): Promise { + const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString(); + const res = await request(`${base()}/webapi/${path}?${qs}`, insecure); + let data: any; + try { + data = JSON.parse(res.text()); + } catch { + throw new Error(`Synology API returned non-JSON response (HTTP ${res.status})`); + } + if (res.status < 200 || res.status >= 300) { + throw new Error(`Synology API error: HTTP ${res.status}`); + } + return data; + } + + async function discoverApis(): Promise> { + if (apiMap) return apiMap; + const data = await rawGet("query.cgi", { api: "SYNO.API.Info", version: 1, method: "query", query: "all" }); + if (!data.success) throw new Error(data.error?.code ? `Synology API.Info error ${data.error.code}` : "Synology API.Info query failed"); + apiMap = data.data; + return apiMap!; + } + + async function login(): Promise { + const apis = await discoverApis(); + const authInfo = apis["SYNO.API.Auth"]; + if (!authInfo) throw new Error("SYNO.API.Auth not available on this DSM instance"); + + const data = await rawGet(authInfo.path, { + api: "SYNO.API.Auth", + version: authInfo.maxVersion, + method: "login", + account: config.username, + passwd: config.password, + format: "sid", + }); + + if (!data.success) { + const code = data.error?.code; + const messages: Record = { + 400: "Invalid username or password", + 401: "Account disabled", + 402: "Permission denied", + 403: "2-factor authentication is required — not supported by this integration", + 404: "2-factor authentication code was rejected", + }; + throw new Error((code && messages[code]) || `Synology login failed (error ${code ?? "unknown"})`); + } + + sid = data.data.sid; + return sid!; + } + + async function callApi(apiName: string, method: string, extraParams: Record = {}): Promise { + const apis = await discoverApis(); + const info = apis[apiName]; + if (!info) throw new Error(`${apiName} is not available on this DSM instance`); + + if (!sid) await login(); + + const doCall = async () => + rawGet(info.path, { api: apiName, version: info.maxVersion, method, _sid: sid!, ...extraParams }); + + let data = await doCall(); + // Session-related error codes (105 permission/session, 106 session timeout, + // 119 invalid session) -> re-login once and retry. + if (!data.success && [105, 106, 119].includes(data.error?.code)) { + sid = null; + await login(); + data = await doCall(); + } + + if (!data.success) { + throw new Error(`${apiName} error ${data.error?.code ?? "unknown"}`); + } + return data.data; + } + + async function getStorageInfo(): Promise { + const data = await callApi("SYNO.Storage.CGI.Storage", "load_info"); + const volumes: SynologyVolume[] = (data.volumes ?? []).map((v: any) => ({ + id: v.id, + status: v.status, + deviceType: v.device_type, + sizeTotal: v.size?.total !== undefined ? Number(v.size.total) : null, + sizeUsed: v.size?.used !== undefined ? Number(v.size.used) : null, + })); + const disks: SynologyDisk[] = (data.disks ?? []).map((d: any) => ({ + id: d.id, + name: d.name, + device: d.device, + status: d.status, + smartStatus: d.smart_status, + temp: d.temp ?? null, + exceedBadSectorThreshold: !!d.exceed_bad_sector_thr, + belowRemainLifeThreshold: !!d.below_remain_life_thr, + })); + return { volumes, disks }; + } + + async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> { + const start = Date.now(); + try { + await login(); + return { ok: true, latencyMs: Date.now() - start }; + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) }; + } + } + + return { ping, getStorageInfo }; +} diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 3476013..72d77f1 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -19,6 +19,7 @@ import { createGiteaAdapter } from "../integrations/gitea/adapter.js"; import { createDockhandAdapter } from "../integrations/dockhand/adapter.js"; import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js"; import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js"; +import { createSynologyAdapter } from "../integrations/synology/adapter.js"; import { asyncHandler } from "../utils/asyncHandler.js"; export const integrationsRouter = Router(); @@ -614,3 +615,44 @@ for (const action of proxmoxActions) { }), ); } + +// ─── Synology ──────────────────────────────────────────────────────────────── +// Read-only per the delivery plan — no start/stop/etc actions. + +async function requireSynologyAdapter(req: Request, res: Response) { + const id = Number(req.params.id); + const loaded = await loadIntegrationConfig(id); + if (!loaded) { + res.status(404).json({ error: "not_found" }); + return null; + } + if (loaded.integration.type !== "synology") { + res.status(400).json({ error: "wrong_type" }); + return null; + } + if (!loaded.integration.enabled) { + res.status(400).json({ error: "integration_disabled" }); + return null; + } + return { integration: loaded.integration, adapter: createSynologyAdapter(loaded.config as any) }; +} + +integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => { + const found = await requireSynologyAdapter(req, res); + if (!found) return; + + try { + const info = await found.adapter.getStorageInfo(); + res.json({ + ...info, + summary: { + volumeCount: info.volumes.length, + volumesNotNormal: info.volumes.filter((v) => v.status !== "normal").length, + diskCount: info.disks.length, + disksNotNormal: info.disks.filter((d) => d.status !== "normal").length, + }, + }); + } catch (err) { + res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); + } +})); diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 6bfcd25..b994289 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -290,6 +290,31 @@ export interface ProxmoxGuestsResponse { summary: { total: number; running: number }; } +export interface SynologyVolume { + id: string; + status: string; + deviceType: string; + sizeTotal: number | null; + sizeUsed: number | null; +} + +export interface SynologyDisk { + id: string; + name: string; + device: string; + status: string; + smartStatus: string; + temp: number | null; + exceedBadSectorThreshold: boolean; + belowRemainLifeThreshold: boolean; +} + +export interface SynologyStorageResponse { + volumes: SynologyVolume[]; + disks: SynologyDisk[]; + summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number }; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -505,5 +530,8 @@ export const api = { restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }), }, + synology: { + storage: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/storage`), + }, }, }; diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx index 9fcd663..ef7d115 100644 --- a/web/src/pages/Dashboard.tsx +++ b/web/src/pages/Dashboard.tsx @@ -31,6 +31,12 @@ interface ProxmoxSummary { total: number; } +interface SynologySummary { + volumeCount: number; + volumesNotNormal: number; + disksNotNormal: number; +} + export default function Dashboard({ user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [tailscaleSummary, setTailscaleSummary] = useState(null); @@ -38,6 +44,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const [dockhandSummary, setDockhandSummary] = useState(null); const [semaphoreSummary, setSemaphoreSummary] = useState(null); const [proxmoxSummary, setProxmoxSummary] = useState(null); + const [synologySummary, setSynologySummary] = useState(null); useEffect(() => { api.integrations.list().then((res) => setIntegrations(res.integrations)); @@ -108,6 +115,24 @@ export default function Dashboard({ user }: { user: CurrentUser }) { .catch(() => setProxmoxSummary(null)); }, [integrations]); + useEffect(() => { + const synology = integrations?.find((i) => i.type === "synology" && i.enabled); + if (!synology) { + setSynologySummary(null); + return; + } + api.integrations.synology + .storage(synology.id) + .then((res) => + setSynologySummary({ + volumeCount: res.summary.volumeCount, + volumesNotNormal: res.summary.volumesNotNormal, + disksNotNormal: res.summary.disksNotNormal, + }), + ) + .catch(() => setSynologySummary(null)); + }, [integrations]); + return ( <>
@@ -128,7 +153,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) { const isLiveDockhand = type === "dockhand" && integration && dockhandSummary; const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary; const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary; - const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox; + const isLiveSynology = type === "synology" && integration && synologySummary; + const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology; return (
@@ -186,6 +212,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
VMs/containers running
+ ) : isLiveSynology ? ( +
+
{synologySummary!.volumeCount}
+
volumes
+ {(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && ( +
+ {synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`} + {synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "} + {synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`} +
+ )} +
) : (
{integration ? ( diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx index 14075d6..1b75d1d 100644 --- a/web/src/pages/Integrations.tsx +++ b/web/src/pages/Integrations.tsx @@ -11,6 +11,7 @@ import { type ProxmoxGuestsResponse, type SemaphoreTemplate, type SemaphoreTemplatesResponse, + type SynologyStorageResponse, type TailscaleDevice, type TailscaleDevicesResponse, } from "../api/client"; @@ -24,6 +25,26 @@ function proxmoxStatusBadge(status: string) { ); } +function healthBadge(status: string) { + return status === "normal" ? ( + Normal + ) : ( + {status} + ); +} + +function formatBytes(bytes: number | null): string { + if (bytes === null) return "—"; + const units = ["B", "KB", "MB", "GB", "TB", "PB"]; + let value = bytes; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit++; + } + return `${value.toFixed(1)} ${units[unit]}`; +} + function formatUptime(seconds: number | null): string { if (!seconds) return "—"; const days = Math.floor(seconds / 86400); @@ -141,6 +162,10 @@ export default function Integrations({ user }: { user: CurrentUser }) { const [loadingGuests, setLoadingGuests] = useState(false); const [actingOnGuest, setActingOnGuest] = useState(null); + const [synologyData, setSynologyData] = useState(null); + const [synologyError, setSynologyError] = useState(null); + const [loadingStorage, setLoadingStorage] = useState(false); + function loadIntegrations() { api.integrations .list() @@ -211,9 +236,24 @@ export default function Integrations({ user }: { user: CurrentUser }) { } else { setProxmoxData(null); } + if (selected?.type === "synology" && selected.enabled && !managing) { + loadSynologyStorage(selected.id); + } else { + setSynologyData(null); + } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId, managing, integrations]); + function loadSynologyStorage(id: number) { + setLoadingStorage(true); + setSynologyError(null); + api.integrations.synology + .storage(id) + .then((res) => setSynologyData(res)) + .catch((err) => setSynologyError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingStorage(false)); + } + function loadSemaphoreTemplates(id: number) { setLoadingTemplates(true); setSemaphoreError(null); @@ -820,6 +860,99 @@ export default function Integrations({ user }: { user: CurrentUser }) {
+ ) : selected?.type === "synology" ? ( +
+
+
+
+

Volumes

+
+ +
+
+ {synologyError &&
{synologyError}
} +
+ + + + + + + + + + + {synologyData?.volumes.map((v) => ( + + + + + + + ))} + {synologyData?.volumes.length === 0 && ( + + + + )} + +
VolumeTypeUsedStatus
{v.id}{v.deviceType} + {formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)} + {healthBadge(v.status)}
+ No volumes found. +
+
+
+
+
+
+
+

Disks

+
+
+ + + + + + + + + + + + {synologyData?.disks.map((d) => ( + + + + + + + + ))} + {synologyData?.disks.length === 0 && ( + + + + )} + +
DiskDeviceTempS.M.A.R.T.Status
{d.name}{d.device}{d.temp !== null ? `${d.temp}°C` : "—"}{healthBadge(d.smartStatus)} + {healthBadge(d.status)} + {d.exceedBadSectorThreshold && Bad sectors} + {d.belowRemainLifeThreshold && Low life} +
+ No disks found. +
+
+
+
+
) : (