diff --git a/server/src/integrations/synology/adapter.ts b/server/src/integrations/synology/adapter.ts index d544222..7d4ca59 100644 --- a/server/src/integrations/synology/adapter.ts +++ b/server/src/integrations/synology/adapter.ts @@ -55,9 +55,28 @@ export interface SynologyStorageInfo { disks: SynologyDisk[]; } +export interface SynologySystemInfo { + hostname: string | null; + model: string | null; + serial: string | null; + firmwareVersion: string | null; + uptime: string | null; + ipAddresses: string[]; + cpu: { + cores: number | null; + clockSpeedMHz: number | null; + loadPercent: number | null; + }; + memory: { + totalBytes: number | null; + usedBytes: number | null; + }; +} + export interface SynologyAdapter { ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>; getStorageInfo(): Promise; + getSystemInfo(): Promise; } interface RawResponse { @@ -218,5 +237,48 @@ export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter { } } - return { ping, getStorageInfo }; + async function getSystemInfo(): Promise { + const [info, utilization, network] = await Promise.all([ + callApi("SYNO.Core.System", "info"), + callApi("SYNO.Core.System.Utilization", "get"), + callApi("SYNO.DSM.Network", "list"), + ]); + + const cpuLoadPercent = + utilization.cpu?.user_load !== undefined + ? Number(utilization.cpu.user_load) + Number(utilization.cpu.system_load) + Number(utilization.cpu.other_load) + : null; + + const memTotalBytes = utilization.memory?.total_real !== undefined ? Number(utilization.memory.total_real) * 1024 : null; + const memAvailBytes = utilization.memory?.avail_real !== undefined ? Number(utilization.memory.avail_real) * 1024 : null; + + const ipAddresses: string[] = []; + for (const iface of network.interfaces ?? []) { + for (const ip of iface.ip ?? []) { + if (ip.address && ip.address !== "127.0.0.1" && !ipAddresses.includes(ip.address)) { + ipAddresses.push(ip.address); + } + } + } + + return { + hostname: network.hostname ?? null, + model: info.model ?? null, + serial: info.serial ?? null, + firmwareVersion: info.firmware_ver ?? null, + uptime: info.up_time ?? null, + ipAddresses, + cpu: { + cores: info.cpu_cores !== undefined ? Number(info.cpu_cores) : null, + clockSpeedMHz: info.cpu_clock_speed !== undefined ? Number(info.cpu_clock_speed) : null, + loadPercent: cpuLoadPercent, + }, + memory: { + totalBytes: memTotalBytes, + usedBytes: memTotalBytes !== null && memAvailBytes !== null ? memTotalBytes - memAvailBytes : null, + }, + }; + } + + return { ping, getStorageInfo, getSystemInfo }; } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 59440c6..5cca002 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -704,3 +704,15 @@ integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => res.status(502).json({ error: err instanceof Error ? err.message : String(err) }); } })); + +integrationsRouter.get("/:id/synology/system", asyncHandler(async (req, res) => { + const found = await requireSynologyAdapter(req, res); + if (!found) return; + + try { + const info = await found.adapter.getSystemInfo(); + res.json(info); + } 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 55ab519..5cba1e7 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -434,6 +434,17 @@ export interface SynologyStorageResponse { summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number }; } +export interface SynologySystemInfo { + hostname: string | null; + model: string | null; + serial: string | null; + firmwareVersion: string | null; + uptime: string | null; + ipAddresses: string[]; + cpu: { cores: number | null; clockSpeedMHz: number | null; loadPercent: number | null }; + memory: { totalBytes: number | null; usedBytes: number | null }; +} + export class UnauthorizedError extends Error {} export class ForbiddenError extends Error {} @@ -669,6 +680,7 @@ export const api = { }, synology: { storage: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/storage`), + system: (integrationId: number) => request(`/api/integrations/${integrationId}/synology/system`), }, }, settings: { diff --git a/web/src/pages/Synology.tsx b/web/src/pages/Synology.tsx index 933e3ed..4fd7c9a 100644 --- a/web/src/pages/Synology.tsx +++ b/web/src/pages/Synology.tsx @@ -1,6 +1,12 @@ import { useEffect, useState } from "react"; import { Link } from "react-router-dom"; -import { api, type CurrentUser, type IntegrationSummary, type SynologyStorageResponse } from "../api/client"; +import { + api, + type CurrentUser, + type IntegrationSummary, + type SynologyStorageResponse, + type SynologySystemInfo, +} from "../api/client"; function healthBadge(status: string) { return status === "normal" ? ( @@ -22,6 +28,24 @@ function formatBytes(bytes: number | null): string { return `${value.toFixed(1)} ${units[unit]}`; } +// DSM reports uptime as the raw output of the Linux `uptime` command (e.g. +// "20:13:21 up 5 days, 15:02, 0 users, load average: ..."); pull out the +// day/hour/minute portion and fall back to the raw string if the format +// doesn't match what we expect. +function formatUptime(raw: string | null): string { + if (!raw) return "—"; + const match = raw.match(/up\s+(?:(\d+)\s+days?,\s+)?(?:(\d+):(\d+)|(\d+)\s*min)/); + if (!match) return raw; + const days = match[1] ? Number(match[1]) : 0; + const hours = match[2] ? Number(match[2]) : 0; + const minutes = match[3] ? Number(match[3]) : match[4] ? Number(match[4]) : 0; + const parts: string[] = []; + if (days) parts.push(`${days}d`); + if (days || hours) parts.push(`${hours}h`); + parts.push(`${minutes}m`); + return parts.join(" "); +} + export default function Synology({ user: _user }: { user: CurrentUser }) { const [integrations, setIntegrations] = useState(null); const [selectedId, setSelectedId] = useState(null); @@ -30,6 +54,10 @@ export default function Synology({ user: _user }: { user: CurrentUser }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(false); + const [system, setSystem] = useState(null); + const [systemError, setSystemError] = useState(null); + const [loadingSystem, setLoadingSystem] = useState(false); + useEffect(() => { api.integrations .list() @@ -54,11 +82,23 @@ export default function Synology({ user: _user }: { user: CurrentUser }) { .finally(() => setLoading(false)); } + function loadSystem(id: number) { + setLoadingSystem(true); + setSystemError(null); + api.integrations.synology + .system(id) + .then((res) => setSystem(res)) + .catch((err) => setSystemError(err instanceof Error ? err.message : String(err))) + .finally(() => setLoadingSystem(false)); + } + useEffect(() => { if (selected?.enabled) { loadStorage(selected.id); + loadSystem(selected.id); } else { setData(null); + setSystem(null); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedId]); @@ -102,6 +142,75 @@ export default function Synology({ user: _user }: { user: CurrentUser }) { ) : (
+
+
+
+

System

+
+ +
+
+
+ {systemError ? ( +
{systemError}
+ ) : ( +
+
+
Hostname
+
{system?.hostname ?? "—"}
+
+
+
Model
+
{system?.model ?? "—"}
+
+
+
Uptime
+
{formatUptime(system?.uptime ?? null)}
+
+
+
IP addresses
+
{system?.ipAddresses.length ? system.ipAddresses.join(", ") : "—"}
+
+
+
CPU
+
+ {system?.cpu.loadPercent !== null && system?.cpu.loadPercent !== undefined + ? `${system.cpu.loadPercent.toFixed(0)}%` + : "—"} + {system?.cpu.cores ? ( + + {" "} + ({system.cpu.cores} cores + {system.cpu.clockSpeedMHz ? ` @ ${(system.cpu.clockSpeedMHz / 1000).toFixed(1)} GHz` : ""}) + + ) : null} +
+
+
+
Memory
+
+ {formatBytes(system?.memory.usedBytes ?? null)} / {formatBytes(system?.memory.totalBytes ?? null)} +
+
+
+
Serial
+
{system?.serial ?? "—"}
+
+
+
Firmware
+
{system?.firmwareVersion ?? "—"}
+
+
+ )} +
+
+