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:
1 parent
95bd831d81
commit
9f2d27e2c4
4 files changed
+197
-2
No files matched your search
@@ -55,9 +55,28 @@ export interface SynologyStorageInfo {
|
|||||||
disks: SynologyDisk[];
|
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 {
|
export interface SynologyAdapter {
|
||||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||||
getStorageInfo(): Promise<SynologyStorageInfo>;
|
getStorageInfo(): Promise<SynologyStorageInfo>;
|
||||||
|
getSystemInfo(): Promise<SynologySystemInfo>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawResponse {
|
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 };
|
||||||
}
|
}
|
||||||
@@ -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) });
|
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) });
|
||||||
|
}
|
||||||
|
}));
|
||||||
@@ -434,6 +434,17 @@ export interface SynologyStorageResponse {
|
|||||||
summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number };
|
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 UnauthorizedError extends Error {}
|
||||||
export class ForbiddenError extends Error {}
|
export class ForbiddenError extends Error {}
|
||||||
|
|
||||||
@@ -669,6 +680,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
synology: {
|
synology: {
|
||||||
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
|
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
|
||||||
|
system: (integrationId: number) => request<SynologySystemInfo>(`/api/integrations/${integrationId}/synology/system`),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
|
|||||||
+110
-1
@@ -1,6 +1,12 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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) {
|
function healthBadge(status: string) {
|
||||||
return status === "normal" ? (
|
return status === "normal" ? (
|
||||||
@@ -22,6 +28,24 @@ function formatBytes(bytes: number | null): string {
|
|||||||
return `${value.toFixed(1)} ${units[unit]}`;
|
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 }) {
|
export default function Synology({ user: _user }: { user: CurrentUser }) {
|
||||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||||
const [selectedId, setSelectedId] = useState<number | 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 [data, setData] = useState<SynologyStorageResponse | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
api.integrations
|
api.integrations
|
||||||
.list()
|
.list()
|
||||||
@@ -54,11 +82,23 @@ export default function Synology({ user: _user }: { user: CurrentUser }) {
|
|||||||
.finally(() => setLoading(false));
|
.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(() => {
|
useEffect(() => {
|
||||||
if (selected?.enabled) {
|
if (selected?.enabled) {
|
||||||
loadStorage(selected.id);
|
loadStorage(selected.id);
|
||||||
|
loadSystem(selected.id);
|
||||||
} else {
|
} else {
|
||||||
setData(null);
|
setData(null);
|
||||||
|
setSystem(null);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [selectedId]);
|
}, [selectedId]);
|
||||||
@@ -102,6 +142,75 @@ export default function Synology({ user: _user }: { user: CurrentUser }) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="row g-3">
|
<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="col-12">
|
||||||
<div className="card">
|
<div className="card">
|
||||||
<div className="card-header">
|
<div className="card-header">
|
||||||
|
|||||||
Reference in new issue
Block a user