From 37c015a76099ba32c51835e58f5629aeb813011d Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 13:06:18 -0500 Subject: [PATCH] feat: move proxmox node stats and collectors into the proxmox plugin --- plugins/proxmox/manifest.json | 37 +- .../mysql/0001_adopt_proxmox_stats.sql | 26 ++ .../postgres/0001_adopt_proxmox_stats.sql | 27 ++ plugins/proxmox/migrations/snapshot.json | 80 ++++ .../sqlite/0001_adopt_proxmox_stats.sql | 27 ++ plugins/proxmox/src/backend/index.ts | 24 +- plugins/proxmox/src/backend/plugin-ctx.ts | 13 + .../proxmox-node-history-repository.ts | 79 ++++ plugins/proxmox/src/backend/proxmox-shared.ts | 7 + .../src/backend/proxmox-stats-polling.ts | 335 ++++++++++++++ .../proxmox/cluster-health-collector.ts | 67 +++ .../backend/proxmox/collect-proxmox-stats.ts | 86 ++++ .../src/backend/proxmox/common-utils.ts | 98 ++++ .../src/backend/proxmox/guests-collector.ts | 113 +++++ .../backend/proxmox/node-network-collector.ts | 145 ++++++ .../backend/proxmox/node-status-collector.ts | 142 ++++++ .../src/backend/proxmox/storage-collector.ts | 76 +++ plugins/proxmox/src/backend/routes.ts | 432 +++++++++--------- plugins/proxmox/src/backend/stats-service.ts | 366 +++++++++++++++ plugins/proxmox/src/backend/tables.ts | 64 +++ .../src/frontend/ProxmoxDiscoverDialog.tsx | 3 +- plugins/proxmox/src/frontend/index.tsx | 50 +- .../proxmox/src/frontend/stats/GuestTable.tsx | 261 +++++++++++ .../frontend/stats/HostProxmoxStatsTab.tsx | 85 ++++ .../src/frontend/stats/NodeSummaryStrip.tsx | 147 ++++++ .../src/frontend/stats/ProxmoxStatsApp.tsx | 51 +++ .../src/frontend/stats/ProxmoxStatsTab.tsx | 382 ++++++++++++++++ .../src/frontend/stats/adaptive-polling.ts | 132 ++++++ .../stats/cards/ClusterHealthCard.tsx | 67 +++ .../frontend/stats/cards/NodeNetworkCard.tsx | 36 ++ .../frontend/stats/cards/StoragePoolsCard.tsx | 60 +++ .../src/frontend/stats/proxmox-stats-api.ts | 80 ++++ .../src/frontend/stats/useConnectionRetry.ts | 193 ++++++++ plugins/proxmox/src/frontend/stats/utils.ts | 6 + plugins/proxmox/src/frontend/types.ts | 103 +++++ plugins/proxmox/tests/backend/helpers.ts | 3 + .../proxmox/tests/backend/migrations.test.ts | 99 ++++ 37 files changed, 3775 insertions(+), 227 deletions(-) create mode 100644 plugins/proxmox/migrations/mysql/0001_adopt_proxmox_stats.sql create mode 100644 plugins/proxmox/migrations/postgres/0001_adopt_proxmox_stats.sql create mode 100644 plugins/proxmox/migrations/snapshot.json create mode 100644 plugins/proxmox/migrations/sqlite/0001_adopt_proxmox_stats.sql create mode 100644 plugins/proxmox/src/backend/plugin-ctx.ts create mode 100644 plugins/proxmox/src/backend/proxmox-node-history-repository.ts create mode 100644 plugins/proxmox/src/backend/proxmox-shared.ts create mode 100644 plugins/proxmox/src/backend/proxmox-stats-polling.ts create mode 100644 plugins/proxmox/src/backend/proxmox/cluster-health-collector.ts create mode 100644 plugins/proxmox/src/backend/proxmox/collect-proxmox-stats.ts create mode 100644 plugins/proxmox/src/backend/proxmox/common-utils.ts create mode 100644 plugins/proxmox/src/backend/proxmox/guests-collector.ts create mode 100644 plugins/proxmox/src/backend/proxmox/node-network-collector.ts create mode 100644 plugins/proxmox/src/backend/proxmox/node-status-collector.ts create mode 100644 plugins/proxmox/src/backend/proxmox/storage-collector.ts create mode 100644 plugins/proxmox/src/backend/stats-service.ts create mode 100644 plugins/proxmox/src/backend/tables.ts create mode 100644 plugins/proxmox/src/frontend/stats/GuestTable.tsx create mode 100644 plugins/proxmox/src/frontend/stats/HostProxmoxStatsTab.tsx create mode 100644 plugins/proxmox/src/frontend/stats/NodeSummaryStrip.tsx create mode 100644 plugins/proxmox/src/frontend/stats/ProxmoxStatsApp.tsx create mode 100644 plugins/proxmox/src/frontend/stats/ProxmoxStatsTab.tsx create mode 100644 plugins/proxmox/src/frontend/stats/adaptive-polling.ts create mode 100644 plugins/proxmox/src/frontend/stats/cards/ClusterHealthCard.tsx create mode 100644 plugins/proxmox/src/frontend/stats/cards/NodeNetworkCard.tsx create mode 100644 plugins/proxmox/src/frontend/stats/cards/StoragePoolsCard.tsx create mode 100644 plugins/proxmox/src/frontend/stats/proxmox-stats-api.ts create mode 100644 plugins/proxmox/src/frontend/stats/useConnectionRetry.ts create mode 100644 plugins/proxmox/src/frontend/stats/utils.ts create mode 100644 plugins/proxmox/src/frontend/types.ts create mode 100644 plugins/proxmox/tests/backend/helpers.ts create mode 100644 plugins/proxmox/tests/backend/migrations.test.ts diff --git a/plugins/proxmox/manifest.json b/plugins/proxmox/manifest.json index b067f4a0d..c3a7ff47a 100644 --- a/plugins/proxmox/manifest.json +++ b/plugins/proxmox/manifest.json @@ -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", diff --git a/plugins/proxmox/migrations/mysql/0001_adopt_proxmox_stats.sql b/plugins/proxmox/migrations/mysql/0001_adopt_proxmox_stats.sql new file mode 100644 index 000000000..472a363b3 --- /dev/null +++ b/plugins/proxmox/migrations/mysql/0001_adopt_proxmox_stats.sql @@ -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`; diff --git a/plugins/proxmox/migrations/postgres/0001_adopt_proxmox_stats.sql b/plugins/proxmox/migrations/postgres/0001_adopt_proxmox_stats.sql new file mode 100644 index 000000000..36b450c9a --- /dev/null +++ b/plugins/proxmox/migrations/postgres/0001_adopt_proxmox_stats.sql @@ -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"; diff --git a/plugins/proxmox/migrations/snapshot.json b/plugins/proxmox/migrations/snapshot.json new file mode 100644 index 000000000..9665ca851 --- /dev/null +++ b/plugins/proxmox/migrations/snapshot.json @@ -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" + } + } +} diff --git a/plugins/proxmox/migrations/sqlite/0001_adopt_proxmox_stats.sql b/plugins/proxmox/migrations/sqlite/0001_adopt_proxmox_stats.sql new file mode 100644 index 000000000..19bc34bd7 --- /dev/null +++ b/plugins/proxmox/migrations/sqlite/0001_adopt_proxmox_stats.sql @@ -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"; diff --git a/plugins/proxmox/src/backend/index.ts b/plugins/proxmox/src/backend/index.ts index 48a5e9801..fcc655f84 100644 --- a/plugins/proxmox/src/backend/index.ts +++ b/plugins/proxmox/src/backend/index.ts @@ -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(); + 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(); } diff --git a/plugins/proxmox/src/backend/plugin-ctx.ts b/plugins/proxmox/src/backend/plugin-ctx.ts new file mode 100644 index 000000000..a36386f49 --- /dev/null +++ b/plugins/proxmox/src/backend/plugin-ctx.ts @@ -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; +} diff --git a/plugins/proxmox/src/backend/proxmox-node-history-repository.ts b/plugins/proxmox/src/backend/proxmox-node-history-repository.ts new file mode 100644 index 000000000..4272994ff --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox-node-history-repository.ts @@ -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(); + + return { + async create(input: ProxmoxNodeHistoryCreateInput): Promise { + const drizzle = await client(); + await drizzle.insert(table).values(input); + await db.persist(); + }, + + async listRange( + hostId: number, + fromIso: string, + toIso: string, + ): Promise { + 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 { + // 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(); + }, + }; +} diff --git a/plugins/proxmox/src/backend/proxmox-shared.ts b/plugins/proxmox/src/backend/proxmox-shared.ts new file mode 100644 index 000000000..112c9f007 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox-shared.ts @@ -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); +} diff --git a/plugins/proxmox/src/backend/proxmox-stats-polling.ts b/plugins/proxmox/src/backend/proxmox-stats-polling.ts new file mode 100644 index 000000000..afc6c3b1b --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox-stats-polling.ts @@ -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(fn: () => Promise): Promise { + if (this.running >= this.max) { + await new Promise((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 { + 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>(); + private snapshotStore = new Map(); + private errorStore = new Map(); + private activeViewers = new Map>(); + private viewerDetails = new Map(); + private inFlight = new Set(); + private viewerCleanupInterval: NodeJS.Timeout; + + constructor( + private readonly deps: { + fetchHostById: ( + hostId: number, + userId: string, + ) => Promise; + withSshConnection: ( + host: THost, + fn: (client: Client) => Promise, + ) => Promise; + 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 { + 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 { + 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 { + 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); + } + } +} diff --git a/plugins/proxmox/src/backend/proxmox/cluster-health-collector.ts b/plugins/proxmox/src/backend/proxmox/cluster-health-collector.ts new file mode 100644 index 000000000..6ab539362 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/cluster-health-collector.ts @@ -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 { + 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>; + 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; + } +} diff --git a/plugins/proxmox/src/backend/proxmox/collect-proxmox-stats.ts b/plugins/proxmox/src/backend/proxmox/collect-proxmox-stats.ts new file mode 100644 index 000000000..846cf21cf --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/collect-proxmox-stats.ts @@ -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 { + 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 { + 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(), + }; +} diff --git a/plugins/proxmox/src/backend/proxmox/common-utils.ts b/plugins/proxmox/src/backend/proxmox/common-utils.ts new file mode 100644 index 000000000..9e8147e30 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/common-utils.ts @@ -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)); +} diff --git a/plugins/proxmox/src/backend/proxmox/guests-collector.ts b/plugins/proxmox/src/backend/proxmox/guests-collector.ts new file mode 100644 index 000000000..703852050 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/guests-collector.ts @@ -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 { + 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>) { + 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; + } +} diff --git a/plugins/proxmox/src/backend/proxmox/node-network-collector.ts b/plugins/proxmox/src/backend/proxmox/node-network-collector.ts new file mode 100644 index 000000000..0c4a03738 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/node-network-collector.ts @@ -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 { + 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(); + 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 { + 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 => + !!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); + } +} diff --git a/plugins/proxmox/src/backend/proxmox/node-status-collector.ts b/plugins/proxmox/src/backend/proxmox/node-status-collector.ts new file mode 100644 index 000000000..e7bcec87c --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/node-status-collector.ts @@ -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 { + 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; + + const cpuFraction = typeof data.cpu === "number" ? data.cpu : null; + const cpuinfo = (data.cpuinfo as Record) || {}; + 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) || {}; + 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) || {}; + 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; + } +} diff --git a/plugins/proxmox/src/backend/proxmox/storage-collector.ts b/plugins/proxmox/src/backend/proxmox/storage-collector.ts new file mode 100644 index 000000000..9b1375257 --- /dev/null +++ b/plugins/proxmox/src/backend/proxmox/storage-collector.ts @@ -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 { + 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> + ).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; + } +} diff --git a/plugins/proxmox/src/backend/routes.ts b/plugins/proxmox/src/backend/routes.ts index dcfe9a61f..3c7082ee8 100644 --- a/plugins/proxmox/src/backend/routes.ts +++ b/plugins/proxmox/src/backend/routes.ts @@ -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(); 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; }> { - 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>; @@ -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 { + 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[]; + const existingHosts = (await ctx.hosts.listOwned()) as unknown as Record< + string, + unknown + >[]; const existingBySource = new Map>(); 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, ): Promise { - 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 { + 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 { 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 diff --git a/plugins/proxmox/src/backend/stats-service.ts b/plugins/proxmox/src/backend/stats-service.ts new file mode 100644 index 000000000..c7b0b0f11 --- /dev/null +++ b/plugins/proxmox/src/backend/stats-service.ts @@ -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 | undefined; +let router: Router | undefined; + +async function fetchHostForPolling( + ctx: PluginContext, + hostId: number, + userId: string, +): Promise { + const enabled = await ctx.asUser(userId, () => + ctx.settings.getHost(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({ + 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, + 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; + + const RANGE_OFFSETS: Record = { + "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 }; diff --git a/plugins/proxmox/src/backend/tables.ts b/plugins/proxmox/src/backend/tables.ts new file mode 100644 index 000000000..0c91970f6 --- /dev/null +++ b/plugins/proxmox/src/backend/tables.ts @@ -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]; diff --git a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx index 27630e506..0c0932990 100644 --- a/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx +++ b/plugins/proxmox/src/frontend/ProxmoxDiscoverDialog.tsx @@ -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 diff --git a/plugins/proxmox/src/frontend/index.tsx b/plugins/proxmox/src/frontend/index.tsx index a3df8cf3a..d1294d787 100644 --- a/plugins/proxmox/src/frontend/index.tsx +++ b/plugins/proxmox/src/frontend/index.tsx @@ -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[0]["setField"]} /> - {/* Other plugins add Proxmox-related settings here (stats). */} + [0]["setField"]} + /> + {/* Other plugins add Proxmox-related settings here. */} ); } +function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) { + return ( + + ); +} + +function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) { + return ; +} + 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, + }); } diff --git a/plugins/proxmox/src/frontend/stats/GuestTable.tsx b/plugins/proxmox/src/frontend/stats/GuestTable.tsx new file mode 100644 index 000000000..9921e7634 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/GuestTable.tsx @@ -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 -; + } + 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 ( +
+
+ + {clamped.toFixed(0)}% + + {usedGiB != null && totalGiB != null && ( + + {usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G + + )} +
+
+
+
+
+ ); +} + +export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) { + const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const [statusFilter, setStatusFilter] = useState("all"); + const [typeFilter, setTypeFilter] = useState("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 ( +
+
+ + {t("proxmoxStats.guestsSummary")} + + + {t("proxmoxStats.guestCounts", { running, total: guests.length })} + +
+
+ + setQuery(e.target.value)} + placeholder={t("proxmoxStats.searchGuests")} + className="h-7 w-40 pl-6 text-xs" + /> +
+
+ {(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => ( + + ))} +
+
+ {(["all", "running", "stopped"] as StatusFilter[]).map((f) => ( + + ))} +
+
+
+ +
+ {guests.length === 0 ? ( +
+ {t("proxmoxStats.noGuests")} +
+ ) : filtered.length === 0 ? ( +
+ {t("proxmoxStats.noGuestsMatch")} +
+ ) : ( + + + + + + + + + + + + + + {filtered.map((guest) => ( + + + + + + + + + + ))} + +
+ {t("proxmoxStats.name")} + + {t("proxmoxStats.status")} + ID + {t("proxmoxStats.cpu")} + + {t("proxmoxStats.mem")} + + {t("proxmoxStats.disk")} + + {t("proxmoxStats.uptime")} +
+
+ {guest.type === "lxc" ? ( + + ) : ( + + )} + + {guest.name} + + + {guest.type === "lxc" ? "LXC" : "VM"} + +
+
+
+ + + {guest.status === "running" + ? t("proxmoxStats.running") + : t("proxmoxStats.stopped")} + +
+
+ {guest.vmid} + + + + + + + + {formatUptime(guest.uptimeSeconds)} +
+ )} +
+
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/HostProxmoxStatsTab.tsx b/plugins/proxmox/src/frontend/stats/HostProxmoxStatsTab.tsx new file mode 100644 index 000000000..8d52b04b5 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/HostProxmoxStatsTab.tsx @@ -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 ( + } + > +
+ + setField("enableProxmoxStats", v)} + /> + + {form.enableProxmoxStats && ( + <> + + + setField("proxmoxStatsConfig", { + ...form.proxmoxStatsConfig, + pollInterval: Math.max( + 15, + Number.parseInt(e.target.value || "60", 10), + ), + }) + } + /> + + + + setField("proxmoxStatsConfig", { + ...form.proxmoxStatsConfig, + nodeName: e.target.value || null, + }) + } + /> + + + )} +
+
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/NodeSummaryStrip.tsx b/plugins/proxmox/src/frontend/stats/NodeSummaryStrip.tsx new file mode 100644 index 000000000..a9564eb68 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/NodeSummaryStrip.tsx @@ -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 ( +
+
+ {icon} + + {label} + +
+
+ + {clamped === null ? "N/A" : `${clamped.toFixed(0)}%`} + + {detail && ( + + {detail} + + )} +
+
+
+
+ {sparkline && sparkline.length > 1 && } +
+ ); +} + +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 ( + + + + ); +} + +export function NodeSummaryStrip({ + node, + histories, +}: { + node: ProxmoxNodeStats | undefined; + histories: { cpu: number[]; memory: number[]; disk: number[] }; +}) { + const { t } = useTranslation(); + + return ( +
+ } + 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} + /> + } + 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} + /> + } + 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} + /> +
+
+ + + {t("proxmoxStats.uptime")} + +
+ + {node?.uptime.formatted ?? "N/A"} + +
+ + + {node?.system.hostname ?? "-"} + {node?.system.pveVersion ? ` ยท ${node.system.pveVersion}` : ""} + +
+
+
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/ProxmoxStatsApp.tsx b/plugins/proxmox/src/frontend/stats/ProxmoxStatsApp.tsx new file mode 100644 index 000000000..7a55820b9 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/ProxmoxStatsApp.tsx @@ -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 = ({ hostId }) => { + const { t } = useTranslation(); + return ( + + {(hostConfig, phase) => { + if (phase === "loading") { + return ( +
+ +
+ ); + } + + if (!hostConfig) { + return ( +
+ +
+ ); + } + + return ( + + ); + }} +
+ ); +}; + +export default ProxmoxStatsApp; diff --git a/plugins/proxmox/src/frontend/stats/ProxmoxStatsTab.tsx b/plugins/proxmox/src/frontend/stats/ProxmoxStatsTab.tsx new file mode 100644 index 000000000..14ac42c4a --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/ProxmoxStatsTab.tsx @@ -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> & 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 ( + + + + ); +} + +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( + null, + ); + const [histories, setHistories] = React.useState({ + 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( + 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 => { + 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 ( +
+
+ {retry.status === "connected" && !notEnabled && ( +
+
+
+ +
+

{title}

+
+ +
+ )} + + {showContent && ( +
+
+ +
+ + + +
+ + + {snapshot.cluster.clustered && ( + + )} +
+
+ )} + + + +

+ {t("proxmoxStats.noHostSelected")} +

+
+ ) : undefined + } + /> +
+
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/adaptive-polling.ts b/plugins/proxmox/src/frontend/stats/adaptive-polling.ts new file mode 100644 index 000000000..5664ef88e --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/adaptive-polling.ts @@ -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, + 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 | 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); + } + }; +} diff --git a/plugins/proxmox/src/frontend/stats/cards/ClusterHealthCard.tsx b/plugins/proxmox/src/frontend/stats/cards/ClusterHealthCard.tsx new file mode 100644 index 000000000..2e16e6674 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/cards/ClusterHealthCard.tsx @@ -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 ( + } + scroll + > + {!cluster || !cluster.clustered ? ( + + {t("proxmoxStats.notClustered")} + + ) : ( +
+
+ + + {cluster.clusterName ?? t("proxmoxStats.clusterHealth")} + + + {cluster.quorate + ? t("proxmoxStats.quorate") + : t("proxmoxStats.notQuorate")} + +
+
+ {cluster.nodes.map((node) => ( +
+ + + {node.name} + + + {node.online + ? t("proxmoxStats.nodeOnline") + : t("proxmoxStats.nodeOffline")} + +
+ ))} +
+
+ )} +
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/cards/NodeNetworkCard.tsx b/plugins/proxmox/src/frontend/stats/cards/NodeNetworkCard.tsx new file mode 100644 index 000000000..cd0d52756 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/cards/NodeNetworkCard.tsx @@ -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 ( + } + scroll + > + {interfaces.length === 0 ? ( + N/A + ) : ( +
+ {interfaces.map((iface) => ( + + ))} +
+ )} +
+ ); +} diff --git a/plugins/proxmox/src/frontend/stats/cards/StoragePoolsCard.tsx b/plugins/proxmox/src/frontend/stats/cards/StoragePoolsCard.tsx new file mode 100644 index 000000000..9b9c48059 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/cards/StoragePoolsCard.tsx @@ -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 ( + } + scroll + > + {pools.length === 0 ? ( + N/A + ) : ( +
+ {pools.map((pool) => ( +
+
+ + {pool.name} + + + {pool.type} + + {!pool.active && ( + + {t("proxmoxStats.inactive")} + + )} +
+
+
+
+
+ + {pool.usedGiB !== null && pool.totalGiB !== null + ? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB` + : "N/A"} + +
+
+ ))} +
+ )} + + ); +} diff --git a/plugins/proxmox/src/frontend/stats/proxmox-stats-api.ts b/plugins/proxmox/src/frontend/stats/proxmox-stats-api.ts new file mode 100644 index 000000000..5fd5c4356 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/proxmox-stats-api.ts @@ -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 { + try { + const response = await api.get( + `/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 { + await api.post(`/stats/stop/${hostId}`, { viewerSessionId }); + }, + + async sendHeartbeat(viewerSessionId: string): Promise { + await api.post("/stats/heartbeat", { viewerSessionId }); + }, + + async getHistory( + hostId: number, + opts: { range?: string; from?: string; to?: string }, + ): Promise { + const response = await api.get( + `/stats/history/${hostId}`, + { params: opts }, + ); + return response.data; + }, + }; +} + +export type ProxmoxStatsApi = ReturnType; + +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; +} diff --git a/plugins/proxmox/src/frontend/stats/useConnectionRetry.ts b/plugins/proxmox/src/frontend/stats/useConnectionRetry.ts new file mode 100644 index 000000000..38dc0d4e1 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/useConnectionRetry.ts @@ -0,0 +1,193 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected"; + +interface UseConnectionRetryOptions { + connect: () => void | Promise; + 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("connecting"); + const [attempt, setAttempt] = useState(0); + const [nextRetryInMs, setNextRetryInMs] = useState(null); + + const connectRef = useRef(connect); + connectRef.current = connect; + + const enabledRef = useRef(enabled); + enabledRef.current = enabled; + + const attemptRef = useRef(0); + const retryTimeoutRef = useRef | null>(null); + const countdownIntervalRef = useRef | 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, + }; +} diff --git a/plugins/proxmox/src/frontend/stats/utils.ts b/plugins/proxmox/src/frontend/stats/utils.ts new file mode 100644 index 000000000..a5ef19350 --- /dev/null +++ b/plugins/proxmox/src/frontend/stats/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx"; +import { twMerge } from "tailwind-merge"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)); +} diff --git a/plugins/proxmox/src/frontend/types.ts b/plugins/proxmox/src/frontend/types.ts new file mode 100644 index 000000000..370aa2130 --- /dev/null +++ b/plugins/proxmox/src/frontend/types.ts @@ -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[]; +} diff --git a/plugins/proxmox/tests/backend/helpers.ts b/plugins/proxmox/tests/backend/helpers.ts new file mode 100644 index 000000000..90bd1b95c --- /dev/null +++ b/plugins/proxmox/tests/backend/helpers.ts @@ -0,0 +1,3 @@ +import { fileURLToPath } from "node:url"; + +export const pluginDir = fileURLToPath(new URL("../..", import.meta.url)); diff --git a/plugins/proxmox/tests/backend/migrations.test.ts b/plugins/proxmox/tests/backend/migrations.test.ts new file mode 100644 index 000000000..4967217c4 --- /dev/null +++ b/plugins/proxmox/tests/backend/migrations.test.ts @@ -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([]); + }); +});