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

+227
View File
@@ -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 &amp; 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>
)}
</>
)}
</>
);
}