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[];
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
@@ -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) });
|
||||
}
|
||||
}));
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in new issue
Block a user