Sorting/CSV export were already app-wide; tables with a real chance of growing into dozens or hundreds of rows (a busy tailnet, a big DNS zone, a homelab's full IP inventory, a Gitea org with many repos, ...) had no pagination at all, making them a long unbroken scroll. New usePagination hook (client-side slicing over an already-sorted/ filtered array, 20 rows per page) and a matching Pagination component (Prev/Next + "Page X of Y (N total)", hidden entirely when everything fits on one page). The current page is clamped to the valid range on every render rather than reset via an effect, so switching to a smaller data set (a different selected integration, a filter that narrows the result) can never strand the view on a now-nonexistent page -- no per-page "reset on change" wiring needed anywhere. Applied to Audit Log, DNS zones and records, IP Addresses, Secrets, Servers (manage table), Docker containers, Proxmox guests, Semaphore templates, Gitea repos, and Tailscale devices. CSV export keeps exporting the full sorted/filtered array regardless of which page is currently shown -- pagination only affects what's rendered on screen. Left the already-small tables (Synology volumes/disks, Users, Integrations, per-node Proxmox storage) unpaginated, and left the Diagnostic Log's existing server-driven pagination as-is rather than bolting a second, different pagination scheme onto it. Verified the clamping logic directly: a normal page, the trailing partial page, a requested page beyond the end (clamps to the last valid page instead of rendering empty), and an empty result set (clamps to page 0 with a page count of 1 instead of a negative range). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
407 lines
17 KiB
TypeScript
407 lines
17 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { Link } from "react-router-dom";
|
|
import {
|
|
api,
|
|
type CurrentUser,
|
|
type IntegrationSummary,
|
|
type ProxmoxGuest,
|
|
type ProxmoxGuestsResponse,
|
|
type ProxmoxNodeStats,
|
|
} from "../api/client";
|
|
import { useSortable } from "../hooks/useSortable";
|
|
import SortableTh from "../components/SortableTh";
|
|
import { usePagination } from "../hooks/usePagination";
|
|
import Pagination from "../components/Pagination";
|
|
import { downloadCsv } from "../utils/csv";
|
|
|
|
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`;
|
|
}
|
|
|
|
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 usageBar(used: number | null, total: number | null) {
|
|
if (!total) return null;
|
|
const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100));
|
|
return (
|
|
<div className="progress progress-sm mt-1">
|
|
<div className={`progress-bar ${pct > 90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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);
|
|
|
|
const [nodeStats, setNodeStats] = useState<ProxmoxNodeStats[] | null>(null);
|
|
const [nodeStatsError, setNodeStatsError] = useState<string | null>(null);
|
|
const [loadingNodeStats, setLoadingNodeStats] = useState(false);
|
|
|
|
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));
|
|
}
|
|
|
|
function loadNodeStats(id: number) {
|
|
setLoadingNodeStats(true);
|
|
setNodeStatsError(null);
|
|
api.integrations.proxmox
|
|
.nodes(id)
|
|
.then((res) => setNodeStats(res.nodes))
|
|
.catch((err) => setNodeStatsError(err instanceof Error ? err.message : String(err)))
|
|
.finally(() => setLoadingNodeStats(false));
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (selected?.enabled) {
|
|
loadGuests(selected.id);
|
|
loadNodeStats(selected.id);
|
|
} else {
|
|
setData(null);
|
|
setNodeStats(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);
|
|
}
|
|
}
|
|
|
|
const { sorted: sortedGuests, sortKey: guestSortKey, sortDir: guestSortDir, requestSort: requestGuestSort } = useSortable(data?.guests);
|
|
const { pageItems: guestPageItems, page: guestPage, setPage: setGuestPage, pageCount: guestPageCount, totalCount: guestTotalCount } =
|
|
usePagination(sortedGuests);
|
|
|
|
function exportGuestsCsv() {
|
|
if (!sortedGuests) return;
|
|
downloadCsv(
|
|
"proxmox-guests.csv",
|
|
["Name", "VMID", "Node", "Type", "Uptime", "Status"],
|
|
sortedGuests.map((g) => [g.name, g.vmid, g.node, g.type === "qemu" ? "VM" : "LXC", formatUptime(g.uptime), g.status]),
|
|
);
|
|
}
|
|
|
|
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>
|
|
) : (
|
|
<>
|
|
{nodeStatsError && <div className="alert alert-danger">{nodeStatsError}</div>}
|
|
<div className="row row-cards mb-3">
|
|
{nodeStats?.map((n) => (
|
|
<div className="col-12" key={n.node}>
|
|
<div className="card h-100">
|
|
<div className="card-header">
|
|
<h3 className="card-title">
|
|
{n.node}
|
|
{n.pveVersion && <span className="text-secondary fw-normal ms-2">{n.pveVersion}</span>}
|
|
</h3>
|
|
<div className="card-actions">
|
|
<button
|
|
className="btn btn-sm btn-outline-secondary"
|
|
onClick={() => selected && loadNodeStats(selected.id)}
|
|
disabled={loadingNodeStats}
|
|
>
|
|
{loadingNodeStats ? "Refreshing…" : "Refresh"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="card-body">
|
|
{n.error && <div className="alert alert-danger">{n.error}</div>}
|
|
<div className="row g-3">
|
|
<div className="col-md-6">
|
|
<div className="mb-3">
|
|
<div className="text-secondary small">Uptime</div>
|
|
<div>{formatUptime(n.uptime)}</div>
|
|
</div>
|
|
<div className="mb-3">
|
|
<div className="text-secondary small">CPU</div>
|
|
<div>
|
|
{n.cpuUsagePercent !== null ? `${n.cpuUsagePercent.toFixed(1)}%` : "—"}
|
|
{n.cpuCores ? <span className="text-secondary"> ({n.cpuCores} cores)</span> : null}
|
|
</div>
|
|
{n.loadAverage && (
|
|
<div className="text-secondary small">Load: {n.loadAverage.map((l) => l.toFixed(2)).join(", ")}</div>
|
|
)}
|
|
</div>
|
|
<div className={n.swapTotalBytes ? "mb-3" : ""}>
|
|
<div className="text-secondary small">Memory</div>
|
|
<div>
|
|
{formatBytes(n.memUsedBytes)} / {formatBytes(n.memTotalBytes)}
|
|
</div>
|
|
{usageBar(n.memUsedBytes, n.memTotalBytes)}
|
|
</div>
|
|
{!!n.swapTotalBytes && (
|
|
<div>
|
|
<div className="text-secondary small">Swap</div>
|
|
<div>
|
|
{formatBytes(n.swapUsedBytes)} / {formatBytes(n.swapTotalBytes)}
|
|
</div>
|
|
{usageBar(n.swapUsedBytes, n.swapTotalBytes)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="col-md-6">
|
|
<div className="text-secondary small mb-1">Storage</div>
|
|
{n.storages.length > 0 ? (
|
|
<table className="table table-sm table-vcenter mb-0">
|
|
<tbody>
|
|
{n.storages.map((s) => (
|
|
<tr key={s.id}>
|
|
<td>
|
|
{s.id}
|
|
{!s.active && <span className="badge bg-secondary-lt text-secondary ms-1">Inactive</span>}
|
|
</td>
|
|
<td className="text-secondary">{s.type}</td>
|
|
<td>
|
|
{formatBytes(s.usedBytes)} / {formatBytes(s.totalBytes)}
|
|
{usageBar(s.usedBytes, s.totalBytes)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
) : (
|
|
!n.error && (
|
|
<div className="text-secondary small">
|
|
No storage visible to this API token. Proxmox filters the storage list
|
|
per-token — grant it <code>Datastore.Audit</code> (e.g. on{" "}
|
|
<code>/storage</code>) to see disk usage here.
|
|
</div>
|
|
)
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
{nodeStats?.length === 0 && (
|
|
<div className="col-12">
|
|
<div className="card">
|
|
<div className="card-body text-secondary">No online Proxmox nodes found.</div>
|
|
</div>
|
|
</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={exportGuestsCsv}
|
|
disabled={!sortedGuests || sortedGuests.length === 0}
|
|
>
|
|
Export CSV
|
|
</button>
|
|
<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>
|
|
<SortableTh<ProxmoxGuest> label="Name" sortKeyName="name" activeKey={guestSortKey} direction={guestSortDir} onSort={requestGuestSort} />
|
|
<SortableTh<ProxmoxGuest> label="Node" sortKeyName="node" activeKey={guestSortKey} direction={guestSortDir} onSort={requestGuestSort} />
|
|
<SortableTh<ProxmoxGuest> label="Type" sortKeyName="type" activeKey={guestSortKey} direction={guestSortDir} onSort={requestGuestSort} />
|
|
<SortableTh<ProxmoxGuest>
|
|
label="Uptime"
|
|
sortKeyName="uptime"
|
|
activeKey={guestSortKey}
|
|
direction={guestSortDir}
|
|
onSort={requestGuestSort}
|
|
/>
|
|
<SortableTh<ProxmoxGuest>
|
|
label="Status"
|
|
sortKeyName="status"
|
|
activeKey={guestSortKey}
|
|
direction={guestSortDir}
|
|
onSort={requestGuestSort}
|
|
/>
|
|
{canEdit && <th className="w-1">Actions</th>}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{guestPageItems?.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>
|
|
))}
|
|
{sortedGuests?.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>
|
|
<Pagination page={guestPage} pageCount={guestPageCount} totalCount={guestTotalCount} onPageChange={setGuestPage} />
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|