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

+33 -4
View File
@@ -24,10 +24,39 @@
"ui:surface"
],
"contributes": {
"hostCapability": {
"key": "enableProxmox",
"labelKey": "hosts.proxmoxIntegration",
"editorTab": "proxmox"
"tabs": [
{
"id": "proxmox-stats",
"titleKey": "nav.proxmoxStats",
"icon": "HardDrive",
"openFrom": ["host-context-menu", "palette"]
}
],
"settings": {
"host": {
"fields": [
{
"key": "enableProxmox",
"type": "custom",
"component": "proxmoxHostSettings"
},
{
"key": "proxmoxConfig",
"type": "custom",
"component": "proxmoxHostSettings"
},
{
"key": "enableProxmoxStats",
"type": "custom",
"component": "proxmoxHostSettings"
},
{
"key": "proxmoxStatsConfig",
"type": "custom",
"component": "proxmoxHostSettings"
}
]
}
}
},
"locales": "locales",
@@ -0,0 +1,26 @@
-- proxmox 0001: adopt_proxmox_stats
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `proxmox_node_history` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`host_id` int NOT NULL,
`ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`cpu_percent` double,
`mem_percent` double,
`disk_percent` double,
`net_rx_bytes` int,
`net_tx_bytes` int,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `proxmox_node_history` TO `p_proxmox_node_history`;
CREATE TABLE IF NOT EXISTS `proxmox_stats_preferences` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`user_id` varchar(255) NOT NULL,
`host_id` int NOT NULL,
`layout` text NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
);
RENAME TABLE `proxmox_stats_preferences` TO `p_proxmox_stats_preferences`;
@@ -0,0 +1,27 @@
-- proxmox 0001: adopt_proxmox_stats
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "proxmox_node_history" (
"id" serial PRIMARY KEY,
"host_id" integer NOT NULL,
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" double precision,
"mem_percent" double precision,
"disk_percent" double precision,
"net_rx_bytes" integer,
"net_tx_bytes" integer,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
ALTER TABLE "proxmox_node_history" RENAME TO "p_proxmox_node_history";
CREATE TABLE IF NOT EXISTS "proxmox_stats_preferences" (
"id" serial PRIMARY KEY,
"user_id" varchar(255) NOT NULL,
"host_id" integer NOT NULL,
"layout" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_proxmox_stats_prefs_user_host" ON "proxmox_stats_preferences" ("user_id", "host_id");
ALTER TABLE "proxmox_stats_preferences" RENAME TO "p_proxmox_stats_preferences";
+80
View File
@@ -0,0 +1,80 @@
{
"version": 1,
"tables": {
"node_history": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"ts": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"cpuPercent": {
"type": "real"
},
"memPercent": {
"type": "real"
},
"diskPercent": {
"type": "real"
},
"netRxBytes": {
"type": "integer"
},
"netTxBytes": {
"type": "integer"
}
},
"indexes": [],
"adopts": "proxmox_node_history"
},
"stats_preferences": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"userId": {
"type": "refUser",
"length": 255,
"notNull": true
},
"hostId": {
"type": "refHost",
"notNull": true
},
"layout": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": [
{
"name": "idx_proxmox_stats_prefs_user_host",
"columns": ["userId", "hostId"],
"unique": true
}
],
"adopts": "proxmox_stats_preferences"
}
}
}
@@ -0,0 +1,27 @@
-- proxmox 0001: adopt_proxmox_stats
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "proxmox_node_history" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"host_id" integer NOT NULL,
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"cpu_percent" real,
"mem_percent" real,
"disk_percent" real,
"net_rx_bytes" integer,
"net_tx_bytes" integer,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
ALTER TABLE "proxmox_node_history" RENAME TO "p_proxmox_node_history";
CREATE TABLE IF NOT EXISTS "proxmox_stats_preferences" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"user_id" text NOT NULL,
"host_id" integer NOT NULL,
"layout" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
);
CREATE UNIQUE INDEX IF NOT EXISTS "idx_proxmox_stats_prefs_user_host" ON "proxmox_stats_preferences" ("user_id", "host_id");
ALTER TABLE "proxmox_stats_preferences" RENAME TO "p_proxmox_stats_preferences";
+23 -1
View File
@@ -1,14 +1,36 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js";
import { setPluginCtx } from "./plugin-ctx.js";
import { startProxmoxService, stopProxmoxService } from "./routes.js";
import {
startProxmoxStatsService,
stopProxmoxStatsService,
} from "./stats-service.js";
import { proxmoxNodeHistory } from "./tables.js";
import { createProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh);
setPluginCtx(ctx);
ctx.disposables.add(() => setPluginSsh(null));
startProxmoxService(ctx.http.router());
ctx.disposables.add(() => setPluginCtx(null));
const historyTable = await ctx.db.define(proxmoxNodeHistory);
const historyRepository = createProxmoxNodeHistoryRepository(
ctx.db,
historyTable,
);
const router = ctx.http.router<Router>();
startProxmoxService(router);
ctx.disposables.add(stopProxmoxService);
startProxmoxStatsService(ctx, router, historyRepository);
ctx.disposables.add(stopProxmoxStatsService);
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
}
export async function deactivate() {
stopProxmoxService();
stopProxmoxStatsService();
}
+13
View File
@@ -0,0 +1,13 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
let current: PluginContext | null = null;
/** Set in activate, cleared on deactivate. */
export function setPluginCtx(ctx: PluginContext | null): void {
current = ctx;
}
export function pluginCtx(): PluginContext {
if (!current) throw new Error("The plugin is not active");
return current;
}
@@ -0,0 +1,79 @@
import { and, gte, lte, eq, asc, lt } from "drizzle-orm";
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
export interface ProxmoxNodeHistoryRow {
id: number;
hostId: number;
ts: string;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
export interface ProxmoxNodeHistoryCreateInput {
hostId: number;
cpuPercent: number | null;
memPercent: number | null;
diskPercent: number | null;
netRxBytes: number | null;
netTxBytes: number | null;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
export type ProxmoxNodeHistoryRepository = ReturnType<
typeof createProxmoxNodeHistoryRepository
>;
export function createProxmoxNodeHistoryRepository(
db: PluginDatabase,
table: Table,
) {
const client = () => db.client<Drizzle>();
return {
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
const drizzle = await client();
await drizzle.insert(table).values(input);
await db.persist();
},
async listRange(
hostId: number,
fromIso: string,
toIso: string,
): Promise<ProxmoxNodeHistoryRow[]> {
const drizzle = await client();
return drizzle
.select()
.from(table)
.where(
and(
eq(table.hostId, hostId),
gte(table.ts, fromIso),
lte(table.ts, toIso),
),
)
.orderBy(asc(table.ts));
},
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
// ts columns store CURRENT_TIMESTAMP as "YYYY-MM-DD HH:MM:SS" UTC, which
// sorts lexicographically, so the cutoff is a plain string comparison.
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
.toISOString()
.slice(0, 19)
.replace("T", " ");
const drizzle = await client();
await drizzle
.delete(table)
.where(and(eq(table.hostId, hostId), lt(table.ts, cutoff)));
await db.persist();
},
};
}
@@ -0,0 +1,7 @@
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
// but we validate defensively before using in a shell command.
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
export function isSafeNodeName(name: string): boolean {
return SAFE_NODE_RE.test(name);
}
@@ -0,0 +1,335 @@
import {
collectProxmoxStats,
type ProxmoxStatsSnapshot,
} from "./proxmox/collect-proxmox-stats.js";
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
import { pluginCtx } from "./plugin-ctx.js";
import type { Client } from "ssh2";
/**
* Proxmox stats polling gets its own concurrency limiter so a burst of polls
* on many hosts cannot starve one host's collection.
*/
class ConcurrentLimiter {
private running = 0;
private queue: Array<() => void> = [];
constructor(private readonly max: number) {}
async run<T>(fn: () => Promise<T>): Promise<T> {
if (this.running >= this.max) {
await new Promise<void>((resolve) => this.queue.push(resolve));
}
this.running++;
try {
return await fn();
} finally {
this.running--;
const next = this.queue.shift();
if (next) next();
}
}
}
const proxmoxStatsPollLimiter = new ConcurrentLimiter(5);
export interface ProxmoxStatsPollableHost {
id: number;
userId: string;
proxmoxStatsConfig?: string | ProxmoxStatsPollConfig | null;
}
export interface ProxmoxStatsPollConfig {
nodeName?: string | null;
pollInterval?: number;
enabledCards?: string[];
}
const DEFAULT_POLL_INTERVAL_SECONDS = 60;
export function parseProxmoxStatsConfig(
raw: string | ProxmoxStatsPollConfig | null | undefined,
): ProxmoxStatsPollConfig {
if (!raw) {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
if (typeof raw === "object") {
return {
nodeName: raw.nodeName ?? null,
pollInterval: raw.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: raw.enabledCards,
};
}
try {
const parsed = JSON.parse(raw) as ProxmoxStatsPollConfig;
return {
nodeName: parsed.nodeName ?? null,
pollInterval: parsed.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: parsed.enabledCards,
};
} catch {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
}
interface HostPollingEntry<THost extends ProxmoxStatsPollableHost> {
host: THost;
timer?: NodeJS.Timeout;
viewerUserId?: string;
}
interface ViewerDetail {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
interface CachedSnapshot {
data: ProxmoxStatsSnapshot;
timestamp: number;
}
interface ErrorSnapshot {
error: string;
timestamp: number;
}
export class ProxmoxPollingManager<
THost extends ProxmoxStatsPollableHost = ProxmoxStatsPollableHost,
> {
private pollingConfigs = new Map<number, HostPollingEntry<THost>>();
private snapshotStore = new Map<number, CachedSnapshot>();
private errorStore = new Map<number, ErrorSnapshot>();
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, ViewerDetail>();
private inFlight = new Set<number>();
private viewerCleanupInterval: NodeJS.Timeout;
constructor(
private readonly deps: {
fetchHostById: (
hostId: number,
userId: string,
) => Promise<THost | undefined>;
withSshConnection: <T>(
host: THost,
fn: (client: Client) => Promise<T>,
) => Promise<T>;
historyRepository: ProxmoxNodeHistoryRepository;
historyEnabled?: () => boolean;
},
) {
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();
}, 60000);
}
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
// Host Metrics' own retention setting is not on the core settings
// allowlist, so this plugin uses a fixed default rather than reaching for
// it. See FINISH-LIST.md.
private readonly retentionDays = 7;
private async pollHostStats(
host: THost,
viewerUserId?: string,
): Promise<void> {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await proxmoxStatsPollLimiter.run(async () => {
const userId = viewerUserId || host.userId;
const refreshed =
(await this.deps.fetchHostById(host.id, userId)) ?? host;
const config = parseProxmoxStatsConfig(refreshed.proxmoxStatsConfig);
try {
const snapshot = await this.deps.withSshConnection(
refreshed,
(client) => collectProxmoxStats(client, config.nodeName),
);
this.snapshotStore.set(host.id, {
data: snapshot,
timestamp: Date.now(),
});
this.errorStore.delete(host.id);
if (this.deps.historyEnabled?.() ?? true) {
await this.insertHistory(host.id, snapshot);
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.errorStore.set(host.id, {
error: message,
timestamp: Date.now(),
});
pluginCtx().log.warn(
`Proxmox stats poll failed for host ${host.id}: ${message}`,
);
}
});
} finally {
this.inFlight.delete(host.id);
}
}
private async insertHistory(
hostId: number,
snapshot: ProxmoxStatsSnapshot,
): Promise<void> {
try {
const iface = snapshot.network?.interfaces?.[0];
const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null;
const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null;
await this.deps.historyRepository.create({
hostId,
cpuPercent: snapshot.node?.cpu?.percent ?? null,
memPercent: snapshot.node?.memory?.percent ?? null,
diskPercent: snapshot.node?.disk?.percent ?? null,
netRxBytes: rxRaw !== null && !isNaN(rxRaw) ? rxRaw : null,
netTxBytes: txRaw !== null && !isNaN(txRaw) ? txRaw : null,
});
await this.deps.historyRepository.pruneOlderThan(
hostId,
this.retentionDays,
);
} catch (err) {
pluginCtx().log.warn(
`Failed to write proxmox node history for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
private startPollingForHost(host: THost, viewerUserId?: string): void {
const existing = this.pollingConfigs.get(host.id);
if (existing?.timer) {
clearInterval(existing.timer);
}
const config = parseProxmoxStatsConfig(host.proxmoxStatsConfig);
const intervalMs = this.intervalWithJitter(
(config.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
host.id,
);
void this.pollHostStats(host, viewerUserId);
const timer = setInterval(() => {
const latest = this.pollingConfigs.get(host.id);
if (latest) {
void this.pollHostStats(latest.host, latest.viewerUserId);
}
}, intervalMs);
this.pollingConfigs.set(host.id, { host, timer, viewerUserId });
}
private stopPollingForHost(hostId: number): void {
const config = this.pollingConfigs.get(hostId);
if (config?.timer) {
clearInterval(config.timer);
}
this.pollingConfigs.delete(hostId);
}
getStats(
hostId: number,
): { data: ProxmoxStatsSnapshot; timestamp: number } | undefined {
return this.snapshotStore.get(hostId);
}
getError(hostId: number): ErrorSnapshot | undefined {
return this.errorStore.get(hostId);
}
async ensurePolling(host: THost, viewerUserId?: string): Promise<void> {
if (!this.pollingConfigs.has(host.id)) {
this.startPollingForHost(host, viewerUserId);
}
if (!this.snapshotStore.has(host.id) && !this.inFlight.has(host.id)) {
await this.pollHostStats(host, viewerUserId);
}
}
registerViewer = (
hostId: number,
sessionId: string,
userId: string,
): void => {
if (!this.activeViewers.has(hostId)) {
this.activeViewers.set(hostId, new Set());
}
this.activeViewers.get(hostId)!.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (this.activeViewers.get(hostId)!.size === 1) {
Promise.resolve()
.then(async () => {
const host = await this.deps.fetchHostById(hostId, userId);
if (host) {
this.startPollingForHost(host, userId);
}
})
.catch((err) => {
pluginCtx().log.warn(
`Proxmox stats startPollingForHost rejected (non-fatal) for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
);
});
}
};
unregisterViewer = (hostId: number, sessionId: string): void => {
const viewers = this.activeViewers.get(hostId);
if (viewers) {
viewers.delete(sessionId);
if (viewers.size === 0) {
this.activeViewers.delete(hostId);
this.stopPollingForHost(hostId);
}
}
this.viewerDetails.delete(sessionId);
};
updateHeartbeat(sessionId: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (viewer) {
viewer.lastHeartbeat = Date.now();
return true;
}
return false;
}
private cleanupInactiveViewers(): void {
const now = Date.now();
const maxInactivity = 120000;
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
if (now - viewer.lastHeartbeat > maxInactivity) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
destroy(): void {
clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
}
}
@@ -0,0 +1,67 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
export interface ProxmoxClusterNodeEntry {
name: string;
online: boolean;
local: boolean;
ip: string | null;
}
export type ProxmoxClusterHealthResult =
| { clustered: false }
| {
clustered: true;
quorate: boolean;
clusterName: string | null;
nodes: ProxmoxClusterNodeEntry[];
};
const EMPTY_RESULT: ProxmoxClusterHealthResult = { clustered: false };
export async function collectProxmoxClusterHealth(
client: Client,
): Promise<ProxmoxClusterHealthResult> {
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/status --output-format json",
15000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const entries = data as Array<Record<string, unknown>>;
const clusterEntry = entries.find((e) => e.type === "cluster");
if (!clusterEntry) {
return EMPTY_RESULT;
}
const nodes: ProxmoxClusterNodeEntry[] = entries
.filter((e) => e.type === "node")
.map((e) => ({
name: typeof e.name === "string" ? e.name : "",
online: e.online === 1 || e.online === true,
local: e.local === 1 || e.local === true,
ip: typeof e.ip === "string" && e.ip ? e.ip : null,
}));
return {
clustered: true,
quorate: clusterEntry.quorate === 1 || clusterEntry.quorate === true,
clusterName:
typeof clusterEntry.name === "string" && clusterEntry.name
? clusterEntry.name
: null,
nodes,
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,86 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
import {
collectProxmoxNodeStatus,
type ProxmoxNodeStatusResult,
} from "./node-status-collector.js";
import {
collectProxmoxNodeNetwork,
type ProxmoxNodeNetworkResult,
} from "./node-network-collector.js";
import {
collectProxmoxGuestsSummary,
type ProxmoxGuestsSummaryResult,
} from "./guests-collector.js";
import {
collectProxmoxStorage,
type ProxmoxStorageResult,
} from "./storage-collector.js";
import {
collectProxmoxClusterHealth,
type ProxmoxClusterHealthResult,
} from "./cluster-health-collector.js";
export interface ProxmoxStatsSnapshot {
node: ProxmoxNodeStatusResult;
network: ProxmoxNodeNetworkResult;
guests: ProxmoxGuestsSummaryResult;
storage: ProxmoxStorageResult;
cluster: ProxmoxClusterHealthResult;
lastChecked: string;
}
async function resolveNodeName(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<string> {
if (configuredNodeName) {
// An explicitly configured name is validated and used as-is - an unsafe
// value is rejected outright rather than silently falling back to
// auto-detection, which would mask a misconfigured (or malicious) override.
if (!isSafeNodeName(configuredNodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
return configuredNodeName;
}
const { stdout } = await execCommand(client, "hostname", 10000);
return stdout.trim();
}
export async function collectProxmoxStats(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<ProxmoxStatsSnapshot> {
const pveshCheck = await execCommand(
client,
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
10000,
);
if (pveshCheck.stdout.trim() !== "ok") {
throw new Error("pvesh not found — is this a Proxmox node?");
}
const nodeName = await resolveNodeName(client, configuredNodeName);
if (!isSafeNodeName(nodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
const [node, network, guests, storage, cluster] = await Promise.all([
collectProxmoxNodeStatus(client, nodeName),
collectProxmoxNodeNetwork(client, nodeName),
collectProxmoxGuestsSummary(client, nodeName),
collectProxmoxStorage(client, nodeName),
collectProxmoxClusterHealth(client),
]);
return {
node,
network,
guests,
storage,
cluster,
lastChecked: new Date().toISOString(),
};
}
@@ -0,0 +1,98 @@
import type { Client, ClientChannel } from "ssh2";
/** Runs a command over an already-open ssh2 client and collects its output. */
export function execCommand(
client: Client,
command: string,
timeoutMs = 30000,
): Promise<{
stdout: string;
stderr: string;
code: number | null;
}> {
return new Promise((resolve, reject) => {
let settled = false;
let stream: ClientChannel | null = null;
const timeout = setTimeout(() => {
if (!settled) {
settled = true;
cleanup();
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
}
}, timeoutMs);
const cleanup = () => {
clearTimeout(timeout);
if (stream) {
try {
stream.removeAllListeners();
if (stream.stderr) {
stream.stderr.removeAllListeners();
}
stream.destroy();
} catch {
// expected - cleanup errors ignored
}
}
};
client.exec(command, { pty: false }, (err, _stream) => {
if (err) {
if (!settled) {
settled = true;
cleanup();
reject(err);
}
return;
}
stream = _stream;
let stdout = "";
let stderr = "";
let exitCode: number | null = null;
stream
.on("close", (code: number | undefined) => {
if (!settled) {
settled = true;
exitCode = typeof code === "number" ? code : null;
cleanup();
resolve({ stdout, stderr, code: exitCode });
}
})
.on("data", (data: Buffer) => {
stdout += data.toString("utf8");
})
.on("error", (streamErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(streamErr);
}
});
if (stream.stderr) {
stream.stderr
.on("data", (data: Buffer) => {
stderr += data.toString("utf8");
})
.on("error", (stderrErr: Error) => {
if (!settled) {
settled = true;
cleanup();
reject(stderrErr);
}
});
}
});
});
}
export function toFixedNum(
n: number | null | undefined,
digits = 2,
): number | null {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
}
@@ -0,0 +1,113 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxGuestSummaryEntry {
vmid: number;
name: string;
type: "qemu" | "lxc";
status: string;
cpuPercent: number | null;
memPercent: number | null;
memUsedGiB: number | null;
memTotalGiB: number | null;
diskPercent: number | null;
diskUsedGiB: number | null;
diskTotalGiB: number | null;
uptimeSeconds: number | null;
}
export interface ProxmoxGuestsSummaryResult {
guests: ProxmoxGuestSummaryEntry[];
counts: { running: number; stopped: number; total: number };
}
const EMPTY_RESULT: ProxmoxGuestsSummaryResult = {
guests: [],
counts: { running: 0, stopped: 0, total: 0 },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxGuestsSummary(
client: Client,
nodeName: string,
): Promise<ProxmoxGuestsSummaryResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/resources --output-format json",
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const resources = JSON.parse(stdout);
if (!Array.isArray(resources)) {
return EMPTY_RESULT;
}
const guests: ProxmoxGuestSummaryEntry[] = [];
for (const r of resources as Array<Record<string, unknown>>) {
const type = r.type;
if (type !== "qemu" && type !== "lxc") continue;
if (r.node !== nodeName) continue;
if (r.template === true || r.template === 1) continue;
const cpuFraction = typeof r.cpu === "number" ? r.cpu : null;
const mem = typeof r.mem === "number" ? r.mem : null;
const maxmem = typeof r.maxmem === "number" ? r.maxmem : null;
const memPercent =
mem !== null && maxmem !== null && maxmem > 0
? Math.max(0, Math.min(100, (mem / maxmem) * 100))
: null;
const disk = typeof r.disk === "number" ? r.disk : null;
const maxdisk = typeof r.maxdisk === "number" ? r.maxdisk : null;
// QEMU guests without a running agent report maxdisk: 0 - a false 0%
// is worse than an honest "unknown", so treat that as no data at all.
const hasDiskData = maxdisk !== null && maxdisk > 0;
const diskPercent =
hasDiskData && disk !== null
? Math.max(0, Math.min(100, (disk / maxdisk) * 100))
: null;
guests.push({
vmid: typeof r.vmid === "number" ? r.vmid : Number(r.vmid),
name:
typeof r.name === "string" && r.name ? r.name : String(r.vmid ?? ""),
type,
status: typeof r.status === "string" ? r.status : "unknown",
cpuPercent: toFixedNum(
cpuFraction !== null ? cpuFraction * 100 : null,
0,
),
memPercent: toFixedNum(memPercent, 0),
memUsedGiB: mem !== null ? toFixedNum(bytesToGiB(mem), 2) : null,
memTotalGiB: maxmem !== null ? toFixedNum(bytesToGiB(maxmem), 2) : null,
diskPercent: toFixedNum(diskPercent, 0),
diskUsedGiB:
hasDiskData && disk !== null ? toFixedNum(bytesToGiB(disk), 2) : null,
diskTotalGiB: hasDiskData ? toFixedNum(bytesToGiB(maxdisk), 2) : null,
uptimeSeconds: typeof r.uptime === "number" ? r.uptime : null,
});
}
const running = guests.filter((g) => g.status === "running").length;
const stopped = guests.filter((g) => g.status !== "running").length;
return {
guests,
counts: { running, stopped, total: guests.length },
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,145 @@
import type { Client } from "ssh2";
import { execCommand } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxNodeNetworkInterface {
name: string;
ip: string | null;
state: string | null;
rxBytes: string | null;
txBytes: string | null;
}
export interface ProxmoxNodeNetworkResult {
interfaces: ProxmoxNodeNetworkInterface[];
}
const EMPTY_RESULT: ProxmoxNodeNetworkResult = { interfaces: [] };
async function collectFromProcNetDev(
client: Client,
): Promise<ProxmoxNodeNetworkResult> {
const interfaces: ProxmoxNodeNetworkInterface[] = [];
try {
const [addrOut, stateOut, procNetOut] = await Promise.all([
execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
),
execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
),
execCommand(client, "cat /proc/net/dev"),
]);
const ifMap = new Map<
string,
{ ip: string | null; state: string | null }
>();
for (const line of addrOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: null });
}
}
for (const line of stateOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const existing = ifMap.get(parts[0]);
if (existing) existing.state = parts[1];
}
}
const rxTxMap = new Map<string, { rx: string; tx: string }>();
for (const line of procNetOut.stdout.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
const ifName = parts[0].replace(":", "");
rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
});
}
} catch {
return EMPTY_RESULT;
}
return { interfaces };
}
export async function collectProxmoxNodeNetwork(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeNetworkResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/netstat --output-format json`,
15000,
);
if (code !== 0) {
return collectFromProcNetDev(client);
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return collectFromProcNetDev(client);
}
const interfaces: ProxmoxNodeNetworkInterface[] = data
.filter(
(entry): entry is Record<string, unknown> =>
!!entry && typeof entry === "object",
)
.map((entry) => ({
name:
typeof entry.dev === "string" ? entry.dev : String(entry.dev ?? ""),
ip: null,
state: null,
rxBytes:
typeof entry.in === "number"
? String(entry.in)
: typeof entry.received === "number"
? String(entry.received)
: null,
txBytes:
typeof entry.out === "number"
? String(entry.out)
: typeof entry.transmitted === "number"
? String(entry.transmitted)
: null,
}))
.filter((iface) => iface.name && iface.name !== "lo");
if (interfaces.length === 0) {
return collectFromProcNetDev(client);
}
return { interfaces };
} catch {
return collectFromProcNetDev(client);
}
}
@@ -0,0 +1,142 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxNodeStatusResult {
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
uptime: {
seconds: number | null;
formatted: string | null;
};
system: {
hostname: string | null;
kernel: string | null;
pveVersion: string | null;
};
}
const EMPTY_RESULT: ProxmoxNodeStatusResult = {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
export async function collectProxmoxNodeStatus(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeStatusResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/status --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout) as Record<string, unknown>;
const cpuFraction = typeof data.cpu === "number" ? data.cpu : null;
const cpuinfo = (data.cpuinfo as Record<string, unknown>) || {};
const cores = typeof cpuinfo.cores === "number" ? cpuinfo.cores : null;
const loadavgRaw = data.loadavg;
let load: [number, number, number] | null = null;
if (Array.isArray(loadavgRaw) && loadavgRaw.length >= 3) {
const parsed = loadavgRaw
.slice(0, 3)
.map((v) => Number(v))
.map((v) => (Number.isFinite(v) ? v : 0));
load = parsed as [number, number, number];
}
const memory = (data.memory as Record<string, unknown>) || {};
const memUsed = typeof memory.used === "number" ? memory.used : null;
const memTotal = typeof memory.total === "number" ? memory.total : null;
const memPercent =
memUsed !== null && memTotal !== null && memTotal > 0
? Math.max(0, Math.min(100, (memUsed / memTotal) * 100))
: null;
const rootfs = (data.rootfs as Record<string, unknown>) || {};
const diskUsed = typeof rootfs.used === "number" ? rootfs.used : null;
const diskTotal = typeof rootfs.total === "number" ? rootfs.total : null;
const diskPercent =
diskUsed !== null && diskTotal !== null && diskTotal > 0
? Math.max(0, Math.min(100, (diskUsed / diskTotal) * 100))
: null;
const uptimeSeconds = typeof data.uptime === "number" ? data.uptime : null;
return {
cpu: {
percent: toFixedNum(cpuFraction !== null ? cpuFraction * 100 : null, 0),
cores,
load,
},
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: memUsed !== null ? toFixedNum(bytesToGiB(memUsed), 2) : null,
totalGiB:
memTotal !== null ? toFixedNum(bytesToGiB(memTotal), 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedGiB: diskUsed !== null ? toFixedNum(bytesToGiB(diskUsed), 2) : null,
totalGiB:
diskTotal !== null ? toFixedNum(bytesToGiB(diskTotal), 2) : null,
},
uptime: {
seconds: uptimeSeconds,
formatted: uptimeSeconds !== null ? formatUptime(uptimeSeconds) : null,
},
system: {
hostname:
typeof data.hostname === "string" && data.hostname
? data.hostname
: null,
kernel:
typeof data.kversion === "string" && data.kversion
? data.kversion
: null,
pveVersion:
typeof data.pveversion === "string" && data.pveversion
? data.pveversion
: null,
},
};
} catch {
return EMPTY_RESULT;
}
}
@@ -0,0 +1,76 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "./common-utils.js";
import { isSafeNodeName } from "../proxmox-shared.js";
export interface ProxmoxStoragePoolEntry {
name: string;
type: string;
active: boolean;
enabled: boolean;
usedGiB: number | null;
totalGiB: number | null;
availGiB: number | null;
percent: number | null;
}
export interface ProxmoxStorageResult {
pools: ProxmoxStoragePoolEntry[];
}
const EMPTY_RESULT: ProxmoxStorageResult = { pools: [] };
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxStorage(
client: Client,
nodeName: string,
): Promise<ProxmoxStorageResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/storage --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const pools: ProxmoxStoragePoolEntry[] = (
data as Array<Record<string, unknown>>
).map((entry) => {
const used = typeof entry.used === "number" ? entry.used : null;
const total = typeof entry.total === "number" ? entry.total : null;
const avail = typeof entry.avail === "number" ? entry.avail : null;
const percent =
used !== null && total !== null && total > 0
? Math.max(0, Math.min(100, (used / total) * 100))
: null;
return {
name: typeof entry.storage === "string" ? entry.storage : "",
type: typeof entry.type === "string" ? entry.type : "unknown",
active: entry.active === 1 || entry.active === true,
enabled: entry.enabled === 1 || entry.enabled === true,
usedGiB: used !== null ? toFixedNum(bytesToGiB(used), 2) : null,
totalGiB: total !== null ? toFixedNum(bytesToGiB(total), 2) : null,
availGiB: avail !== null ? toFixedNum(bytesToGiB(avail), 2) : null,
percent: toFixedNum(percent, 0),
};
});
return { pools };
} catch {
return EMPTY_RESULT;
}
}
+213 -219
View File
@@ -1,36 +1,27 @@
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express, { type Router } from "express";
import type { Client as SSHClient } from "ssh2";
import { logger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import {
type AuthenticatedRequest,
type SSHHost,
} from "../../../../src/types/index.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import type {
PluginHostCreateInput,
PluginHostRecord,
PluginHostUpdateInput,
} from "@termix/plugin-sdk/backend";
import { connectSsh } from "./ssh.js";
import { pluginCtx } from "./plugin-ctx.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isSafeNodeName } from "./proxmox-shared.js";
const router = express.Router();
const proxmoxLogger = logger;
const runningSyncs = new Set<string>();
const MIN_SYNC_INTERVAL_MINUTES = 5;
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
const authManager = AuthManager.getInstance();
const authenticateJWT = authManager.createAuthMiddleware();
const requireDataAccess = authManager.createDataAccessMiddleware();
// Helpers
function execCommand(
@@ -189,6 +180,9 @@ type ProxmoxGuest = {
enableDocker: boolean;
};
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
type ProxmoxSource = {
source: "proxmox";
sourceHostId: number;
@@ -285,44 +279,30 @@ async function discoverProxmoxGuestsForHost(
parsedHostId: number,
onProgress?: (done: number, total: number) => void,
): Promise<{
host: SSHHost;
host: PluginSshHostWithSudo;
guests: ProxmoxGuest[];
credentialId: number | null;
defaultCredentialId: number | null;
jumpHosts: unknown[] | null;
config: ReturnType<typeof parseProxmoxConfig>;
}> {
if (!DataCrypto.canUserAccessData(userId)) {
const error = new Error("Session expired — please log in again");
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
throw error;
}
const resolvedHost = await resolveHostById(parsedHostId, userId);
if (!resolvedHost) {
const error = new Error("Host not found");
(error as Error & { status?: number }).status = 404;
throw error;
}
const host = resolvedHost as SSHHost;
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
const ctx = pluginCtx();
const proxmoxCfgRaw = await ctx.settings.getHost(
parsedHostId,
"proxmoxConfig",
);
const config = parseProxmoxConfig(proxmoxCfgRaw);
const hostCredentialId = host.credentialId ?? null;
const { client } = await connectSsh(host as unknown as PluginSshHost, {
const { client, host } = await connectSsh(parsedHostId, {
purpose: "proxmox",
timeoutMs: 35000,
overrides: { tryKeyboard: false, readyTimeout: 30000 },
});
const hostWithSudo = host as PluginSshHostWithSudo;
const hostCredentialId = (hostWithSudo.credentialId as number | null) ?? null;
try {
proxmoxLogger.info("Proxmox discovery SSH connection established", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
ctx.log.info("Proxmox discovery SSH connection established");
const pveshCheck = await execCommand(
client,
@@ -337,7 +317,7 @@ async function discoverProxmoxGuestsForHost(
const resourcesJson = await execPveshCommand(
client,
"pvesh get /cluster/resources --output-format json 2>/dev/null",
host.sudoPassword,
hostWithSudo.sudoPassword,
);
let resources: Array<Record<string, unknown>>;
@@ -366,11 +346,9 @@ async function discoverProxmoxGuestsForHost(
if (r.template) continue;
const node = r.node as string;
if (!isSafeNodeName(node)) {
proxmoxLogger.warn("Skipping guest with unsafe node name", {
operation: "proxmox_discover",
node,
vmid: r.vmid,
});
ctx.log.warn(
`Skipping guest with unsafe node name: ${node} (vmid ${r.vmid})`,
);
continue;
}
guestBases.push({
@@ -389,7 +367,7 @@ async function discoverProxmoxGuestsForHost(
const cfgJson = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
host.sudoPassword,
hostWithSudo.sudoPassword,
25000,
);
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
@@ -404,7 +382,7 @@ async function discoverProxmoxGuestsForHost(
const ifRaw = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
host.sudoPassword,
hostWithSudo.sudoPassword,
12000,
);
const data = JSON.parse(ifRaw);
@@ -437,7 +415,7 @@ async function discoverProxmoxGuestsForHost(
const ifJson = await execPveshCommand(
client,
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
host.sudoPassword,
hostWithSudo.sudoPassword,
12000,
);
const data = JSON.parse(ifJson);
@@ -508,21 +486,16 @@ async function discoverProxmoxGuestsForHost(
enableDocker: matchesAny(g.name, config.dockerPatterns),
}));
proxmoxLogger.info("Proxmox discovery completed", {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
guestCount: guests.length,
});
ctx.log.info(
`Proxmox discovery completed for host ${parsedHostId}: ${guests.length} guest(s)`,
);
return {
host,
host: hostWithSudo,
guests,
credentialId: hostCredentialId,
defaultCredentialId: config.defaultCredentialId,
jumpHosts: parseProxmoxJumpHosts(
(host as unknown as { jumpHosts?: unknown }).jumpHosts,
),
jumpHosts: parseProxmoxJumpHosts(hostWithSudo.jumpHosts),
config,
};
} finally {
@@ -538,6 +511,7 @@ async function syncProxmoxHost(
userId: string,
sourceHostId: number,
): Promise<ProxmoxSyncResult> {
const ctx = pluginCtx();
const lockKey = `${userId}:${sourceHostId}`;
if (runningSyncs.has(lockKey)) {
return {
@@ -570,10 +544,10 @@ async function syncProxmoxHost(
);
const now = new Date().toISOString();
const existingHosts =
(await createCurrentHostRepository().listDecryptedByUserId(
userId,
)) as unknown as Record<string, unknown>[];
const existingHosts = (await ctx.hosts.listOwned()) as unknown as Record<
string,
unknown
>[];
const existingBySource = new Map<string, Record<string, unknown>>();
for (const host of existingHosts) {
const source = getProxmoxSource(host);
@@ -647,10 +621,9 @@ async function syncProxmoxHost(
update.credentialId = importAuth.credentialId;
update.overrideCredentialUsername = false;
}
await createCurrentHostRepository().updateEncryptedForUser(
userId,
await ctx.hosts.update(
existing.id as number,
update,
update as PluginHostUpdateInput,
);
result.updated++;
continue;
@@ -665,9 +638,8 @@ async function syncProxmoxHost(
enableRdp: connectionType === "rdp",
});
await createCurrentHostRepository().createEncryptedForUser(userId, {
await ctx.hosts.create({
...update,
userId,
createdAt: now,
pin: false,
authType: connectionType === "rdp" ? "password" : importAuth.authType,
@@ -709,7 +681,7 @@ async function syncProxmoxHost(
showTunnelInSidebar: 0,
showDockerInSidebar: 0,
showServerStatsInSidebar: 0,
});
} as unknown as PluginHostCreateInput);
result.created++;
}
@@ -720,8 +692,7 @@ async function syncProxmoxHost(
const source = getProxmoxSource(existing);
if (!source) continue;
const missingSince = source.missingSince || now;
await createCurrentHostRepository().updateEncryptedForUser(
userId,
await ctx.hosts.update(
existing.id as number,
{
tags: mergeTags(existing.tags, ["proxmox-missing"]),
@@ -733,13 +704,13 @@ async function syncProxmoxHost(
},
}),
updatedAt: now,
},
} as PluginHostUpdateInput,
);
result.markedMissing++;
}
}
await writeSyncStatus(userId, sourceHostId, {
await writeSyncStatus(sourceHostId, {
lastSyncAt: startedAt,
lastSyncStatus: "success",
lastSyncError: null,
@@ -750,7 +721,7 @@ async function syncProxmoxHost(
} catch (error) {
const message = getErrorMessage(error);
result.errors.push(message);
await writeSyncStatus(userId, sourceHostId, {
await writeSyncStatus(sourceHostId, {
lastSyncAt: startedAt,
lastSyncStatus: "error",
lastSyncError: message,
@@ -763,22 +734,46 @@ async function syncProxmoxHost(
}
async function writeSyncStatus(
userId: string,
hostId: number,
patch: Record<string, unknown>,
): Promise<void> {
const hostRepository = createCurrentHostRepository();
const row = await hostRepository.findByIdForUser(userId, hostId);
if (!row) return;
const config = parseJsonObject(row.proxmoxConfig);
await hostRepository.updateForUser(userId, hostId, {
proxmoxConfig: JSON.stringify({ ...config, ...patch }),
});
const ctx = pluginCtx();
const config = parseJsonObject(
await ctx.settings.getHost(hostId, "proxmoxConfig"),
);
await ctx.settings.setHost(hostId, "proxmoxConfig", { ...config, ...patch });
}
router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
/**
* @openapi
* /proxmox/sync:
* post:
* summary: Sync Proxmox guests for a host
* description: Re-runs discovery for a Proxmox node and creates, updates or marks missing the imported guest hosts.
* tags: [Proxmox]
* security:
* - bearerAuth: []
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* required: [hostId]
* properties:
* hostId:
* type: number
* responses:
* 200:
* description: Sync result.
* 400:
* description: Missing or invalid hostId.
* 500:
* description: Sync failed.
*/
router.post("/sync", async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = (req as unknown as AuthenticatedRequest).userId;
const userId = pluginCtx().currentActor()!;
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
@@ -790,30 +785,48 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
return res.json(result);
} catch (err: unknown) {
const message = getErrorMessage(err);
const status =
(err as Error & { code?: string; status?: number }).code ===
"SESSION_EXPIRED"
? 401
: (err as Error & { status?: number }).status || 500;
proxmoxLogger.error("Proxmox sync failed", err, {
operation: "proxmox_sync",
hostId: parsedHostId,
userId,
});
const status = (err as Error & { status?: number }).status || 500;
pluginCtx().log.error(
`Proxmox sync failed for host ${parsedHostId}`,
err as Error,
);
return res.status(status).json({ error: `Sync failed: ${message}` });
}
});
async function runDueProxmoxAutoSyncs(): Promise<void> {
const ctx = pluginCtx();
try {
const rows = await createCurrentHostRepository().listProxmoxEnabled();
const {
createCurrentPluginSettingsRepository,
createCurrentHostRepository,
} =
await import("../../../../src/backend/database/repositories/factory.js");
const enabledRows = await createCurrentPluginSettingsRepository().listByKey(
"proxmox",
"host",
"enableProxmox",
);
const hostRepository = createCurrentHostRepository();
const now = Date.now();
for (const row of rows) {
const configRaw = parseJsonObject(row.proxmoxConfig);
for (const row of enabledRows) {
if (row.value !== "true") continue;
const hostId = Number(row.scopeId);
if (!Number.isInteger(hostId)) continue;
const host = await hostRepository.findById(hostId);
if (!host) continue;
const configRow = await createCurrentPluginSettingsRepository().get(
"proxmox",
"host",
String(hostId),
"proxmoxConfig",
);
const configRaw = configRow?.value ? JSON.parse(configRow.value) : {};
const config = parseProxmoxConfig(configRaw);
if (!config.autoSyncEnabled) continue;
if (!DataCrypto.canUserAccessData(row.userId)) continue;
const lastSyncAt =
typeof configRaw.lastSyncAt === "string"
@@ -822,18 +835,17 @@ async function runDueProxmoxAutoSyncs(): Promise<void> {
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
syncProxmoxHost(row.userId, row.id).catch((error) => {
proxmoxLogger.error("Scheduled Proxmox sync failed", error, {
operation: "proxmox_auto_sync",
hostId: row.id,
userId: row.userId,
ctx
.asUser(host.userId, () => syncProxmoxHost(host.userId, hostId))
.catch((error) => {
ctx.log.error(
`Scheduled Proxmox sync failed for host ${hostId}`,
error as Error,
);
});
});
}
} catch (error) {
proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, {
operation: "proxmox_auto_sync_scan",
});
ctx.log.error("Failed to scan Proxmox auto sync jobs", error as Error);
}
}
@@ -915,127 +927,109 @@ let proxmoxAutoSyncStartupTimer: NodeJS.Timeout | undefined;
* 500:
* description: Discovery failed.
*/
router.get(
"/discover/stream",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const userId = (req as unknown as AuthenticatedRequest).userId;
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
router.get("/discover/stream", async (req, res) => {
const userId = pluginCtx().currentActor()!;
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
let closed = false;
const send = (event: string, data: unknown) => {
if (closed) return;
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
closed = true;
}
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-store, no-transform",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
res.flushHeaders?.();
let closed = false;
const send = (event: string, data: unknown) => {
if (closed) return;
try {
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
} catch {
closed = true;
}
};
const heartbeat = setInterval(() => {
if (closed) return;
try {
res.write(": keepalive\n\n");
} catch {
closed = true;
clearInterval(heartbeat);
}
}, 15000);
req.on("close", () => {
};
const heartbeat = setInterval(() => {
if (closed) return;
try {
res.write(": keepalive\n\n");
} catch {
closed = true;
clearInterval(heartbeat);
});
}
}, 15000);
req.on("close", () => {
closed = true;
clearInterval(heartbeat);
});
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
(done, total) => send("progress", { done, total }),
);
send("result", {
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
send("fail", { message });
} finally {
clearInterval(heartbeat);
if (!closed) {
try {
res.end();
} catch {
// ignore end errors
}
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
(done, total) => send("progress", { done, total }),
);
send("result", {
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
pluginCtx().log.error(
`Proxmox discovery (stream) failed for host ${parsedHostId}`,
err as Error,
);
send("fail", { message });
} finally {
clearInterval(heartbeat);
if (!closed) {
try {
res.end();
} catch {
// ignore end errors
}
}
},
);
}
});
router.post(
"/discover",
authenticateJWT,
requireDataAccess,
async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = (req as unknown as AuthenticatedRequest).userId;
router.post("/discover", async (req, res) => {
const { hostId } = req.body as { hostId?: unknown };
const userId = pluginCtx().currentActor()!;
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
const parsedHostId = Number(hostId);
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
return res.status(400).json({ error: "Missing or invalid hostId" });
}
try {
const discovery = await discoverProxmoxGuestsForHost(
userId,
parsedHostId,
);
return res.json({
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
proxmoxLogger.error("Proxmox discovery failed", err, {
operation: "proxmox_discover",
hostId: parsedHostId,
userId,
});
try {
const discovery = await discoverProxmoxGuestsForHost(userId, parsedHostId);
return res.json({
guests: discovery.guests,
credentialId: discovery.credentialId,
defaultCredentialId: discovery.defaultCredentialId,
jumpHosts: discovery.jumpHosts,
});
} catch (err: unknown) {
const message = getErrorMessage(err);
pluginCtx().log.error(
`Proxmox discovery failed for host ${parsedHostId}`,
err as Error,
);
const status =
(err as Error & { code?: string; status?: number }).code ===
"SESSION_EXPIRED"
? 401
: (err as Error & { status?: number }).status ||
(message.includes("Authentication failed") ||
message.includes("connect ECONNREFUSED") ||
message.includes("connect ETIMEDOUT")
? 422
: 500);
return res.status(status).json({ error: `Discovery failed: ${message}` });
}
},
);
const status =
(err as Error & { status?: number }).status ||
(message.includes("Authentication failed") ||
message.includes("connect ECONNREFUSED") ||
message.includes("connect ETIMEDOUT")
? 422
: 500);
return res.status(status).json({ error: `Discovery failed: ${message}` });
}
});
/** Called from activate(). Mounts this router at /proxmox via the shared
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
@@ -0,0 +1,366 @@
/**
* Proxmox node stats: polling, viewer sessions and their routes.
*
* Moved here from host-metrics, which only ever hosted this because the
* plugin split predates B5. host-metrics must not import proxmox code, so
* this plugin owns its own polling manager, SSH connections and routes.
*/
import type { Router } from "express";
import express from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { pluginCtx } from "./plugin-ctx.js";
import {
ProxmoxPollingManager,
parseProxmoxStatsConfig,
type ProxmoxStatsPollableHost,
} from "./proxmox-stats-polling.js";
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
const EMPTY_SNAPSHOT = {
node: {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
},
network: { interfaces: [] },
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
storage: { pools: [] },
cluster: { clustered: false },
lastChecked: new Date(0).toISOString(),
};
type PollableHost = ProxmoxStatsPollableHost & { enableProxmoxStats: boolean };
let pollingManager: ProxmoxPollingManager<PollableHost> | undefined;
let router: Router | undefined;
async function fetchHostForPolling(
ctx: PluginContext,
hostId: number,
userId: string,
): Promise<PollableHost | undefined> {
const enabled = await ctx.asUser(userId, () =>
ctx.settings.getHost<boolean>(hostId, "enableProxmoxStats"),
);
if (!enabled) return undefined;
const host = await ctx.asUser(userId, () => ctx.hosts.get(hostId));
if (!host) return undefined;
const proxmoxStatsConfig = await ctx.asUser(userId, () =>
ctx.settings.getHost(hostId, "proxmoxStatsConfig"),
);
return {
id: host.id,
userId: host.userId,
proxmoxStatsConfig: proxmoxStatsConfig as string | undefined,
enableProxmoxStats: true,
};
}
/** Called from activate(). Builds the polling manager and mounts its routes. */
export function startProxmoxStatsService(
ctx: PluginContext,
mountOn: Router,
historyRepository: ProxmoxNodeHistoryRepository,
): void {
pollingManager = new ProxmoxPollingManager<PollableHost>({
fetchHostById: (hostId, userId) => fetchHostForPolling(ctx, hostId, userId),
withSshConnection: (host, fn) =>
ctx.ssh.withConnection(
host.id,
{
pool: "proxmox-stats",
purpose: "proxmox",
overrides: { readyTimeout: 60000 },
},
fn,
),
historyRepository,
});
router = express.Router();
registerRoutes(router, pollingManager, historyRepository);
mountOn.use("/stats", router);
}
export function stopProxmoxStatsService(): void {
pollingManager?.destroy();
pollingManager = undefined;
router = undefined;
}
function registerRoutes(
app: Router,
manager: ProxmoxPollingManager<PollableHost>,
historyRepository: ProxmoxNodeHistoryRepository,
): void {
/**
* @openapi
* /plugin-api/proxmox/stats/{id}:
* get:
* summary: Get cached Proxmox node stats for a host
* description: Returns the most recently polled Proxmox stats snapshot for a host, or an empty skeleton if none has been collected yet.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proxmox stats snapshot.
* 404:
* description: Stats not available yet.
*/
app.get("/:id", async (req, res) => {
const id = Number(req.params.id);
const cached = manager.getStats(id);
if (!cached) {
const errorState = manager.getError(id);
return res.status(404).json({
error: errorState?.error || "Stats not available",
...EMPTY_SNAPSHOT,
lastChecked: new Date().toISOString(),
});
}
res.json({
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
});
/**
* @openapi
* /plugin-api/proxmox/stats/start/{id}:
* post:
* summary: Start Proxmox stats collection
* description: Registers a viewer and starts (or reuses) polling for a host's Proxmox node stats.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Polling started, snapshot returned if already available.
* 403:
* description: Proxmox stats is not enabled for this host.
* 404:
* description: Host not found.
*/
app.post("/start/:id", async (req, res) => {
const id = Number(req.params.id);
const userId = pluginCtx().currentActor()!;
try {
const host = await fetchHostForPolling(pluginCtx(), id, userId);
if (!host) {
return res
.status(403)
.json({ error: "Proxmox stats is not enabled for this host" });
}
const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
manager.registerViewer(id, viewerSessionId, userId);
await manager.ensurePolling(host, userId);
const cached = manager.getStats(id);
if (cached) {
return res.json({
success: true,
viewerSessionId,
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
}
const errorState = manager.getError(id);
if (errorState) {
return res.json({
success: true,
viewerSessionId,
status: "error",
error: errorState.error,
});
}
return res.json({ success: true, viewerSessionId, status: "collecting" });
} catch (error) {
pluginCtx().log.error(
`Failed to start proxmox stats collection for host ${id}`,
error as Error,
);
res
.status(500)
.json({ error: "Failed to start proxmox stats collection" });
}
});
/**
* @openapi
* /plugin-api/proxmox/stats/stop/{id}:
* post:
* summary: Stop Proxmox stats collection
* description: Unregisters a viewer session for a host's Proxmox node stats polling.
* tags: [Proxmox]
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Polling stopped successfully.
*/
app.post("/stop/:id", (req, res) => {
const id = Number(req.params.id);
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (viewerSessionId && typeof viewerSessionId === "string") {
manager.unregisterViewer(id, viewerSessionId);
}
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/proxmox/stats/heartbeat:
* post:
* summary: Update Proxmox stats viewer heartbeat
* description: Keeps a Proxmox stats viewer session alive.
* tags: [Proxmox]
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Heartbeat updated.
* 400:
* description: Invalid viewerSessionId.
* 404:
* description: Viewer session not found.
*/
app.post("/heartbeat", (req, res) => {
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (!viewerSessionId || typeof viewerSessionId !== "string") {
return res.status(400).json({ error: "Invalid viewerSessionId" });
}
const ok = manager.updateHeartbeat(viewerSessionId);
if (!ok) return res.status(404).json({ error: "Viewer session not found" });
res.json({ success: true });
});
/**
* @openapi
* /plugin-api/proxmox/stats/history/{hostId}:
* get:
* summary: Get historical Proxmox node stats for a host
* tags: [Proxmox]
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of node history rows.
* 400:
* description: Invalid range or date.
*/
app.get("/history/:hostId", async (req, res) => {
const hostId = Number(req.params.hostId);
const { range, from, to } = req.query as Record<string, string | undefined>;
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
let fromTs: string;
let toTs = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res.status(400).json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
try {
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await historyRepository.listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
pluginCtx().log.error(
`Failed to fetch proxmox stats history for host ${hostId}`,
error as Error,
);
res.status(500).json({ error: "Failed to fetch proxmox stats history" });
}
});
}
export { parseProxmoxStatsConfig };
+64
View File
@@ -0,0 +1,64 @@
import {
adoptLegacyTable,
defineTable,
id,
integer,
real,
refHost,
refUser,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* Per-host historical node stats samples (CPU, memory, disk, network),
* polled on an interval while a viewer has the Proxmox stats tab open.
*
* Adopted from core's proxmox_node_history, so the column and index names are
* the legacy ones: the rename carries the existing rows across.
*/
export const proxmoxNodeHistory = adoptLegacyTable(
"proxmox_node_history",
defineTable("node_history", {
id: id(),
hostId: refHost(),
ts: timestamp().notNull().defaultNow(),
cpuPercent: real(),
memPercent: real(),
diskPercent: real(),
netRxBytes: integer(),
netTxBytes: integer(),
}),
);
/**
* One saved dashboard layout per user per host for the Proxmox stats tab.
*
* Adopted from core's proxmox_stats_preferences.
*/
export const proxmoxStatsPreferences = adoptLegacyTable(
"proxmox_stats_preferences",
defineTable(
"stats_preferences",
{
id: id(),
userId: refUser(),
hostId: refHost(),
// JSON-encoded layout. No secrets, so plain text.
layout: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
},
{
indexes: [
{
name: "idx_proxmox_stats_prefs_user_host",
columns: ["userId", "hostId"],
unique: true,
},
],
},
),
);
export const tables = [proxmoxNodeHistory, proxmoxStatsPreferences];
@@ -68,7 +68,8 @@ export function ProxmoxDiscoverDialog({
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
const sshHosts = hosts.filter(
(h) => !("isFolder" in h) && h.enableProxmox === true,
(h) =>
!("isFolder" in h) && h.pluginSettings?.proxmox?.enableProxmox === true,
);
// The Proxmox host the discovery runs against — imported guests are grouped
+48 -2
View File
@@ -1,15 +1,21 @@
import { useEffect, useState, type ComponentType } from "react";
import { Boxes, Server } from "lucide-react";
import { Boxes, HardDrive, Server } from "lucide-react";
import {
useHosts,
useTranslation,
type HostEditorSectionProps,
type StandaloneViewProps,
type TabProps,
type TermixApp,
} from "@termix/plugin-sdk/frontend";
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
import type { SSHHostWithStatus } from "@/main-axios";
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
import { HostProxmoxTab } from "./HostProxmoxTab";
import { ProxmoxStatsTab } from "./stats/ProxmoxStatsTab";
import ProxmoxStatsApp from "./stats/ProxmoxStatsApp";
import { HostProxmoxStatsTab } from "./stats/HostProxmoxStatsTab";
import type { SSHHost } from "@/types";
interface DiscoverRequest {
hostId?: number;
@@ -26,12 +32,32 @@ function ProxmoxHostSection({ form, setField }: HostEditorSectionProps) {
form={form}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
/>
{/* Other plugins add Proxmox-related settings here (stats). */}
<HostProxmoxStatsTab
form={form}
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
/>
{/* Other plugins add Proxmox-related settings here. */}
<ComponentSlot slotId="proxmox.hostEditor" props={{ form, setField }} />
</>
);
}
function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) {
return (
<ProxmoxStatsTab
hostConfig={sshHost as unknown as SSHHost}
title={label}
isVisible={isVisible}
isTopbarOpen={false}
embedded={true}
/>
);
}
function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) {
return <ProxmoxStatsApp hostId={hostId} />;
}
export function activate(app: TermixApp): void {
// The dialog lives in the hosts panel; the menu item and host action open
// it through this, created per activation.
@@ -125,4 +151,24 @@ export function activate(app: TermixApp): void {
order: 50,
component: ProxmoxHostSection,
});
app.registerTab("proxmox-stats", ProxmoxStatsTabView, {
icon: HardDrive,
titleKey: "nav.proxmoxStats",
requiresHost: true,
noHostMessageKey: "proxmoxStats.noHostSelected",
standalone: ProxmoxStatsStandalone,
preload: () => import("./stats/ProxmoxStatsTab"),
});
app.registerHostAction({
id: "proxmox-stats",
titleKey: "nav.proxmoxStats",
icon: HardDrive,
kind: "open",
order: 60,
tabType: "proxmox-stats",
copyUrlView: "proxmox-stats",
when: (host) => host.enableProxmoxStats === true,
});
}
@@ -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));
}
+103
View File
@@ -0,0 +1,103 @@
// Frontend-side mirror of the backend Proxmox stats collectors' return
// shapes (src/backend/proxmox/*.ts). Kept in sync by hand.
export interface ProxmoxNodeStats {
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
uptime: {
seconds: number | null;
formatted: string | null;
};
system: {
hostname: string | null;
kernel: string | null;
pveVersion: string | null;
};
}
export interface ProxmoxNodeNetwork {
interfaces: Array<{
name: string;
ip: string | null;
state: string | null;
rxBytes: string | null;
txBytes: string | null;
}>;
}
export interface ProxmoxGuestSummary {
vmid: number;
name: string;
type: "qemu" | "lxc";
status: string;
cpuPercent: number | null;
memPercent: number | null;
memUsedGiB: number | null;
memTotalGiB: number | null;
diskPercent: number | null;
diskUsedGiB: number | null;
diskTotalGiB: number | null;
uptimeSeconds: number | null;
}
export interface ProxmoxGuestsSummary {
guests: ProxmoxGuestSummary[];
counts: { running: number; stopped: number; total: number };
}
export interface ProxmoxStoragePool {
name: string;
type: string;
active: boolean;
enabled: boolean;
usedGiB: number | null;
totalGiB: number | null;
availGiB: number | null;
percent: number | null;
}
export interface ProxmoxStorage {
pools: ProxmoxStoragePool[];
}
export type ProxmoxClusterHealth =
| { clustered: false }
| {
clustered: true;
quorate: boolean;
clusterName: string | null;
nodes: Array<{
name: string;
online: boolean;
local: boolean;
ip: string | null;
}>;
};
export interface ProxmoxStatsSnapshot {
node: ProxmoxNodeStats;
network: ProxmoxNodeNetwork;
guests: ProxmoxGuestsSummary;
storage: ProxmoxStorage;
cluster: ProxmoxClusterHealth;
lastChecked: string;
}
export interface ProxmoxStatsConfig {
nodeName?: string | null;
pollInterval?: number;
enabledCards?: string[];
}
+3
View File
@@ -0,0 +1,3 @@
import { fileURLToPath } from "node:url";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
@@ -0,0 +1,99 @@
import { afterEach, describe, expect, it } from "vitest";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { pluginDir } from "./helpers.js";
// proxmox_node_history and proxmox_stats_preferences as core's SQLite
// bootstrap created them before 2.9.0.
const LEGACY_DDL = `
CREATE TABLE proxmox_node_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
cpu_percent REAL,
mem_percent REAL,
disk_percent REAL,
net_rx_bytes INTEGER,
net_tx_bytes INTEGER
);
CREATE TABLE proxmox_stats_preferences (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
layout TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX idx_proxmox_stats_prefs_user_host ON proxmox_stats_preferences (user_id, host_id);
`;
let db: TestDb | null = null;
afterEach(() => {
db?.close();
db = null;
});
const tableExists = (name: string) =>
!!db!.sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(name);
const indexOn = (table: string) =>
(
db!.sqlite
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
)
.all(table) as { name: string }[]
).map((row) => row.name);
describe("adopting proxmox_node_history and proxmox_stats_preferences", () => {
it("keeps every existing row when the legacy tables are there", async () => {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1')",
);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
sqlite.exec(LEGACY_DDL);
sqlite
.prepare(
"INSERT INTO proxmox_node_history (host_id, ts, cpu_percent) VALUES (?, ?, ?)",
)
.run(1, "2026-01-01 00:00:00", 42);
sqlite
.prepare(
"INSERT INTO proxmox_stats_preferences (user_id, host_id, layout) VALUES (?, ?, ?)",
)
.run("user-1", 1, '{"cards":[]}');
},
});
expect(db.applied).toEqual(["0001_adopt_proxmox_stats"]);
expect(tableExists("proxmox_node_history")).toBe(false);
expect(tableExists("proxmox_stats_preferences")).toBe(false);
expect(tableExists("p_proxmox_node_history")).toBe(true);
expect(tableExists("p_proxmox_stats_preferences")).toBe(true);
expect(
db.sqlite.prepare("SELECT cpu_percent FROM p_proxmox_node_history").all(),
).toEqual([{ cpu_percent: 42 }]);
expect(
db.sqlite.prepare("SELECT layout FROM p_proxmox_stats_preferences").all(),
).toEqual([{ layout: '{"cards":[]}' }]);
expect(indexOn("p_proxmox_stats_preferences")).toEqual([
"idx_proxmox_stats_prefs_user_host",
]);
});
it("creates the tables fresh when there is no legacy one", async () => {
db = await createTestDb(pluginDir);
expect(tableExists("proxmox_node_history")).toBe(false);
expect(tableExists("p_proxmox_node_history")).toBe(true);
expect(tableExists("p_proxmox_stats_preferences")).toBe(true);
expect(
db.sqlite.prepare("SELECT * FROM p_proxmox_node_history").all(),
).toEqual([]);
});
});