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:
bobbanandClaude Sonnet 5 committed 2026-09-15 00:35:21 +02:00
1 parent ae39f18755
commit a83a4b3b11
7 files changed
+465 -1

No files matched your search

+133
View File
@@ -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">