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
@@ -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