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:
bobbanandClaude Sonnet 5 committed 2026-09-16 00:21:07 +02:00
1 parent 8fef68c27a
commit 95bd831d81
6 files changed
+451 -300

No files matched your search

+6 -290
View File
@@ -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 &amp; 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 &amp; 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">
<div className="card-body text-secondary">
View this integration's storage on the <Link to="/synology">Synology</Link> page.
</div>
</div>
) : selected?.type === "dockhand" ? (