Add Synology integration — completes all six planned live integrations
Sixth and final live integration: volume and disk health across the NAS, read-only per the delivery plan (DSM write actions are riskier and stayed out of scope). Adapter built against the same discover-then-call pattern used by hacf-fr/synologydsm-api (the library behind Home Assistant's Synology integration) since DSM's API paths/versions vary by release and the user's NAS isn't reachable from here to check directly: - GET query.cgi?api=SYNO.API.Info&method=query&query=all once per adapter instance, to learn the real path/version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than hardcoding them - SYNO.API.Auth login (account/passwd/format=sid) for a session id, re-used across calls and refreshed on session-related error codes (105/106/119) - SYNO.Storage.CGI.Storage's `load_info` method, which returns disks, volumes, and pools in one call — verified against that library's storage.py field mapping (size.total/used, status, smart_status, temp, exceed_bad_sector_thr, below_remain_life_thr) Uses node:https directly (like Proxmox and the cPanel DNS adapter) for an "allow self-signed certificate" option, since DSM ships one by default. No 2FA support — login surfaces a clear error if the account requires it rather than failing silently. Verified: full build passes. Unreachable from here, so ran an 11-check HTTP test against the live server: confirms all six integration types are now registered, role gating, credential (password) non-leakage, disabled- integration blocking, and the wrong_type crash-safety check — server stays up throughout. Real volume/disk data still needs verification once this app can reach the user's NAS. This completes the integrations phase from the original plan: Tailscale, Gitea, Dockhand, Semaphore, Proxmox, Synology are all built, each following the same config-in-UI + encrypted-credentials pattern. Combined with the foundation, Secrets, IPAM, DNS, and Servers & Tasks modules from earlier, Homelab Manager now covers every feature area from the user's original request. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ae39f18755
commit
a83a4b3b11
7 files changed
+465
-1
No files matched your search
@@ -33,6 +33,12 @@ export const INTEGRATION_FIELDS: Partial<Record<IntegrationType, IntegrationFiel
|
||||
{ key: "tokenSecret", label: "API token secret", secret: true, type: "password" },
|
||||
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
||||
],
|
||||
synology: [
|
||||
{ key: "url", label: "Synology DSM URL", secret: false, placeholder: "https://nas.example.lan:5001" },
|
||||
{ key: "username", label: "Username", secret: false },
|
||||
{ key: "password", label: "Password", secret: true, type: "password" },
|
||||
{ key: "insecure", label: "Allow self-signed certificate", secret: false, type: "checkbox" },
|
||||
],
|
||||
};
|
||||
|
||||
/** Fixed base URL per integration type, stored on the row for display/reference. */
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createGiteaAdapter } from "./gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "./dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "./semaphore/adapter.js";
|
||||
import { createProxmoxAdapter } from "./proxmox/adapter.js";
|
||||
import { createSynologyAdapter } from "./synology/adapter.js";
|
||||
|
||||
export interface PingableAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
@@ -29,6 +30,8 @@ export function createIntegrationAdapter(type: IntegrationType, config: Integrat
|
||||
return createSemaphoreAdapter(config as any);
|
||||
case "proxmox":
|
||||
return createProxmoxAdapter(config as any);
|
||||
case "synology":
|
||||
return createSynologyAdapter(config as any);
|
||||
default:
|
||||
throw new Error(`Integration type "${type}" is not implemented yet`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* Synology DSM adapter — uses the DSM Web API (webapi/*.cgi).
|
||||
* Requires config: url, username, password; optional: insecure
|
||||
*
|
||||
* Read-only by design (per the delivery plan — DSM write actions are riskier
|
||||
* and out of scope for v1): reports volume/pool usage and disk health only.
|
||||
*
|
||||
* DSM's API paths and versions vary by DSM release, so — like every
|
||||
* well-behaved DSM client (this follows the same discover-then-call pattern
|
||||
* as hacf-fr/synologydsm-api, the library behind Home Assistant's Synology
|
||||
* integration) — this first queries SYNO.API.Info to learn the real path and
|
||||
* version for SYNO.API.Auth and SYNO.Storage.CGI.Storage rather than
|
||||
* hardcoding them. SYNO.Storage.CGI.Storage's `load_info` method returns
|
||||
* disks, volumes, and storage pools in one call (verified against that
|
||||
* library's storage.py wrapper).
|
||||
*
|
||||
* DSM ships with a self-signed certificate unless the admin configured a
|
||||
* real one, so (like the cPanel/Proxmox adapters) this uses node:https
|
||||
* directly to support an "insecure" opt-out of certificate verification.
|
||||
*/
|
||||
import * as https from "node:https";
|
||||
|
||||
export interface SynologyConfig {
|
||||
url: string;
|
||||
username: string;
|
||||
password: string;
|
||||
insecure?: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyVolume {
|
||||
id: string;
|
||||
status: string; // "normal" | "degraded" | "crashed" | ...
|
||||
deviceType: string;
|
||||
sizeTotal: number | null;
|
||||
sizeUsed: number | null;
|
||||
}
|
||||
|
||||
export interface SynologyDisk {
|
||||
id: string;
|
||||
name: string;
|
||||
device: string;
|
||||
status: string; // "normal" | "system_partition_failed" | "crashed" | ...
|
||||
smartStatus: string;
|
||||
temp: number | null;
|
||||
exceedBadSectorThreshold: boolean;
|
||||
belowRemainLifeThreshold: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyStorageInfo {
|
||||
volumes: SynologyVolume[];
|
||||
disks: SynologyDisk[];
|
||||
}
|
||||
|
||||
export interface SynologyAdapter {
|
||||
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
|
||||
getStorageInfo(): Promise<SynologyStorageInfo>;
|
||||
}
|
||||
|
||||
interface RawResponse {
|
||||
status: number;
|
||||
text: () => string;
|
||||
}
|
||||
|
||||
function request(url: string, insecure: boolean): Promise<RawResponse> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const req = https.request(
|
||||
{
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || 5001,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: "GET",
|
||||
rejectUnauthorized: !insecure,
|
||||
},
|
||||
(res) => {
|
||||
let body = "";
|
||||
res.setEncoding("utf8");
|
||||
res.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
|
||||
},
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
interface ApiInfo {
|
||||
path: string;
|
||||
maxVersion: number;
|
||||
}
|
||||
|
||||
export function createSynologyAdapter(config: SynologyConfig): SynologyAdapter {
|
||||
const insecure = config.insecure === true;
|
||||
let apiMap: Record<string, ApiInfo> | null = null;
|
||||
let sid: string | null = null;
|
||||
|
||||
function base() {
|
||||
return config.url.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async function rawGet(path: string, params: Record<string, string | number>): Promise<any> {
|
||||
const qs = new URLSearchParams(Object.entries(params).map(([k, v]) => [k, String(v)])).toString();
|
||||
const res = await request(`${base()}/webapi/${path}?${qs}`, insecure);
|
||||
let data: any;
|
||||
try {
|
||||
data = JSON.parse(res.text());
|
||||
} catch {
|
||||
throw new Error(`Synology API returned non-JSON response (HTTP ${res.status})`);
|
||||
}
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Synology API error: HTTP ${res.status}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function discoverApis(): Promise<Record<string, ApiInfo>> {
|
||||
if (apiMap) return apiMap;
|
||||
const data = await rawGet("query.cgi", { api: "SYNO.API.Info", version: 1, method: "query", query: "all" });
|
||||
if (!data.success) throw new Error(data.error?.code ? `Synology API.Info error ${data.error.code}` : "Synology API.Info query failed");
|
||||
apiMap = data.data;
|
||||
return apiMap!;
|
||||
}
|
||||
|
||||
async function login(): Promise<string> {
|
||||
const apis = await discoverApis();
|
||||
const authInfo = apis["SYNO.API.Auth"];
|
||||
if (!authInfo) throw new Error("SYNO.API.Auth not available on this DSM instance");
|
||||
|
||||
const data = await rawGet(authInfo.path, {
|
||||
api: "SYNO.API.Auth",
|
||||
version: authInfo.maxVersion,
|
||||
method: "login",
|
||||
account: config.username,
|
||||
passwd: config.password,
|
||||
format: "sid",
|
||||
});
|
||||
|
||||
if (!data.success) {
|
||||
const code = data.error?.code;
|
||||
const messages: Record<number, string> = {
|
||||
400: "Invalid username or password",
|
||||
401: "Account disabled",
|
||||
402: "Permission denied",
|
||||
403: "2-factor authentication is required — not supported by this integration",
|
||||
404: "2-factor authentication code was rejected",
|
||||
};
|
||||
throw new Error((code && messages[code]) || `Synology login failed (error ${code ?? "unknown"})`);
|
||||
}
|
||||
|
||||
sid = data.data.sid;
|
||||
return sid!;
|
||||
}
|
||||
|
||||
async function callApi(apiName: string, method: string, extraParams: Record<string, string | number> = {}): Promise<any> {
|
||||
const apis = await discoverApis();
|
||||
const info = apis[apiName];
|
||||
if (!info) throw new Error(`${apiName} is not available on this DSM instance`);
|
||||
|
||||
if (!sid) await login();
|
||||
|
||||
const doCall = async () =>
|
||||
rawGet(info.path, { api: apiName, version: info.maxVersion, method, _sid: sid!, ...extraParams });
|
||||
|
||||
let data = await doCall();
|
||||
// Session-related error codes (105 permission/session, 106 session timeout,
|
||||
// 119 invalid session) -> re-login once and retry.
|
||||
if (!data.success && [105, 106, 119].includes(data.error?.code)) {
|
||||
sid = null;
|
||||
await login();
|
||||
data = await doCall();
|
||||
}
|
||||
|
||||
if (!data.success) {
|
||||
throw new Error(`${apiName} error ${data.error?.code ?? "unknown"}`);
|
||||
}
|
||||
return data.data;
|
||||
}
|
||||
|
||||
async function getStorageInfo(): Promise<SynologyStorageInfo> {
|
||||
const data = await callApi("SYNO.Storage.CGI.Storage", "load_info");
|
||||
const volumes: SynologyVolume[] = (data.volumes ?? []).map((v: any) => ({
|
||||
id: v.id,
|
||||
status: v.status,
|
||||
deviceType: v.device_type,
|
||||
sizeTotal: v.size?.total !== undefined ? Number(v.size.total) : null,
|
||||
sizeUsed: v.size?.used !== undefined ? Number(v.size.used) : null,
|
||||
}));
|
||||
const disks: SynologyDisk[] = (data.disks ?? []).map((d: any) => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
device: d.device,
|
||||
status: d.status,
|
||||
smartStatus: d.smart_status,
|
||||
temp: d.temp ?? null,
|
||||
exceedBadSectorThreshold: !!d.exceed_bad_sector_thr,
|
||||
belowRemainLifeThreshold: !!d.below_remain_life_thr,
|
||||
}));
|
||||
return { volumes, disks };
|
||||
}
|
||||
|
||||
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
await login();
|
||||
return { ok: true, latencyMs: Date.now() - start };
|
||||
} catch (err) {
|
||||
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
||||
}
|
||||
}
|
||||
|
||||
return { ping, getStorageInfo };
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { createGiteaAdapter } from "../integrations/gitea/adapter.js";
|
||||
import { createDockhandAdapter } from "../integrations/dockhand/adapter.js";
|
||||
import { createSemaphoreAdapter } from "../integrations/semaphore/adapter.js";
|
||||
import { createProxmoxAdapter } from "../integrations/proxmox/adapter.js";
|
||||
import { createSynologyAdapter } from "../integrations/synology/adapter.js";
|
||||
import { asyncHandler } from "../utils/asyncHandler.js";
|
||||
|
||||
export const integrationsRouter = Router();
|
||||
@@ -614,3 +615,44 @@ for (const action of proxmoxActions) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Synology ────────────────────────────────────────────────────────────────
|
||||
// Read-only per the delivery plan — no start/stop/etc actions.
|
||||
|
||||
async function requireSynologyAdapter(req: Request, res: Response) {
|
||||
const id = Number(req.params.id);
|
||||
const loaded = await loadIntegrationConfig(id);
|
||||
if (!loaded) {
|
||||
res.status(404).json({ error: "not_found" });
|
||||
return null;
|
||||
}
|
||||
if (loaded.integration.type !== "synology") {
|
||||
res.status(400).json({ error: "wrong_type" });
|
||||
return null;
|
||||
}
|
||||
if (!loaded.integration.enabled) {
|
||||
res.status(400).json({ error: "integration_disabled" });
|
||||
return null;
|
||||
}
|
||||
return { integration: loaded.integration, adapter: createSynologyAdapter(loaded.config as any) };
|
||||
}
|
||||
|
||||
integrationsRouter.get("/:id/synology/storage", asyncHandler(async (req, res) => {
|
||||
const found = await requireSynologyAdapter(req, res);
|
||||
if (!found) return;
|
||||
|
||||
try {
|
||||
const info = await found.adapter.getStorageInfo();
|
||||
res.json({
|
||||
...info,
|
||||
summary: {
|
||||
volumeCount: info.volumes.length,
|
||||
volumesNotNormal: info.volumes.filter((v) => v.status !== "normal").length,
|
||||
diskCount: info.disks.length,
|
||||
disksNotNormal: info.disks.filter((d) => d.status !== "normal").length,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}));
|
||||
@@ -290,6 +290,31 @@ export interface ProxmoxGuestsResponse {
|
||||
summary: { total: number; running: number };
|
||||
}
|
||||
|
||||
export interface SynologyVolume {
|
||||
id: string;
|
||||
status: string;
|
||||
deviceType: string;
|
||||
sizeTotal: number | null;
|
||||
sizeUsed: number | null;
|
||||
}
|
||||
|
||||
export interface SynologyDisk {
|
||||
id: string;
|
||||
name: string;
|
||||
device: string;
|
||||
status: string;
|
||||
smartStatus: string;
|
||||
temp: number | null;
|
||||
exceedBadSectorThreshold: boolean;
|
||||
belowRemainLifeThreshold: boolean;
|
||||
}
|
||||
|
||||
export interface SynologyStorageResponse {
|
||||
volumes: SynologyVolume[];
|
||||
disks: SynologyDisk[];
|
||||
summary: { volumeCount: number; volumesNotNormal: number; diskCount: number; disksNotNormal: number };
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {}
|
||||
export class ForbiddenError extends Error {}
|
||||
|
||||
@@ -505,5 +530,8 @@ export const api = {
|
||||
restart: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
|
||||
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/restart`, { method: "POST" }),
|
||||
},
|
||||
synology: {
|
||||
storage: (integrationId: number) => request<SynologyStorageResponse>(`/api/integrations/${integrationId}/synology/storage`),
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -31,6 +31,12 @@ interface ProxmoxSummary {
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface SynologySummary {
|
||||
volumeCount: number;
|
||||
volumesNotNormal: number;
|
||||
disksNotNormal: number;
|
||||
}
|
||||
|
||||
export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [tailscaleSummary, setTailscaleSummary] = useState<TailscaleDevicesResponse["summary"] | null>(null);
|
||||
@@ -38,6 +44,7 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const [dockhandSummary, setDockhandSummary] = useState<DockhandSummary | null>(null);
|
||||
const [semaphoreSummary, setSemaphoreSummary] = useState<SemaphoreSummary | null>(null);
|
||||
const [proxmoxSummary, setProxmoxSummary] = useState<ProxmoxSummary | null>(null);
|
||||
const [synologySummary, setSynologySummary] = useState<SynologySummary | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations.list().then((res) => setIntegrations(res.integrations));
|
||||
@@ -108,6 +115,24 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
.catch(() => setProxmoxSummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
useEffect(() => {
|
||||
const synology = integrations?.find((i) => i.type === "synology" && i.enabled);
|
||||
if (!synology) {
|
||||
setSynologySummary(null);
|
||||
return;
|
||||
}
|
||||
api.integrations.synology
|
||||
.storage(synology.id)
|
||||
.then((res) =>
|
||||
setSynologySummary({
|
||||
volumeCount: res.summary.volumeCount,
|
||||
volumesNotNormal: res.summary.volumesNotNormal,
|
||||
disksNotNormal: res.summary.disksNotNormal,
|
||||
}),
|
||||
)
|
||||
.catch(() => setSynologySummary(null));
|
||||
}, [integrations]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="row row-cards mb-3">
|
||||
@@ -128,7 +153,8 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
const isLiveDockhand = type === "dockhand" && integration && dockhandSummary;
|
||||
const isLiveSemaphore = type === "semaphore" && integration && semaphoreSummary;
|
||||
const isLiveProxmox = type === "proxmox" && integration && proxmoxSummary;
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox;
|
||||
const isLiveSynology = type === "synology" && integration && synologySummary;
|
||||
const isLive = isLiveTailscale || isLiveGitea || isLiveDockhand || isLiveSemaphore || isLiveProxmox || isLiveSynology;
|
||||
|
||||
return (
|
||||
<div className="col-sm-6 col-lg-4" key={type}>
|
||||
@@ -186,6 +212,18 @@ export default function Dashboard({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
<div className="text-secondary">VMs/containers running</div>
|
||||
</div>
|
||||
) : isLiveSynology ? (
|
||||
<div className="mt-2">
|
||||
<div className="h3 mb-0">{synologySummary!.volumeCount}</div>
|
||||
<div className="text-secondary">volumes</div>
|
||||
{(synologySummary!.volumesNotNormal > 0 || synologySummary!.disksNotNormal > 0) && (
|
||||
<div className="text-red small mt-1">
|
||||
{synologySummary!.volumesNotNormal > 0 && `${synologySummary!.volumesNotNormal} volume(s) unhealthy`}
|
||||
{synologySummary!.volumesNotNormal > 0 && synologySummary!.disksNotNormal > 0 && ", "}
|
||||
{synologySummary!.disksNotNormal > 0 && `${synologySummary!.disksNotNormal} disk(s) unhealthy`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-secondary mt-2">
|
||||
{integration ? (
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type ProxmoxGuestsResponse,
|
||||
type SemaphoreTemplate,
|
||||
type SemaphoreTemplatesResponse,
|
||||
type SynologyStorageResponse,
|
||||
type TailscaleDevice,
|
||||
type TailscaleDevicesResponse,
|
||||
} from "../api/client";
|
||||
@@ -24,6 +25,26 @@ function proxmoxStatusBadge(status: string) {
|
||||
);
|
||||
}
|
||||
|
||||
function healthBadge(status: string) {
|
||||
return status === "normal" ? (
|
||||
<span className="badge bg-green-lt text-green">Normal</span>
|
||||
) : (
|
||||
<span className="badge bg-red-lt text-red">{status}</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null): string {
|
||||
if (bytes === null) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (!seconds) return "—";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
@@ -141,6 +162,10 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
const [loadingGuests, setLoadingGuests] = useState(false);
|
||||
const [actingOnGuest, setActingOnGuest] = useState<number | null>(null);
|
||||
|
||||
const [synologyData, setSynologyData] = useState<SynologyStorageResponse | null>(null);
|
||||
const [synologyError, setSynologyError] = useState<string | null>(null);
|
||||
const [loadingStorage, setLoadingStorage] = useState(false);
|
||||
|
||||
function loadIntegrations() {
|
||||
api.integrations
|
||||
.list()
|
||||
@@ -211,9 +236,24 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
} else {
|
||||
setProxmoxData(null);
|
||||
}
|
||||
if (selected?.type === "synology" && selected.enabled && !managing) {
|
||||
loadSynologyStorage(selected.id);
|
||||
} else {
|
||||
setSynologyData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, managing, integrations]);
|
||||
|
||||
function loadSynologyStorage(id: number) {
|
||||
setLoadingStorage(true);
|
||||
setSynologyError(null);
|
||||
api.integrations.synology
|
||||
.storage(id)
|
||||
.then((res) => setSynologyData(res))
|
||||
.catch((err) => setSynologyError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoadingStorage(false));
|
||||
}
|
||||
|
||||
function loadSemaphoreTemplates(id: number) {
|
||||
setLoadingTemplates(true);
|
||||
setSemaphoreError(null);
|
||||
@@ -820,6 +860,99 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : selected?.type === "synology" ? (
|
||||
<div className="row g-3">
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Volumes</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => loadSynologyStorage(selected.id)}
|
||||
disabled={loadingStorage}
|
||||
>
|
||||
{loadingStorage ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{synologyError && <div className="alert alert-danger m-3 mb-0">{synologyError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Volume</th>
|
||||
<th>Type</th>
|
||||
<th>Used</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{synologyData?.volumes.map((v) => (
|
||||
<tr key={v.id}>
|
||||
<td>{v.id}</td>
|
||||
<td className="text-secondary">{v.deviceType}</td>
|
||||
<td className="text-secondary">
|
||||
{formatBytes(v.sizeUsed)} / {formatBytes(v.sizeTotal)}
|
||||
</td>
|
||||
<td>{healthBadge(v.status)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{synologyData?.volumes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="text-secondary text-center">
|
||||
No volumes found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Disks</h3>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Disk</th>
|
||||
<th>Device</th>
|
||||
<th>Temp</th>
|
||||
<th>S.M.A.R.T.</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{synologyData?.disks.map((d) => (
|
||||
<tr key={d.id}>
|
||||
<td>{d.name}</td>
|
||||
<td className="text-secondary">{d.device}</td>
|
||||
<td className="text-secondary">{d.temp !== null ? `${d.temp}°C` : "—"}</td>
|
||||
<td>{healthBadge(d.smartStatus)}</td>
|
||||
<td>
|
||||
{healthBadge(d.status)}
|
||||
{d.exceedBadSectorThreshold && <span className="badge bg-yellow-lt text-yellow ms-1">Bad sectors</span>}
|
||||
{d.belowRemainLifeThreshold && <span className="badge bg-yellow-lt text-yellow ms-1">Low life</span>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{synologyData?.disks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
No disks found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
|
||||
Reference in new issue
Block a user