feat: move proxmox node stats and collectors into the proxmox plugin

This commit is contained in:
LukeGus committed 2026-09-23 13:06:18 -05:00
1 parent 218c5088e7
commit 37c015a760
37 files changed
+3775 -227

No files matched your search

@@ -0,0 +1,261 @@
import { useMemo, useState } from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Box, Search, Server as ServerIcon } from "lucide-react";
import { Input } from "@termix/plugin-sdk/ui";
import type { ProxmoxGuestSummary } from "../types";
import { cn } from "./utils";
type StatusFilter = "all" | "running" | "stopped";
type TypeFilter = "all" | "qemu" | "lxc";
function formatUptime(seconds: number | null): string {
if (seconds === null || seconds <= 0) return "-";
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) return `${days}d ${hours}h`;
if (hours > 0) return `${hours}h ${minutes}m`;
return `${minutes}m`;
}
function UsageCell({
percent,
usedGiB,
totalGiB,
}: {
percent: number | null;
usedGiB?: number | null;
totalGiB?: number | null;
}) {
if (percent === null) {
return <span className="text-[11px] text-muted-foreground">-</span>;
}
const clamped = Math.min(100, Math.max(0, percent));
const barColor =
clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-[92px] flex-col gap-1">
<div className="flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold tabular-nums">
{clamped.toFixed(0)}%
</span>
{usedGiB != null && totalGiB != null && (
<span className="text-[10px] tabular-nums text-muted-foreground">
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full", barColor)}
style={{ width: `${clamped}%` }}
/>
</div>
</div>
);
}
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
const { t } = useTranslation();
const [query, setQuery] = useState("");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
return guests
.filter(
(g) =>
statusFilter === "all" ||
(statusFilter === "running") === (g.status === "running"),
)
.filter((g) => typeFilter === "all" || g.type === typeFilter)
.filter(
(g) =>
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
)
.sort((a, b) => {
if (a.status === b.status) return a.name.localeCompare(b.name);
return a.status === "running" ? -1 : 1;
});
}, [guests, query, statusFilter, typeFilter]);
const running = guests.filter((g) => g.status === "running").length;
return (
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
{t("proxmoxStats.guestsSummary")}
</span>
<span className="text-[11px] text-muted-foreground">
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
</span>
<div className="ml-auto flex items-center gap-1.5">
<div className="relative">
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("proxmoxStats.searchGuests")}
className="h-7 w-40 pl-6 text-xs"
/>
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
<button
key={f}
onClick={() => setTypeFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
typeFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "qemu"
? t("proxmoxStats.filterVm")
: t("proxmoxStats.filterLxc")}
</button>
))}
</div>
<div className="flex overflow-hidden rounded-none border border-border">
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
statusFilter === f
? "bg-accent-brand text-white"
: "bg-background text-muted-foreground hover:bg-muted",
)}
>
{f === "all"
? t("proxmoxStats.filterAll")
: f === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</button>
))}
</div>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
{guests.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuests")}
</div>
) : filtered.length === 0 ? (
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
{t("proxmoxStats.noGuestsMatch")}
</div>
) : (
<table className="w-full border-collapse text-xs">
<thead className="sticky top-0 z-10 bg-card">
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.name")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.status")}
</th>
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.cpu")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.mem")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.disk")}
</th>
<th className="px-3 py-2 text-left font-semibold">
{t("proxmoxStats.uptime")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filtered.map((guest) => (
<tr
key={`${guest.type}-${guest.vmid}`}
className="transition-colors hover:bg-muted/40"
>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{guest.type === "lxc" ? (
<Box className="size-3.5 shrink-0 text-muted-foreground" />
) : (
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
)}
<span className="min-w-0 truncate font-semibold">
{guest.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
{guest.type === "lxc" ? "LXC" : "VM"}
</span>
</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
guest.status === "running"
? "bg-green-500"
: "bg-muted-foreground/50",
)}
/>
<span
className={cn(
"font-medium",
guest.status === "running"
? "text-foreground"
: "text-muted-foreground",
)}
>
{guest.status === "running"
? t("proxmoxStats.running")
: t("proxmoxStats.stopped")}
</span>
</div>
</td>
<td className="px-3 py-2 font-mono text-muted-foreground">
{guest.vmid}
</td>
<td className="px-3 py-2">
<UsageCell percent={guest.cpuPercent} />
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.memPercent}
usedGiB={guest.memUsedGiB}
totalGiB={guest.memTotalGiB}
/>
</td>
<td className="px-3 py-2">
<UsageCell
percent={guest.diskPercent}
usedGiB={guest.diskUsedGiB}
totalGiB={guest.diskTotalGiB}
/>
</td>
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
{formatUptime(guest.uptimeSeconds)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,85 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Server } from "lucide-react";
import {
Input,
SectionCard,
SettingRow,
FakeSwitch,
} from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsConfig } from "../types";
interface HostProxmoxStatsForm {
enableProxmoxStats: boolean;
proxmoxStatsConfig?: ProxmoxStatsConfig | null;
}
type SetHostField = (key: string, value: unknown) => void;
export function HostProxmoxStatsTab({
form,
setField,
}: {
form: HostProxmoxStatsForm;
setField: SetHostField;
}) {
const { t } = useTranslation();
return (
<SectionCard
title={t("hosts.enableProxmoxStats")}
icon={<Server className="size-3.5" />}
>
<div className="flex flex-col gap-0 py-1">
<SettingRow
label={t("hosts.enableProxmoxStats")}
description={t("hosts.enableProxmoxStatsDesc")}
>
<FakeSwitch
checked={form.enableProxmoxStats}
onChange={(v) => setField("enableProxmoxStats", v)}
/>
</SettingRow>
{form.enableProxmoxStats && (
<>
<SettingRow
label={t("hosts.proxmoxStatsPollInterval")}
description={t("hosts.proxmoxStatsPollIntervalDesc")}
>
<Input
className="w-24 h-7 text-xs"
type="number"
min={15}
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
pollInterval: Math.max(
15,
Number.parseInt(e.target.value || "60", 10),
),
})
}
/>
</SettingRow>
<SettingRow
label={t("hosts.proxmoxStatsNodeOverride")}
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
>
<Input
className="w-44 h-7 text-xs"
value={form.proxmoxStatsConfig?.nodeName ?? ""}
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
onChange={(e) =>
setField("proxmoxStatsConfig", {
...form.proxmoxStatsConfig,
nodeName: e.target.value || null,
})
}
/>
</SettingRow>
</>
)}
</div>
</SectionCard>
);
}
@@ -0,0 +1,147 @@
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
import type { ProxmoxNodeStats } from "../types";
import { cn } from "./utils";
function Tile({
icon,
label,
percent,
detail,
sparkline,
}: {
icon: React.ReactNode;
label: string;
percent: number | null;
detail?: string | null;
sparkline?: number[];
}) {
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
const barColor =
clamped === null
? "bg-muted-foreground/30"
: clamped >= 90
? "bg-red-500"
: clamped >= 75
? "bg-yellow-500"
: "bg-accent-brand";
return (
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
{icon}
<span className="text-[10px] font-bold uppercase tracking-widest">
{label}
</span>
</div>
<div className="flex items-baseline justify-between gap-2">
<span className="text-2xl font-bold tabular-nums">
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
</span>
{detail && (
<span className="truncate text-[11px] text-muted-foreground">
{detail}
</span>
)}
</div>
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
<div
className={cn("h-full rounded-full transition-all", barColor)}
style={{ width: `${clamped ?? 0}%` }}
/>
</div>
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
</div>
);
}
function MiniSparkline({ data }: { data: number[] }) {
const w = 100;
const h = 20;
const max = 100;
const points = data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(" ");
return (
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
<polyline
points={points}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
export function NodeSummaryStrip({
node,
histories,
}: {
node: ProxmoxNodeStats | undefined;
histories: { cpu: number[]; memory: number[]; disk: number[] };
}) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap gap-2">
<Tile
icon={<Cpu className="size-3.5" />}
label={t("proxmoxStats.cpuUsage")}
percent={node?.cpu.percent ?? null}
detail={
node?.cpu.cores != null
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
: undefined
}
sparkline={histories.cpu}
/>
<Tile
icon={<MemoryStick className="size-3.5" />}
label={t("proxmoxStats.memoryUsage")}
percent={node?.memory.percent ?? null}
detail={
node?.memory.usedGiB != null && node?.memory.totalGiB != null
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.memory}
/>
<Tile
icon={<HardDrive className="size-3.5" />}
label={t("proxmoxStats.diskUsage")}
percent={node?.disk.percent ?? null}
detail={
node?.disk.usedGiB != null && node?.disk.totalGiB != null
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
: undefined
}
sparkline={histories.disk}
/>
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
<div className="flex items-center gap-2 text-muted-foreground">
<Clock className="size-3.5" />
<span className="text-[10px] font-bold uppercase tracking-widest">
{t("proxmoxStats.uptime")}
</span>
</div>
<span className="text-2xl font-bold">
{node?.uptime.formatted ?? "N/A"}
</span>
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
<Server className="size-3 shrink-0" />
<span className="truncate">
{node?.system.hostname ?? "-"}
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,51 @@
import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
import { ProxmoxStatsTab } from "./ProxmoxStatsTab";
interface ProxmoxStatsAppProps {
hostId?: string;
}
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
const { t } = useTranslation();
return (
<FullScreenAppWrapper hostId={hostId}>
{(hostConfig, phase) => {
if (phase === "loading") {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="connecting"
message={t("hosts.loadingHost")}
/>
</div>
);
}
if (!hostConfig) {
return (
<div className="relative h-full w-full">
<ConnectionScreen
status="disconnected"
message={t("hosts.hostNotFound")}
/>
</div>
);
}
return (
<ProxmoxStatsTab
hostConfig={hostConfig}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true}
isTopbarOpen={false}
embedded={true}
/>
);
}}
</FullScreenAppWrapper>
);
};
export default ProxmoxStatsApp;
@@ -0,0 +1,382 @@
/* eslint-disable react-hooks/exhaustive-deps */
import React from "react";
import {
Button,
ConnectionScreen,
ConnectionLogProvider,
useConnectionLog,
} from "@termix/plugin-sdk/ui";
import {
useTranslation,
usePluginApi,
useHosts,
} from "@termix/plugin-sdk/frontend";
import { RefreshCw, Server } from "lucide-react";
import { createProxmoxStatsApi } from "./proxmox-stats-api";
import type { ProxmoxStatsSnapshot, ProxmoxStatsConfig } from "../types";
import { NodeSummaryStrip } from "./NodeSummaryStrip";
import { GuestTable } from "./GuestTable";
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
import { useConnectionRetry } from "./useConnectionRetry";
import { runAdaptivePolling } from "./adaptive-polling";
const HISTORY_LEN = 30;
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
const bucket = (value: number | null | undefined) =>
value == null ? null : Math.round(value / 5) * 5;
return JSON.stringify({
cpu: bucket(data.node.cpu.percent),
memory: bucket(data.node.memory.percent),
disk: bucket(data.node.disk.percent),
guests: data.guests.counts,
cluster: data.cluster,
});
}
const DEFAULT_POLL_INTERVAL = 60;
interface HostConfig {
id: number;
name: string;
ip: string;
username: string;
enableProxmoxStats?: boolean;
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
authType?: string;
port?: number;
[key: string]: unknown;
}
interface ProxmoxStatsProps {
hostConfig?: HostConfig;
title?: string;
isVisible?: boolean;
isTopbarOpen?: boolean;
embedded?: boolean;
}
interface ProxmoxStatsHistories {
cpu: number[];
memory: number[];
disk: number[];
}
function parseProxmoxStatsConfig(
raw?: string | ProxmoxStatsConfig | null,
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
if (!raw) return defaults;
try {
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
return { ...defaults, ...parsed };
} catch {
return defaults;
}
}
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
return (
<ConnectionLogProvider>
<ProxmoxStatsInner {...props} />
</ConnectionLogProvider>
);
}
function ProxmoxStatsInner({
hostConfig,
title,
isVisible = true,
isTopbarOpen = true,
embedded = false,
}: ProxmoxStatsProps): React.ReactElement {
const { t } = useTranslation();
const { addLog, clearLogs } = useConnectionLog();
const pluginApi = usePluginApi();
const api = React.useMemo(
() => createProxmoxStatsApi(pluginApi),
[pluginApi],
);
const { hosts } = useHosts();
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
null,
);
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
cpu: [],
memory: [],
disk: [],
});
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
const [isRefreshing, setIsRefreshing] = React.useState(false);
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
null,
);
const statsConfig = React.useMemo(
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
[currentHostConfig?.proxmoxStatsConfig],
);
React.useEffect(() => {
const onVis = () => setIsPageVisible(!document.hidden);
document.addEventListener("visibilitychange", onVis);
return () => document.removeEventListener("visibilitychange", onVis);
}, []);
const isActuallyVisible = isVisible && isPageVisible;
React.useEffect(() => {
if (!viewerSessionId || !isActuallyVisible) return;
const interval = setInterval(() => {
api.sendHeartbeat(viewerSessionId).catch(() => {});
}, 30000);
return () => clearInterval(interval);
}, [viewerSessionId, isActuallyVisible, api]);
React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) {
setSnapshot(null);
setHistories({ cpu: [], memory: [], disk: [] });
}
setCurrentHostConfig(hostConfig);
}, [hostConfig?.id]);
React.useEffect(() => {
if (!hostConfig?.id) return;
const updated = hosts.find((h) => String(h.id) === String(hostConfig.id));
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
}, [hostConfig?.id, hosts]);
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
setHistories((prev) => {
const add = (arr: number[], v: number | null | undefined) =>
[...arr, v ?? 0].slice(-HISTORY_LEN);
return {
cpu: add(prev.cpu, data.node?.cpu?.percent),
memory: add(prev.memory, data.node?.memory?.percent),
disk: add(prev.disk, data.node?.disk?.percent),
};
});
}, []);
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
const stopPollingRef = React.useRef<(() => void) | null>(null);
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
if (!currentHostConfig?.id) return;
addLog({
type: "info",
stage: "stats_connecting",
message: t("proxmoxStats.connecting"),
});
const result = await api.startPolling(currentHostConfig.id);
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
addLog({
type: "info",
stage: "stats_polling",
message: t("proxmoxStats.connecting"),
});
const data = await api.getStats(currentHostConfig.id);
if (!data) {
throw new Error(t("proxmoxStats.connectionFailed"));
}
setSnapshot(data);
addLog({
type: "success",
stage: "connected",
message: t("terminal.connected"),
});
const intervalMs =
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
let signature = statsChangeKey(data);
stopPollingRef.current?.();
stopPollingRef.current = runAdaptivePolling(
async () => {
const next = await api.getStats(currentHostConfig.id);
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
const nextSignature = statsChangeKey(next);
const changed = nextSignature !== signature;
signature = nextSignature;
setSnapshot(next);
pushHistory(next);
return changed;
},
{
minIntervalMs: intervalMs,
maxIntervalMs: Math.min(120_000, intervalMs * 6),
stablePollsPerStep: 3,
},
{ runImmediately: false },
);
}, [
currentHostConfig?.id,
statsConfig.pollInterval,
addLog,
t,
pushHistory,
api,
]);
const retry = useConnectionRetry({
connect: async () => {
try {
await fetchSnapshot();
retry.markConnected();
} catch (error: unknown) {
addLog({
type: "error",
stage: "error",
message:
error instanceof Error
? error.message
: t("proxmoxStats.connectionFailed"),
});
retry.markFailed();
}
},
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
autoStart: false,
});
const retryRef = React.useRef(retry);
retryRef.current = retry;
// Connects once per host and stays connected while this tab exists, even
// when the user switches to another tab and back. Only the browser tab
// going into the background (isPageVisible) pauses/resumes it -- switching
// between Termix tabs must not tear down and reconnect the session.
React.useEffect(() => {
if (notEnabled || !currentHostConfig?.id) return;
let cancelled = false;
const debounce = setTimeout(() => {
if (isPageVisible && !cancelled) {
clearLogs();
retryRef.current.reset();
retryRef.current.retryNow();
} else if (!isPageVisible) {
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
api.stopPolling(currentHostConfig.id, undefined).catch(() => {});
}
}
}, 500);
return () => {
cancelled = true;
clearTimeout(debounce);
stopPollingRef.current?.();
stopPollingRef.current = null;
if (currentHostConfig?.id) {
api.stopPolling(currentHostConfig.id).catch(() => {});
}
};
}, [currentHostConfig?.id, notEnabled, isPageVisible, api]);
const wrapperStyle: React.CSSProperties = embedded
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
: {
opacity: isVisible ? 1 : 0,
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
};
const handleRefresh = async () => {
if (!currentHostConfig?.id) return;
if (retry.status !== "connected") {
retry.retryNow();
return;
}
try {
setIsRefreshing(true);
const data = await api.getStats(currentHostConfig.id);
if (data) {
setSnapshot(data);
pushHistory(data);
}
} finally {
setIsRefreshing(false);
}
};
const showContent = !notEnabled && retry.status === "connected" && snapshot;
return (
<div
style={wrapperStyle}
className="relative flex flex-col overflow-hidden"
>
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
{retry.status === "connected" && !notEnabled && (
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
<Server className="size-5 text-accent-brand" />
</div>
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
</div>
<Button
variant="outline"
size="default"
onClick={handleRefresh}
disabled={isRefreshing}
className="gap-2 font-semibold"
>
<RefreshCw
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
/>
{t("proxmoxStats.refresh")}
</Button>
</div>
)}
{showContent && (
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
<div className="shrink-0">
<NodeSummaryStrip node={snapshot.node} histories={histories} />
</div>
<GuestTable guests={snapshot.guests.guests} />
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
<NodeNetworkCard snapshot={snapshot} />
<StoragePoolsCard snapshot={snapshot} />
{snapshot.cluster.clustered && (
<ClusterHealthCard snapshot={snapshot} />
)}
</div>
</div>
)}
<ConnectionScreen
status={notEnabled ? "connected" : retry.status}
message={t("proxmoxStats.connecting")}
attempt={retry.attempt}
maxAttempts={retry.maxAttempts}
nextRetryInMs={retry.nextRetryInMs}
onManualRetry={retry.retryNow}
emptyState={
notEnabled ? (
<div className="text-center opacity-40">
<Server className="mx-auto mb-4 size-16" />
<p className="text-xl font-bold uppercase tracking-widest">
{t("proxmoxStats.noHostSelected")}
</p>
</div>
) : undefined
}
/>
</div>
</div>
);
}
@@ -0,0 +1,132 @@
export interface AdaptivePollingPolicy {
minIntervalMs: number;
maxIntervalMs: number;
stablePollsPerStep?: number;
jitterRatio?: number;
/** Prevent expensive requests from consuming more than this share of time. */
maxRequestDutyCycle?: number;
}
export interface AdaptivePollingState {
stablePolls: number;
consecutiveFailures: number;
lastPollDurationMs?: number;
}
export type AdaptivePollResult = boolean | void;
export function computeAdaptivePollDelay(
policy: AdaptivePollingPolicy,
state: AdaptivePollingState,
random = Math.random,
): number {
const min = Math.max(250, policy.minIntervalMs);
const max = Math.max(min, policy.maxIntervalMs);
const stableStep = Math.floor(
state.stablePolls / Math.max(1, policy.stablePollsPerStep ?? 3),
);
const exponent = state.consecutiveFailures || stableStep;
let base = Math.min(max, min * 2 ** Math.min(exponent, 10));
const dutyCycle = policy.maxRequestDutyCycle;
if (dutyCycle && dutyCycle > 0 && dutyCycle < 1) {
const duration = Math.max(0, state.lastPollDurationMs ?? 0);
base = Math.max(base, duration * ((1 - dutyCycle) / dutyCycle));
}
base = Math.min(max, base);
const jitterRatio = Math.max(0, Math.min(0.5, policy.jitterRatio ?? 0.1));
const jitter = base * jitterRatio * (random() * 2 - 1);
return Math.max(min, Math.min(max, Math.round(base + jitter)));
}
// Copy of src/ui/lib/adaptive-polling.ts. Duplicated here rather than exposed
// through the SDK because only this plugin's Proxmox stats tab uses it
// today; promote to @termix/plugin-sdk/frontend once a second plugin needs it.
export function runAdaptivePolling(
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
policy: AdaptivePollingPolicy,
options: {
enabled?: () => boolean;
visible?: () => boolean;
runImmediately?: boolean;
random?: () => number;
intervalMultiplier?: () => number;
onError?: (error: unknown) => void;
} = {},
): () => void {
const enabled = options.enabled ?? (() => true);
const visible =
options.visible ??
(() =>
typeof document === "undefined" || document.visibilityState !== "hidden");
const state: AdaptivePollingState = {
stablePolls: 0,
consecutiveFailures: 0,
};
let timer: ReturnType<typeof setTimeout> | null = null;
let stopped = false;
let inFlight = false;
const clearTimer = () => {
if (!timer) return;
clearTimeout(timer);
timer = null;
};
const schedule = () => {
clearTimer();
if (stopped || !enabled() || !visible()) return;
timer = setTimeout(
() => void tick(),
Math.min(
policy.maxIntervalMs,
Math.round(
computeAdaptivePollDelay(policy, state, options.random) *
Math.max(1, options.intervalMultiplier?.() ?? 1),
),
),
);
};
const tick = async () => {
if (stopped || inFlight || !enabled() || !visible()) return;
inFlight = true;
const startedAt = Date.now();
try {
const changed = await poll();
state.consecutiveFailures = 0;
state.stablePolls = changed === false ? state.stablePolls + 1 : 0;
} catch (error) {
state.stablePolls = 0;
state.consecutiveFailures += 1;
options.onError?.(error);
} finally {
state.lastPollDurationMs = Date.now() - startedAt;
inFlight = false;
schedule();
}
};
const onVisibility = () => {
if (!visible()) {
clearTimer();
return;
}
state.stablePolls = 0;
state.consecutiveFailures = 0;
void tick();
};
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", onVisibility);
}
if (options.runImmediately ?? true) void tick();
else schedule();
return () => {
stopped = true;
clearTimer();
if (typeof document !== "undefined") {
document.removeEventListener("visibilitychange", onVisibility);
}
};
}
@@ -0,0 +1,67 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
import { cn } from "../utils";
export function ClusterHealthCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const cluster = snapshot?.cluster;
return (
<MetricCard
title={t("proxmoxStats.clusterHealth")}
icon={<Network className="size-3.5" />}
scroll
>
{!cluster || !cluster.clustered ? (
<span className="text-xs text-muted-foreground">
{t("proxmoxStats.notClustered")}
</span>
) : (
<div className="flex flex-col gap-2 text-xs">
<div className="flex items-center gap-2">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
cluster.quorate ? "bg-green-500" : "bg-red-500",
)}
/>
<span className="font-medium">
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
</span>
<span className="text-muted-foreground">
{cluster.quorate
? t("proxmoxStats.quorate")
: t("proxmoxStats.notQuorate")}
</span>
</div>
<div className="flex flex-col divide-y divide-border">
{cluster.nodes.map((node) => (
<div key={node.name} className="flex items-center gap-2 py-1.5">
<span
className={cn(
"size-1.5 shrink-0 rounded-full",
node.online ? "bg-green-500" : "bg-muted-foreground/50",
)}
/>
<span className="min-w-0 flex-1 truncate font-medium">
{node.name}
</span>
<span className="shrink-0 text-[10px] text-muted-foreground">
{node.online
? t("proxmoxStats.nodeOnline")
: t("proxmoxStats.nodeOffline")}
</span>
</div>
))}
</div>
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,36 @@
import { Network } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
export function NodeNetworkCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const interfaces = snapshot?.network.interfaces ?? [];
return (
<MetricCard
title={t("proxmoxStats.networkInterfaces")}
icon={<Network className="size-3.5" />}
scroll
>
{interfaces.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{interfaces.map((iface) => (
<StatRow
key={iface.name}
label={iface.name}
value={iface.ip ?? iface.state ?? "-"}
mono
/>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,60 @@
import { Database } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend";
import { MetricCard } from "@termix/plugin-sdk/ui";
import type { ProxmoxStatsSnapshot } from "../../types";
export function StoragePoolsCard({
snapshot,
}: {
snapshot: ProxmoxStatsSnapshot | null;
}) {
const { t } = useTranslation();
const pools = snapshot?.storage.pools ?? [];
return (
<MetricCard
title={t("proxmoxStats.storagePools")}
icon={<Database className="size-3.5" />}
scroll
>
{pools.length === 0 ? (
<span className="text-xs text-muted-foreground">N/A</span>
) : (
<div className="flex flex-col divide-y divide-border">
{pools.map((pool) => (
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate font-medium">
{pool.name}
</span>
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
{pool.type}
</span>
{!pool.active && (
<span className="shrink-0 text-[10px] text-muted-foreground">
{t("proxmoxStats.inactive")}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-accent-brand"
style={{
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
}}
/>
</div>
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
{pool.usedGiB !== null && pool.totalGiB !== null
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
: "N/A"}
</span>
</div>
</div>
))}
</div>
)}
</MetricCard>
);
}
@@ -0,0 +1,80 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
import type { ProxmoxStatsSnapshot } from "../types";
/**
* The Proxmox node-stats routes, through the plugin's own client. Paths are
* relative to /plugin-api/proxmox/, which the client already points at.
*/
export function createProxmoxStatsApi(api: PluginApiClient) {
return {
async getStats(hostId: number): Promise<ProxmoxStatsSnapshot | null> {
try {
const response = await api.get<ProxmoxStatsSnapshot>(
`/stats/${hostId}`,
);
return response.data;
} catch (error) {
if (
typeof error === "object" &&
error &&
"response" in error &&
(error as { response?: { status?: number } }).response?.status === 404
) {
return null;
}
throw error;
}
},
async startPolling(hostId: number): Promise<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}> {
const response = await api.post<{
success: boolean;
viewerSessionId?: string;
status?: string;
error?: string;
}>(`/stats/start/${hostId}`);
return response.data;
},
async stopPolling(hostId: number, viewerSessionId?: string): Promise<void> {
await api.post(`/stats/stop/${hostId}`, { viewerSessionId });
},
async sendHeartbeat(viewerSessionId: string): Promise<void> {
await api.post("/stats/heartbeat", { viewerSessionId });
},
async getHistory(
hostId: number,
opts: { range?: string; from?: string; to?: string },
): Promise<ProxmoxStatsHistoryResponse> {
const response = await api.get<ProxmoxStatsHistoryResponse>(
`/stats/history/${hostId}`,
{ params: opts },
);
return response.data;
},
};
}
export type ProxmoxStatsApi = ReturnType<typeof createProxmoxStatsApi>;
export interface ProxmoxStatsHistoryRow {
ts: string;
cpu_percent: number | null;
mem_percent: number | null;
disk_percent: number | null;
net_rx_bytes: number | null;
net_tx_bytes: number | null;
}
export interface ProxmoxStatsHistoryResponse {
rows: ProxmoxStatsHistoryRow[];
fromTs: string;
toTs: string;
}
@@ -0,0 +1,193 @@
import { useCallback, useEffect, useRef, useState } from "react";
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
interface UseConnectionRetryOptions {
connect: () => void | Promise<void>;
maxAttempts?: number;
baseDelayMs?: number;
maxDelayMs?: number;
enabled?: boolean;
autoStart?: boolean;
}
interface UseConnectionRetryResult {
status: ConnectionStatus;
attempt: number;
maxAttempts: number;
nextRetryInMs: number | null;
markConnected: () => void;
markFailed: () => void;
retryNow: () => void;
reset: () => void;
}
const COUNTDOWN_TICK_MS = 250;
const RETRY_JITTER_RATIO = 0.1;
export function computeReconnectDelay(
attempt: number,
baseDelayMs: number,
maxDelayMs: number,
random = Math.random,
): number {
const base = Math.min(
baseDelayMs * Math.pow(2, Math.max(0, attempt - 1)),
maxDelayMs,
);
const jitter = base * RETRY_JITTER_RATIO * (random() * 2 - 1);
return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(base + jitter)));
}
// Copy of src/ui/lib/useConnectionRetry.ts. Duplicated here rather than
// exposed through the SDK because only this plugin's Proxmox stats tab uses
// it today; promote to @termix/plugin-sdk/ui once a second plugin needs it.
export function useConnectionRetry({
connect,
maxAttempts = 8,
baseDelayMs = 2000,
maxDelayMs = 8000,
enabled = true,
autoStart = true,
}: UseConnectionRetryOptions): UseConnectionRetryResult {
const [status, setStatus] = useState<ConnectionStatus>("connecting");
const [attempt, setAttempt] = useState(0);
const [nextRetryInMs, setNextRetryInMs] = useState<number | null>(null);
const connectRef = useRef(connect);
connectRef.current = connect;
const enabledRef = useRef(enabled);
enabledRef.current = enabled;
const attemptRef = useRef(0);
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
null,
);
const isMountedRef = useRef(true);
const markFailedRef = useRef<() => void>(() => {});
const clearTimers = useCallback(() => {
if (retryTimeoutRef.current) {
clearTimeout(retryTimeoutRef.current);
retryTimeoutRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}, []);
const runConnect = useCallback(() => {
if (!isMountedRef.current) return;
setStatus("connecting");
try {
void Promise.resolve(connectRef.current()).catch(() => {
markFailedRef.current();
});
} catch {
markFailedRef.current();
}
}, []);
const scheduleRetry = useCallback(() => {
if (attemptRef.current >= maxAttempts) {
setStatus("disconnected");
setNextRetryInMs(null);
return;
}
attemptRef.current += 1;
setAttempt(attemptRef.current);
const delay = computeReconnectDelay(
attemptRef.current,
baseDelayMs,
maxDelayMs,
);
let remaining = delay;
setNextRetryInMs(remaining);
countdownIntervalRef.current = setInterval(() => {
remaining = Math.max(0, remaining - COUNTDOWN_TICK_MS);
setNextRetryInMs(remaining);
}, COUNTDOWN_TICK_MS);
retryTimeoutRef.current = setTimeout(() => {
clearTimers();
if (!isMountedRef.current || !enabledRef.current) return;
runConnect();
}, delay);
}, [baseDelayMs, maxDelayMs, maxAttempts, clearTimers, runConnect]);
const markConnected = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connected");
}, [clearTimers]);
const markFailed = useCallback(() => {
clearTimers();
if (!isMountedRef.current) return;
setStatus("error");
if (enabledRef.current) {
scheduleRetry();
} else {
setNextRetryInMs(null);
}
}, [clearTimers, scheduleRetry]);
markFailedRef.current = markFailed;
const retryNow = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
runConnect();
}, [clearTimers, runConnect]);
const reset = useCallback(() => {
clearTimers();
attemptRef.current = 0;
setAttempt(0);
setNextRetryInMs(null);
if (isMountedRef.current) setStatus("connecting");
}, [clearTimers]);
useEffect(() => {
isMountedRef.current = true;
if (autoStart) {
runConnect();
}
return () => {
isMountedRef.current = false;
clearTimers();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
if (enabled && status === "error" && !retryTimeoutRef.current) {
scheduleRetry();
}
if (!enabled) {
clearTimers();
setNextRetryInMs(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled]);
return {
status,
attempt,
maxAttempts,
nextRetryInMs,
markConnected,
markFailed,
retryNow,
reset,
};
}
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}