Files
Homelab-manager/web/src/pages/Integrations.tsx
T
bobbanandClaude Sonnet 5 8fef68c27a Move Tailscale, Semaphore, and Gitea to their own top-level pages
Same move as Docker: each was buried inside the generic Integrations
browsing view, sharing one "pick any integration" dropdown across six
unrelated types. Give them dedicated pages instead.

New web/src/pages/{Tailscale,Semaphore,Gitea}.tsx each 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 (Tailscale, Semaphore, Gitea, right after Docker) and routes.

Removed the corresponding state/handlers/tables from Integrations.tsx
entirely (374 lines). Adding, editing, enabling/disabling, and
deleting each credential itself still happens under Integrations ->
Manage integrations, same as Proxmox and Synology, which stay on the
Integrations page. Selecting one of these three types in Integrations'
generic browsing dropdown now points to its new page instead of
rendering a table there too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-16 00:09:02 +02:00

516 lines
21 KiB
TypeScript

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 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",
proxmox: "Proxmox",
synology: "Synology",
semaphore: "Semaphore",
gitea: "Gitea",
dockhand: "Dockhand",
};
function typeBadgeStyle(colors: Record<string, string>, type: IntegrationType): CSSProperties {
const color = colors[type];
if (!color) return {};
return { backgroundColor: `${color}22`, color, border: `1px solid ${color}55` };
}
export default function Integrations({ user }: { user: CurrentUser }) {
const isAdmin = user.role === "admin";
const canEdit = user.role === "admin" || user.role === "operator";
const [integrations, setIntegrations] = useState<IntegrationSummary[] | null>(null);
const [managing, setManaging] = useState(false);
const [editingIntegration, setEditingIntegration] = useState<IntegrationSummary | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const [integrationColors, setIntegrationColors] = useState<Record<string, string>>({});
useEffect(() => {
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()
.then((res) => {
setIntegrations(res.integrations);
if (!selectedId && res.integrations.length > 0) setSelectedId(res.integrations[0].id);
})
.catch((err) => setError(String(err)));
}
useEffect(loadIntegrations, []);
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 });
loadIntegrations();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
async function removeIntegration(i: IntegrationSummary) {
if (!confirm(`Delete integration "${i.name}"?`)) return;
try {
await api.integrations.remove(i.id);
if (selectedId === i.id) setSelectedId(null);
loadIntegrations();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
}
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">
<h2 className="page-title mb-0">Integrations</h2>
{isAdmin && (
<button className="btn btn-outline-secondary ms-auto" onClick={() => setManaging((v) => !v)}>
{managing ? "Back to browsing" : "Manage integrations"}
</button>
)}
</div>
{error && <div className="alert alert-danger">{error}</div>}
{managing ? (
<>
{editingIntegration ? (
<IntegrationEditForm
integration={editingIntegration}
onSaved={() => {
setEditingIntegration(null);
loadIntegrations();
}}
onCancel={() => setEditingIntegration(null)}
/>
) : (
<IntegrationForm onCreated={loadIntegrations} onCancel={() => setManaging(false)} />
)}
<div className="card">
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Status</th>
<th className="w-1">Actions</th>
</tr>
</thead>
<tbody>
{integrations?.map((i) => (
<tr key={i.id}>
<td>{i.name}</td>
<td>
<span className="badge" style={typeBadgeStyle(integrationColors, i.type)}>
{TYPE_LABELS[i.type]}
</span>
</td>
<td>
<button
className={`badge ${i.enabled ? "bg-green-lt text-green" : "bg-secondary-lt text-secondary"} border-0`}
onClick={() => toggleEnabled(i)}
>
{i.enabled ? "Enabled" : "Disabled"}
</button>
</td>
<td>
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => setEditingIntegration(i)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => removeIntegration(i)}>
Delete
</button>
</div>
</td>
</tr>
))}
{integrations?.length === 0 && (
<tr>
<td colSpan={4} className="text-secondary text-center">
No integrations configured yet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</>
) : (
<>
{integrations?.length === 0 ? (
<div className="card">
<div className="card-body text-secondary">
No integrations configured yet.
{isAdmin && (
<>
{" "}
<button className="btn btn-link p-0 align-baseline" onClick={() => setManaging(true)}>
Add one
</button>
.
</>
)}
</div>
</div>
) : (
<>
<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} ({TYPE_LABELS[i.type]}){!i.enabled ? " — disabled" : ""}
</option>
))}
</select>
</div>
{selected?.type === "tailscale" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's devices on the <Link to="/tailscale">Tailscale</Link> page.
</div>
</div>
) : selected?.type === "gitea" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's repositories on the <Link to="/gitea">Gitea</Link> page.
</div>
</div>
) : selected?.type === "semaphore" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's templates on the <Link to="/semaphore">Semaphore</Link> page.
</div>
</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>
</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>
) : selected?.type === "dockhand" ? (
<div className="card">
<div className="card-body text-secondary">
Manage this integration's containers on the <Link to="/docker">Docker</Link> page.
</div>
</div>
) : (
<div className="card">
<div className="card-body text-secondary">
{selected ? `${TYPE_LABELS[selected.type]} isn't wired up in the UI yet.` : "Select an integration."}
</div>
</div>
)}
</>
)}
</>
)}
</>
);
}