Add system/hardware info to the Synology page

Beyond volume/disk health, DSM exposes hostname, CPU, RAM, IP
addresses, model/serial/firmware, and uptime -- surface those too as a
"System" card on the Synology page.

New adapter.getSystemInfo() combines three DSM Web API calls (fields
confirmed against the actively-maintained mib1185/py-synologydsm-api
client, which documents the same SYNO.Core.System / SYNO.Core.System.
Utilization / SYNO.DSM.Network endpoints this adapter already uses the
discover-then-call pattern for):
- SYNO.Core.System "info" -- model, serial, firmware_ver, cpu_cores,
  cpu_clock_speed, up_time (a raw uptime-command-style string, not a
  duration -- parsed client-side into "5d 15h 2m" with a fallback to
  the raw string if DSM ever returns an unexpected format).
- SYNO.Core.System.Utilization "get" -- live CPU load % (user+system+
  other) and real memory usage, in KB.
- SYNO.DSM.Network "list" -- hostname and interface IP addresses
  (loopback filtered out).

New GET /:id/synology/system route alongside the existing /storage
one, read-only like the rest of this integration.

Verified end-to-end against a local mock DSM server standing in for
the real API (login/session flow, all three endpoint shapes, IP
filtering, byte math) since this sandbox can't reach the user's LAN;
also unit-checked the client-side uptime regex against DSM's three
uptime-command formats (with/without days, minutes-only) plus its
unmatched-format fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 00:35:39 +02:00
co-authored by Claude Sonnet 5
parent 95bd831d81
commit 9f2d27e2c4
4 changed files with 197 additions and 2 deletions
+63 -1
View File
@@ -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<SynologyStorageInfo>;
getSystemInfo(): Promise<SynologySystemInfo>;
}
interface RawResponse {
@@ -218,5 +237,48 @@ export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
}
}
return { ping, getStorageInfo };
async function getSystemInfo(): Promise<SynologySystemInfo> {
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 };
}
+12
View File
@@ -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) });
}
}));
+12
View File
@@ -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<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
system: (integrationId: number) => request<SynologySystemInfo>(`/api/integrations/${integrationId}/synology/system`),
},
},
settings: {
+110 -1
View File
@@ -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<IntegrationSummary[] | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
@@ -30,6 +54,10 @@ export default function Synology({ user: _user }: { user: CurrentUser }) {
const [data, setData] = useState<SynologyStorageResponse | null>(null);
const [loading, setLoading] = useState(false);
const [system, setSystem] = useState<SynologySystemInfo | null>(null);
const [systemError, setSystemError] = useState<string | null>(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 }) {
</div>
) : (
<div className="row g-3">
<div className="col-12">
<div className="card">
<div className="card-header">
<h3 className="card-title">System</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadSystem(selected.id)}
disabled={loadingSystem}
>
{loadingSystem ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="card-body">
{systemError ? (
<div className="text-danger">{systemError}</div>
) : (
<div className="row g-3">
<div className="col-6 col-md-3">
<div className="text-secondary">Hostname</div>
<div>{system?.hostname ?? "—"}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Model</div>
<div>{system?.model ?? "—"}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Uptime</div>
<div>{formatUptime(system?.uptime ?? null)}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">IP addresses</div>
<div>{system?.ipAddresses.length ? system.ipAddresses.join(", ") : "—"}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">CPU</div>
<div>
{system?.cpu.loadPercent !== null && system?.cpu.loadPercent !== undefined
? `${system.cpu.loadPercent.toFixed(0)}%`
: "—"}
{system?.cpu.cores ? (
<span className="text-secondary">
{" "}
({system.cpu.cores} cores
{system.cpu.clockSpeedMHz ? ` @ ${(system.cpu.clockSpeedMHz / 1000).toFixed(1)} GHz` : ""})
</span>
) : null}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Memory</div>
<div>
{formatBytes(system?.memory.usedBytes ?? null)} / {formatBytes(system?.memory.totalBytes ?? null)}
</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Serial</div>
<div>{system?.serial ?? "—"}</div>
</div>
<div className="col-6 col-md-3">
<div className="text-secondary">Firmware</div>
<div>{system?.firmwareVersion ?? "—"}</div>
</div>
</div>
)}
</div>
</div>
</div>
<div className="col-12">
<div className="card">
<div className="card-header">