Move Proxmox and Synology to their own top-level pages
Same move as Docker/Tailscale/Semaphore/Gitea: each was buried inside
the generic Integrations browsing view. Give them dedicated pages too
— this was the last pair, so every integration type with a browsing UI
now has its own page.
New web/src/pages/{Proxmox,Synology}.tsx reuse the existing, unchanged
API routes and adapters (no server changes) with their own integration
picker scoped to just that type. Added matching nav items and routes.
Synology stays read-only, matching its original design.
Removed the corresponding state/handlers/tables from Integrations.tsx
(296 lines). It's now down to just the "Manage integrations" CRUD
table plus a browsing dropdown whose six branches are all redirects to
the type's own page — genuinely pointless now that every type has
moved out, worth simplifying separately.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8fef68c27a
commit
95bd831d81
6 files changed
+450
-299
No files matched your search
@@ -34,11 +34,17 @@ All modules from the original plan are built:
|
||||
Proxmox for VM/LXC-backed servers, or from the agent's own hardware
|
||||
report for everything else. Proxmox-linked servers also get
|
||||
start/stop/restart buttons right on the detail page.
|
||||
- **Tailscale**, **Semaphore**, **Gitea**, and **Docker** each get their
|
||||
own top-level page (backed by the matching integration) instead of
|
||||
living inside a shared Integrations browsing view:
|
||||
- **Tailscale**, **Proxmox**, **Synology**, **Semaphore**, **Gitea**,
|
||||
and **Docker** each get their own top-level page (backed by the
|
||||
matching integration) instead of living inside a shared Integrations
|
||||
browsing view:
|
||||
- **Tailscale** — device list with online/authorized status, and
|
||||
authorize/deauthorize/remove actions; a live device-count widget.
|
||||
- **Proxmox** — VM/LXC status across every node in the cluster, with
|
||||
start/restart/shutdown/stop actions; a live running/total widget.
|
||||
Supports self-signed certificates (common in homelab setups).
|
||||
- **Synology** — volume and disk health (read-only by design).
|
||||
Supports self-signed certificates.
|
||||
- **Semaphore** — Ansible run status per template across every
|
||||
project, with a "Run" action to trigger a template; a live
|
||||
template-count widget (with a last-failed warning).
|
||||
@@ -51,13 +57,7 @@ All modules from the original plan are built:
|
||||
running/total widget.
|
||||
|
||||
Add/edit/enable/disable/remove each integration's credential itself
|
||||
under Integrations → Manage integrations, same as Proxmox and
|
||||
Synology (which stay on the Integrations page).
|
||||
- **Integrations → Proxmox** — VM/LXC status across every node in the
|
||||
cluster, with start/stop/restart actions; a live running/total widget.
|
||||
Supports self-signed certificates (common in homelab Proxmox setups).
|
||||
- **Integrations → Synology** — volume and disk health (read-only by
|
||||
design). Supports self-signed certificates.
|
||||
under Integrations → Manage integrations.
|
||||
- **Settings** (admin-only) — notification channels (Gotify, ntfy, SMTP,
|
||||
generic webhook) with per-channel test buttons, per-event toggles (DNS
|
||||
record added/updated/deleted, daily secret-expiry reminder with a
|
||||
|
||||
@@ -15,6 +15,8 @@ import Docker from "./pages/Docker";
|
||||
import Tailscale from "./pages/Tailscale";
|
||||
import Semaphore from "./pages/Semaphore";
|
||||
import Gitea from "./pages/Gitea";
|
||||
import Proxmox from "./pages/Proxmox";
|
||||
import Synology from "./pages/Synology";
|
||||
import Settings from "./pages/Settings";
|
||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||
import BadgeSettings from "./pages/settings/BadgeSettings";
|
||||
@@ -80,6 +82,8 @@ export default function App() {
|
||||
<Route path="/tailscale" element={<Tailscale user={user} />} />
|
||||
<Route path="/semaphore" element={<Semaphore user={user} />} />
|
||||
<Route path="/gitea" element={<Gitea user={user} />} />
|
||||
<Route path="/proxmox" element={<Proxmox user={user} />} />
|
||||
<Route path="/synology" element={<Synology user={user} />} />
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
IconAffiliate,
|
||||
IconPlayerPlay,
|
||||
IconBrandGit,
|
||||
IconServerCog,
|
||||
IconDatabase,
|
||||
IconUsers,
|
||||
IconHistory,
|
||||
IconSettings,
|
||||
@@ -36,6 +38,8 @@ const NAV_ITEMS: NavItem[] = [
|
||||
{ to: "/tailscale", label: "Tailscale", icon: <IconAffiliate size={20} /> },
|
||||
{ to: "/semaphore", label: "Semaphore", icon: <IconPlayerPlay size={20} /> },
|
||||
{ to: "/gitea", label: "Gitea", icon: <IconBrandGit size={20} /> },
|
||||
{ to: "/proxmox", label: "Proxmox", icon: <IconServerCog size={20} /> },
|
||||
{ to: "/synology", label: "Synology", icon: <IconDatabase size={20} /> },
|
||||
{ to: "/users", label: "Users", icon: <IconUsers size={20} />, minRole: "admin" },
|
||||
{ to: "/audit-log", label: "Audit Log", icon: <IconHistory size={20} />, minRole: "operator" },
|
||||
{ to: "/settings", label: "Settings", icon: <IconSettings size={20} />, minRole: "admin" },
|
||||
|
||||
@@ -1,54 +1,8 @@
|
||||
import { useEffect, useState, type CSSProperties } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type IntegrationType,
|
||||
type ProxmoxGuest,
|
||||
type ProxmoxGuestsResponse,
|
||||
type SynologyStorageResponse,
|
||||
} from "../api/client";
|
||||
import { api, type CurrentUser, type IntegrationSummary, type IntegrationType } from "../api/client";
|
||||
import IntegrationForm from "../components/IntegrationForm";
|
||||
import IntegrationEditForm from "../components/IntegrationEditForm";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
function proxmoxStatusBadge(status: string) {
|
||||
return status === "running" ? (
|
||||
<span className="badge bg-green-lt text-green">Running</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">Stopped</span>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<IntegrationType, string> = {
|
||||
tailscale: "Tailscale",
|
||||
@@ -80,15 +34,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
api.settings.integrationColors().then((res) => setIntegrationColors(res.integrationColors)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
const [proxmoxData, setProxmoxData] = useState<ProxmoxGuestsResponse | null>(null);
|
||||
const [proxmoxError, setProxmoxError] = useState<string | null>(null);
|
||||
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()
|
||||
@@ -103,40 +48,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.type === "proxmox" && selected.enabled && !managing) {
|
||||
loadProxmoxGuests(selected.id);
|
||||
} 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 loadProxmoxGuests(id: number) {
|
||||
setLoadingGuests(true);
|
||||
setProxmoxError(null);
|
||||
api.integrations.proxmox
|
||||
.guests(id)
|
||||
.then((res) => setProxmoxData(res))
|
||||
.catch((err) => setProxmoxError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoadingGuests(false));
|
||||
}
|
||||
|
||||
async function toggleEnabled(i: IntegrationSummary) {
|
||||
try {
|
||||
await api.integrations.update(i.id, { enabled: !i.enabled });
|
||||
@@ -157,22 +68,6 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||
if (!selectedId) return;
|
||||
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
||||
return;
|
||||
setActingOnGuest(g.vmid);
|
||||
setProxmoxError(null);
|
||||
try {
|
||||
await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid);
|
||||
loadProxmoxGuests(selectedId);
|
||||
} catch (err) {
|
||||
setProxmoxError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setActingOnGuest(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
@@ -304,193 +199,14 @@ export default function Integrations({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
) : selected?.type === "proxmox" ? (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
VMs & Containers
|
||||
{proxmoxData && (
|
||||
<span className="text-secondary fw-normal ms-2">
|
||||
{proxmoxData.summary.running}/{proxmoxData.summary.total} running
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => loadProxmoxGuests(selected.id)}
|
||||
disabled={loadingGuests}
|
||||
>
|
||||
{loadingGuests ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{proxmoxError && <div className="alert alert-danger m-3 mb-0">{proxmoxError}</div>}
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Uptime</th>
|
||||
<th>Status</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{proxmoxData?.guests.map((g) => (
|
||||
<tr key={`${g.node}:${g.type}:${g.vmid}`}>
|
||||
<td>
|
||||
{g.name} <span className="text-secondary">#{g.vmid}</span>
|
||||
</td>
|
||||
<td className="text-secondary">{g.node}</td>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{g.type === "qemu" ? "VM" : "LXC"}</span>
|
||||
</td>
|
||||
<td className="text-secondary">{formatUptime(g.uptime)}</td>
|
||||
<td>{proxmoxStatusBadge(g.status)}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
{g.status === "running" ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => guestAction(g, "restart")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => guestAction(g, "shutdown")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Shutdown
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => guestAction(g, "stop")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => guestAction(g, "start")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{proxmoxData?.guests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No VMs or containers found across any node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className="card-body text-secondary">
|
||||
Manage this integration's VMs & containers on the <Link to="/proxmox">Proxmox</Link> page.
|
||||
</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 className="card-body text-secondary">
|
||||
View this integration's storage on the <Link to="/synology">Synology</Link> page.
|
||||
</div>
|
||||
</div>
|
||||
) : selected?.type === "dockhand" ? (
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type ProxmoxGuest,
|
||||
type ProxmoxGuestsResponse,
|
||||
} from "../api/client";
|
||||
|
||||
function proxmoxStatusBadge(status: string) {
|
||||
return status === "running" ? (
|
||||
<span className="badge bg-green-lt text-green">Running</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">Stopped</span>
|
||||
);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (!seconds) return "—";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export default function Proxmox({ user }: { user: CurrentUser }) {
|
||||
const canEdit = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<ProxmoxGuestsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [actingOnGuest, setActingOnGuest] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => {
|
||||
const proxmoxIntegrations = res.integrations.filter((i) => i.type === "proxmox");
|
||||
setIntegrations(proxmoxIntegrations);
|
||||
if (!selectedId && proxmoxIntegrations.length > 0) setSelectedId(proxmoxIntegrations[0].id);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
function loadGuests(id: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.integrations.proxmox
|
||||
.guests(id)
|
||||
.then((res) => setData(res))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.enabled) {
|
||||
loadGuests(selected.id);
|
||||
} else {
|
||||
setData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
async function guestAction(g: ProxmoxGuest, action: "start" | "stop" | "restart" | "shutdown") {
|
||||
if (!selectedId) return;
|
||||
if (action === "stop" && !confirm(`Stop ${g.type === "qemu" ? "VM" : "container"} "${g.name}" immediately? Use Shutdown instead for a graceful power-off.`))
|
||||
return;
|
||||
setActingOnGuest(g.vmid);
|
||||
setError(null);
|
||||
try {
|
||||
await api.integrations.proxmox[action](selectedId, g.node, g.type, g.vmid);
|
||||
loadGuests(selectedId);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setActingOnGuest(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Proxmox</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{integrations?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No Proxmox integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{integrations && integrations.length > 1 && (
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedId ?? ""}
|
||||
onChange={(e) => setSelectedId(Number(e.target.value))}
|
||||
>
|
||||
{integrations.map((i) => (
|
||||
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||
{i.name}
|
||||
{!i.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && !selected.enabled ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
|
||||
VMs and containers.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
VMs & Containers
|
||||
{data && (
|
||||
<span className="text-secondary fw-normal ms-2">
|
||||
{data.summary.running}/{data.summary.total} running
|
||||
</span>
|
||||
)}
|
||||
</h3>
|
||||
<div className="card-actions">
|
||||
<button
|
||||
className="btn btn-sm btn-outline-secondary"
|
||||
onClick={() => selected && loadGuests(selected.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="table-responsive">
|
||||
<table className="table table-vcenter card-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Node</th>
|
||||
<th>Type</th>
|
||||
<th>Uptime</th>
|
||||
<th>Status</th>
|
||||
{canEdit && <th className="w-1">Actions</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data?.guests.map((g) => (
|
||||
<tr key={`${g.node}:${g.type}:${g.vmid}`}>
|
||||
<td>
|
||||
{g.name} <span className="text-secondary">#{g.vmid}</span>
|
||||
</td>
|
||||
<td className="text-secondary">{g.node}</td>
|
||||
<td>
|
||||
<span className="badge bg-blue-lt">{g.type === "qemu" ? "VM" : "LXC"}</span>
|
||||
</td>
|
||||
<td className="text-secondary">{formatUptime(g.uptime)}</td>
|
||||
<td>{proxmoxStatusBadge(g.status)}</td>
|
||||
{canEdit && (
|
||||
<td>
|
||||
<div className="btn-list flex-nowrap">
|
||||
{g.status === "running" ? (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => guestAction(g, "restart")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm"
|
||||
onClick={() => guestAction(g, "shutdown")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Shutdown
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline-danger"
|
||||
onClick={() => guestAction(g, "stop")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button
|
||||
className="btn btn-sm btn-primary"
|
||||
onClick={() => guestAction(g, "start")}
|
||||
disabled={actingOnGuest === g.vmid}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
{data?.guests.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
|
||||
No VMs or containers found across any node.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { api, type CurrentUser, type IntegrationSummary, type SynologyStorageResponse } from "../api/client";
|
||||
|
||||
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]}`;
|
||||
}
|
||||
|
||||
export default function Synology({ user: _user }: { user: CurrentUser }) {
|
||||
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [data, setData] = useState<SynologyStorageResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => {
|
||||
const synologyIntegrations = res.integrations.filter((i) => i.type === "synology");
|
||||
setIntegrations(synologyIntegrations);
|
||||
if (!selectedId && synologyIntegrations.length > 0) setSelectedId(synologyIntegrations[0].id);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const selected = integrations?.find((i) => i.id === selectedId) ?? null;
|
||||
|
||||
function loadStorage(id: number) {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
api.integrations.synology
|
||||
.storage(id)
|
||||
.then((res) => setData(res))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => setLoading(false));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (selected?.enabled) {
|
||||
loadStorage(selected.id);
|
||||
} else {
|
||||
setData(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="page-title mb-3">Synology</h2>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{integrations?.length === 0 ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
No Synology integration configured yet. <Link to="/integrations">Add one under Integrations</Link>.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{integrations && integrations.length > 1 && (
|
||||
<div className="mb-3" style={{ maxWidth: 320 }}>
|
||||
<select
|
||||
className="form-select"
|
||||
value={selectedId ?? ""}
|
||||
onChange={(e) => setSelectedId(Number(e.target.value))}
|
||||
>
|
||||
{integrations.map((i) => (
|
||||
<option key={i.id} value={i.id} disabled={!i.enabled}>
|
||||
{i.name}
|
||||
{!i.enabled ? " — disabled" : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected && !selected.enabled ? (
|
||||
<div className="card">
|
||||
<div className="card-body text-secondary">
|
||||
"{selected.name}" is disabled. Enable it under Integrations → Manage integrations to see its
|
||||
storage.
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<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={() => selected && loadStorage(selected.id)}
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
</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>
|
||||
{data?.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>
|
||||
))}
|
||||
{data?.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>
|
||||
{data?.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>
|
||||
))}
|
||||
{data?.disks.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={5} className="text-secondary text-center">
|
||||
No disks found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user