Files
Homelab-manager/web/src/pages/Integrations.tsx
T
bobbanandClaude Sonnet 5 f92f8de96e Add a Date & Time setting and fix inconsistent date formats app-wide
Different tables used different date formats: Servers & Tasks used a
fixed "YYYY-MM-DD HH:mm:ss" (24h), while Audit Log, DNS, Integrations
(Tailscale/Semaphore), and Users called plain .toLocaleString() with
no options, which renders using the browser's own locale — different
per browser/OS, and inconsistent with the other pages' fixed format.

- New Settings -> Date & Time page: pick date order (YYYY-MM-DD,
  DD/MM/YYYY, MM/DD/YYYY) and 12h vs 24h clock, with a live preview.
- utils/date.ts's formatDateTime() now reads these settings instead of
  being hardcoded to sv-SE/24h; the setting is fetched once at app
  startup (alongside /api/me) via a new non-secret GET
  /api/settings/display (any signed-in user, same rationale as the
  badge-color endpoints) and applied immediately on save too, without
  needing a page reload.
- Switched every remaining raw new Date(...).toLocaleString() call
  (Audit Log, DNS zone sync time, Tailscale/Semaphore last-seen,
  Users' last login) over to the shared formatter, so every table now
  renders dates identically.

Verified the formatter's date-order x 12h logic against all six
combinations plus the midnight/noon 12h edge cases, and the settings
endpoints end-to-end (defaults, partial updates, validation
rejection, audit logging) against the real dev server.

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

1012 lines
43 KiB
TypeScript

import { useEffect, useState, type CSSProperties } from "react";
import {
api,
type CurrentUser,
type DockhandContainer,
type DockhandContainersResponse,
type GiteaRepo,
type IntegrationSummary,
type IntegrationType,
type ProxmoxGuest,
type ProxmoxGuestsResponse,
type SemaphoreTemplate,
type SemaphoreTemplatesResponse,
type SynologyStorageResponse,
type TailscaleDevice,
type TailscaleDevicesResponse,
} 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`;
}
function semaphoreStatusBadge(template: SemaphoreTemplate) {
const status = template.lastTask?.status;
if (!status) return <span className="badge bg-secondary-lt text-secondary">Never run</span>;
switch (status) {
case "success":
return <span className="badge bg-green-lt text-green">Success</span>;
case "error":
return <span className="badge bg-red-lt text-red">Failed</span>;
case "stopped":
return <span className="badge bg-secondary-lt text-secondary">Stopped</span>;
case "rejected":
return <span className="badge bg-secondary-lt text-secondary">Rejected</span>;
case "waiting":
case "starting":
case "waiting_confirmation":
case "confirmed":
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
case "running":
case "stopping":
return <span className="badge bg-blue-lt text-blue">Running</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{status}</span>;
}
}
function containerStateBadge(state: string) {
switch (state) {
case "running":
return <span className="badge bg-green-lt text-green">Running</span>;
case "exited":
case "dead":
return <span className="badge bg-red-lt text-red">{state === "dead" ? "Dead" : "Exited"}</span>;
case "paused":
return <span className="badge bg-yellow-lt text-yellow">Paused</span>;
case "restarting":
return <span className="badge bg-blue-lt text-blue">Restarting</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
}
}
function runStatusBadge(repo: GiteaRepo) {
const run = repo.latestRun;
if (!run) return <span className="badge bg-secondary-lt text-secondary">No runs</span>;
const state = run.conclusion || run.status;
switch (state) {
case "success":
return <span className="badge bg-green-lt text-green">Success</span>;
case "failure":
return <span className="badge bg-red-lt text-red">Failed</span>;
case "cancelled":
return <span className="badge bg-secondary-lt text-secondary">Cancelled</span>;
case "skipped":
return <span className="badge bg-secondary-lt text-secondary">Skipped</span>;
case "waiting":
case "queued":
case "pending":
return <span className="badge bg-yellow-lt text-yellow">Queued</span>;
case "running":
case "in_progress":
return <span className="badge bg-blue-lt text-blue">Running</span>;
default:
return <span className="badge bg-secondary-lt text-secondary">{state}</span>;
}
}
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 [tailscaleData, setTailscaleData] = useState<TailscaleDevicesResponse | null>(null);
const [tailscaleError, setTailscaleError] = useState<string | null>(null);
const [loadingDevices, setLoadingDevices] = useState(false);
const [giteaRepos, setGiteaRepos] = useState<GiteaRepo[] | null>(null);
const [giteaError, setGiteaError] = useState<string | null>(null);
const [loadingRepos, setLoadingRepos] = useState(false);
const [rerunningRun, setRerunningRun] = useState<number | null>(null);
const [dockhandData, setDockhandData] = useState<DockhandContainersResponse | null>(null);
const [dockhandError, setDockhandError] = useState<string | null>(null);
const [loadingContainers, setLoadingContainers] = useState(false);
const [actingOnContainer, setActingOnContainer] = useState<string | null>(null);
const [semaphoreData, setSemaphoreData] = useState<SemaphoreTemplatesResponse | null>(null);
const [semaphoreError, setSemaphoreError] = useState<string | null>(null);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [runningTemplate, setRunningTemplate] = useState<number | null>(null);
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;
function loadTailscaleDevices(id: number) {
setLoadingDevices(true);
setTailscaleError(null);
api.integrations.tailscale
.devices(id)
.then((res) => setTailscaleData(res))
.catch((err) => setTailscaleError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingDevices(false));
}
function loadGiteaRepos(id: number) {
setLoadingRepos(true);
setGiteaError(null);
api.integrations.gitea
.repos(id)
.then((res) => setGiteaRepos(res.repos))
.catch((err) => setGiteaError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingRepos(false));
}
function loadDockhandContainers(id: number) {
setLoadingContainers(true);
setDockhandError(null);
api.integrations.dockhand
.containers(id)
.then((res) => setDockhandData(res))
.catch((err) => setDockhandError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingContainers(false));
}
useEffect(() => {
if (selected?.type === "tailscale" && selected.enabled && !managing) {
loadTailscaleDevices(selected.id);
} else {
setTailscaleData(null);
}
if (selected?.type === "gitea" && selected.enabled && !managing) {
loadGiteaRepos(selected.id);
} else {
setGiteaRepos(null);
}
if (selected?.type === "dockhand" && selected.enabled && !managing) {
loadDockhandContainers(selected.id);
} else {
setDockhandData(null);
}
if (selected?.type === "semaphore" && selected.enabled && !managing) {
loadSemaphoreTemplates(selected.id);
} else {
setSemaphoreData(null);
}
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 loadSemaphoreTemplates(id: number) {
setLoadingTemplates(true);
setSemaphoreError(null);
api.integrations.semaphore
.templates(id)
.then((res) => setSemaphoreData(res))
.catch((err) => setSemaphoreError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingTemplates(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 setDeviceAuthorized(device: TailscaleDevice, authorized: boolean) {
if (!selectedId) return;
try {
await api.integrations.tailscale.setAuthorized(selectedId, device.id, authorized);
loadTailscaleDevices(selectedId);
} catch (err) {
setTailscaleError(err instanceof Error ? err.message : String(err));
}
}
async function removeDevice(device: TailscaleDevice) {
if (!selectedId) return;
if (!confirm(`Remove device "${device.label || device.hostname}" from the tailnet?`)) return;
try {
await api.integrations.tailscale.remove(selectedId, device.id);
loadTailscaleDevices(selectedId);
} catch (err) {
setTailscaleError(err instanceof Error ? err.message : String(err));
}
}
async function rerunFailed(repo: GiteaRepo) {
if (!selectedId || !repo.latestRun) return;
setRerunningRun(repo.latestRun.id);
setGiteaError(null);
try {
await api.integrations.gitea.rerunFailed(selectedId, repo.owner, repo.name, repo.latestRun.id);
loadGiteaRepos(selectedId);
} catch (err) {
setGiteaError(err instanceof Error ? err.message : String(err));
} finally {
setRerunningRun(null);
}
}
async function containerAction(c: DockhandContainer, action: "start" | "stop" | "restart") {
if (!selectedId) return;
if (action === "stop" && !confirm(`Stop container "${c.name}"?`)) return;
setActingOnContainer(c.id);
setDockhandError(null);
try {
await api.integrations.dockhand[action](selectedId, c.environmentId, c.id);
loadDockhandContainers(selectedId);
} catch (err) {
setDockhandError(err instanceof Error ? err.message : String(err));
} finally {
setActingOnContainer(null);
}
}
async function runTemplate(t: SemaphoreTemplate) {
if (!selectedId) return;
if (!confirm(`Run "${t.name}" now?`)) return;
setRunningTemplate(t.id);
setSemaphoreError(null);
try {
await api.integrations.semaphore.run(selectedId, t.projectId, t.id);
loadSemaphoreTemplates(selectedId);
} catch (err) {
setSemaphoreError(err instanceof Error ? err.message : String(err));
} finally {
setRunningTemplate(null);
}
}
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-header">
<h3 className="card-title">
Devices
{tailscaleData && (
<span className="text-secondary fw-normal ms-2">
{tailscaleData.summary.online}/{tailscaleData.summary.total} online
{tailscaleData.summary.unauthorized > 0 && `, ${tailscaleData.summary.unauthorized} awaiting authorization`}
</span>
)}
</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadTailscaleDevices(selected.id)} disabled={loadingDevices}>
{loadingDevices ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{tailscaleError && <div className="alert alert-danger m-3 mb-0">{tailscaleError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Device</th>
<th>Address</th>
<th>OS</th>
<th>Status</th>
<th>Last seen</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{tailscaleData?.devices.map((d) => (
<tr key={d.id}>
<td>
{d.label || d.hostname}
{d.isExitNode && <span className="badge bg-purple-lt ms-2">Exit node</span>}
</td>
<td className="text-secondary">{d.primaryAddress}</td>
<td>{d.os || "—"}</td>
<td>
{d.online ? (
<span className="badge bg-green-lt text-green me-1">Online</span>
) : (
<span className="badge bg-secondary-lt text-secondary me-1">Offline</span>
)}
{!d.authorized && <span className="badge bg-yellow-lt text-yellow">Unauthorized</span>}
</td>
<td className="text-secondary">{d.lastSeen ? formatDateTime(new Date(d.lastSeen)) : "—"}</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{d.authorized ? (
<button className="btn btn-sm" onClick={() => setDeviceAuthorized(d, false)}>
Deauthorize
</button>
) : (
<button className="btn btn-sm btn-primary" onClick={() => setDeviceAuthorized(d, true)}>
Authorize
</button>
)}
<button className="btn btn-sm btn-outline-danger" onClick={() => removeDevice(d)}>
Remove
</button>
</div>
</td>
)}
</tr>
))}
{tailscaleData?.devices.length === 0 && (
<tr>
<td colSpan={canEdit ? 6 : 5} className="text-secondary text-center">
No devices in this tailnet.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "gitea" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">Repositories</h3>
<div className="card-actions">
<button className="btn btn-sm btn-outline-secondary" onClick={() => loadGiteaRepos(selected.id)} disabled={loadingRepos}>
{loadingRepos ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{giteaError && <div className="alert alert-danger m-3 mb-0">{giteaError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Repository</th>
<th>Last run</th>
<th>Branch</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{giteaRepos?.map((r) => (
<tr key={r.fullName}>
<td>
<a href={r.htmlUrl} target="_blank" rel="noopener noreferrer">
{r.fullName}
</a>
{r.private && <span className="badge bg-secondary-lt ms-2">Private</span>}
</td>
<td className="text-secondary">
{r.latestRun ? (
<a href={r.latestRun.htmlUrl} target="_blank" rel="noopener noreferrer">
#{r.latestRun.runNumber} {r.latestRun.displayTitle}
</a>
) : r.hasActions ? (
"—"
) : (
"Actions disabled"
)}
</td>
<td className="text-secondary">{r.latestRun?.headBranch ?? "—"}</td>
<td>{runStatusBadge(r)}</td>
{canEdit && (
<td>
{r.latestRun && (r.latestRun.conclusion === "failure" || r.latestRun.status === "failure") && (
<button
className="btn btn-sm"
onClick={() => rerunFailed(r)}
disabled={rerunningRun === r.latestRun.id}
>
{rerunningRun === r.latestRun.id ? "Re-running…" : "Re-run failed"}
</button>
)}
</td>
)}
</tr>
))}
{giteaRepos?.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No repositories visible to this token.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "dockhand" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Containers
{dockhandData && (
<span className="text-secondary fw-normal ms-2">
{dockhandData.summary.running}/{dockhandData.summary.total} running
</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadDockhandContainers(selected.id)}
disabled={loadingContainers}
>
{loadingContainers ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{dockhandError && <div className="alert alert-danger m-3 mb-0">{dockhandError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Container</th>
<th>Host</th>
<th>Image</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{dockhandData?.containers.map((c) => (
<tr key={`${c.environmentId}:${c.id}`}>
<td>{c.name}</td>
<td className="text-secondary">{c.environmentName}</td>
<td className="text-secondary">{c.image}</td>
<td>
{containerStateBadge(c.state)}
<div className="text-secondary small">{c.status}</div>
</td>
{canEdit && (
<td>
<div className="btn-list flex-nowrap">
{c.state === "running" ? (
<>
<button
className="btn btn-sm"
onClick={() => containerAction(c, "restart")}
disabled={actingOnContainer === c.id}
>
Restart
</button>
<button
className="btn btn-sm btn-outline-danger"
onClick={() => containerAction(c, "stop")}
disabled={actingOnContainer === c.id}
>
Stop
</button>
</>
) : (
<button
className="btn btn-sm btn-primary"
onClick={() => containerAction(c, "start")}
disabled={actingOnContainer === c.id}
>
Start
</button>
)}
</div>
</td>
)}
</tr>
))}
{dockhandData?.containers.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No containers found across any environment.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : selected?.type === "semaphore" ? (
<div className="card">
<div className="card-header">
<h3 className="card-title">
Templates
{semaphoreData && semaphoreData.summary.failing > 0 && (
<span className="text-red fw-normal ms-2">{semaphoreData.summary.failing} failing</span>
)}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => loadSemaphoreTemplates(selected.id)}
disabled={loadingTemplates}
>
{loadingTemplates ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
{semaphoreError && <div className="alert alert-danger m-3 mb-0">{semaphoreError}</div>}
<div className="table-responsive">
<table className="table table-vcenter card-table">
<thead>
<tr>
<th>Template</th>
<th>Project</th>
<th>Last run</th>
<th>Status</th>
{canEdit && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{semaphoreData?.templates.map((t) => (
<tr key={`${t.projectId}:${t.id}`}>
<td>{t.name}</td>
<td className="text-secondary">{t.projectName}</td>
<td className="text-secondary">
{t.lastTask
? formatDateTime(new Date(t.lastTask.end ?? t.lastTask.start ?? t.lastTask.created ?? ""))
: "—"}
</td>
<td>{semaphoreStatusBadge(t)}</td>
{canEdit && (
<td>
<button
className="btn btn-sm btn-primary"
onClick={() => runTemplate(t)}
disabled={runningTemplate === t.id}
>
{runningTemplate === t.id ? "Starting…" : "Run"}
</button>
</td>
)}
</tr>
))}
{semaphoreData?.templates.length === 0 && (
<tr>
<td colSpan={canEdit ? 5 : 4} className="text-secondary text-center">
No templates found across any project.
</td>
</tr>
)}
</tbody>
</table>
</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>
) : (
<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>
)}
</>
)}
</>
)}
</>
);
}