mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: move proxmox node stats and collectors into the proxmox plugin
This commit is contained in:
1 parent
218c5088e7
commit
37c015a760
37 files changed
+3775
-227
No files matched your search
@@ -24,10 +24,39 @@
|
||||
"ui:surface"
|
||||
],
|
||||
"contributes": {
|
||||
"hostCapability": {
|
||||
"key": "enableProxmox",
|
||||
"labelKey": "hosts.proxmoxIntegration",
|
||||
"editorTab": "proxmox"
|
||||
"tabs": [
|
||||
{
|
||||
"id": "proxmox-stats",
|
||||
"titleKey": "nav.proxmoxStats",
|
||||
"icon": "HardDrive",
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"host": {
|
||||
"fields": [
|
||||
{
|
||||
"key": "enableProxmox",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
},
|
||||
{
|
||||
"key": "proxmoxConfig",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
},
|
||||
{
|
||||
"key": "enableProxmoxStats",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
},
|
||||
{
|
||||
"key": "proxmoxStatsConfig",
|
||||
"type": "custom",
|
||||
"component": "proxmoxHostSettings"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- proxmox 0001: adopt_proxmox_stats
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `proxmox_node_history` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`host_id` int NOT NULL,
|
||||
`ts` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`cpu_percent` double,
|
||||
`mem_percent` double,
|
||||
`disk_percent` double,
|
||||
`net_rx_bytes` int,
|
||||
`net_tx_bytes` int,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `proxmox_node_history` TO `p_proxmox_node_history`;
|
||||
CREATE TABLE IF NOT EXISTS `proxmox_stats_preferences` (
|
||||
`id` int AUTO_INCREMENT PRIMARY KEY,
|
||||
`user_id` varchar(255) NOT NULL,
|
||||
`host_id` int NOT NULL,
|
||||
`layout` text NOT NULL,
|
||||
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE,
|
||||
FOREIGN KEY (`host_id`) REFERENCES `ssh_data` (`id`) ON DELETE CASCADE
|
||||
);
|
||||
RENAME TABLE `proxmox_stats_preferences` TO `p_proxmox_stats_preferences`;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- proxmox 0001: adopt_proxmox_stats
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "proxmox_node_history" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"host_id" integer NOT NULL,
|
||||
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cpu_percent" double precision,
|
||||
"mem_percent" double precision,
|
||||
"disk_percent" double precision,
|
||||
"net_rx_bytes" integer,
|
||||
"net_tx_bytes" integer,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "proxmox_node_history" RENAME TO "p_proxmox_node_history";
|
||||
CREATE TABLE IF NOT EXISTS "proxmox_stats_preferences" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"user_id" varchar(255) NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"layout" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_proxmox_stats_prefs_user_host" ON "proxmox_stats_preferences" ("user_id", "host_id");
|
||||
ALTER TABLE "proxmox_stats_preferences" RENAME TO "p_proxmox_stats_preferences";
|
||||
@@ -0,0 +1,80 @@
|
||||
{
|
||||
"version": 1,
|
||||
"tables": {
|
||||
"node_history": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"ts": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"cpuPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"memPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"diskPercent": {
|
||||
"type": "real"
|
||||
},
|
||||
"netRxBytes": {
|
||||
"type": "integer"
|
||||
},
|
||||
"netTxBytes": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"indexes": [],
|
||||
"adopts": "proxmox_node_history"
|
||||
},
|
||||
"stats_preferences": {
|
||||
"columns": {
|
||||
"id": {
|
||||
"type": "id",
|
||||
"primaryKey": true,
|
||||
"notNull": true
|
||||
},
|
||||
"userId": {
|
||||
"type": "refUser",
|
||||
"length": 255,
|
||||
"notNull": true
|
||||
},
|
||||
"hostId": {
|
||||
"type": "refHost",
|
||||
"notNull": true
|
||||
},
|
||||
"layout": {
|
||||
"type": "text",
|
||||
"notNull": true
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "timestamp",
|
||||
"notNull": true,
|
||||
"defaultNow": true
|
||||
}
|
||||
},
|
||||
"indexes": [
|
||||
{
|
||||
"name": "idx_proxmox_stats_prefs_user_host",
|
||||
"columns": ["userId", "hostId"],
|
||||
"unique": true
|
||||
}
|
||||
],
|
||||
"adopts": "proxmox_stats_preferences"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
-- proxmox 0001: adopt_proxmox_stats
|
||||
-- Generated by termix-plugin migrations. Review before committing.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "proxmox_node_history" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"host_id" integer NOT NULL,
|
||||
"ts" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"cpu_percent" real,
|
||||
"mem_percent" real,
|
||||
"disk_percent" real,
|
||||
"net_rx_bytes" integer,
|
||||
"net_tx_bytes" integer,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
ALTER TABLE "proxmox_node_history" RENAME TO "p_proxmox_node_history";
|
||||
CREATE TABLE IF NOT EXISTS "proxmox_stats_preferences" (
|
||||
"id" integer PRIMARY KEY AUTOINCREMENT,
|
||||
"user_id" text NOT NULL,
|
||||
"host_id" integer NOT NULL,
|
||||
"layout" text NOT NULL,
|
||||
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("host_id") REFERENCES "ssh_data" ("id") ON DELETE CASCADE
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "idx_proxmox_stats_prefs_user_host" ON "proxmox_stats_preferences" ("user_id", "host_id");
|
||||
ALTER TABLE "proxmox_stats_preferences" RENAME TO "p_proxmox_stats_preferences";
|
||||
@@ -1,14 +1,36 @@
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginCtx } from "./plugin-ctx.js";
|
||||
import { startProxmoxService, stopProxmoxService } from "./routes.js";
|
||||
import {
|
||||
startProxmoxStatsService,
|
||||
stopProxmoxStatsService,
|
||||
} from "./stats-service.js";
|
||||
import { proxmoxNodeHistory } from "./tables.js";
|
||||
import { createProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
setPluginCtx(ctx);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
startProxmoxService(ctx.http.router());
|
||||
ctx.disposables.add(() => setPluginCtx(null));
|
||||
|
||||
const historyTable = await ctx.db.define(proxmoxNodeHistory);
|
||||
const historyRepository = createProxmoxNodeHistoryRepository(
|
||||
ctx.db,
|
||||
historyTable,
|
||||
);
|
||||
|
||||
const router = ctx.http.router<Router>();
|
||||
startProxmoxService(router);
|
||||
ctx.disposables.add(stopProxmoxService);
|
||||
startProxmoxStatsService(ctx, router, historyRepository);
|
||||
ctx.disposables.add(stopProxmoxStatsService);
|
||||
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
stopProxmoxService();
|
||||
stopProxmoxStatsService();
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginContext | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginCtx(ctx: PluginContext | null): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function pluginCtx(): PluginContext {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { and, gte, lte, eq, asc, lt } from "drizzle-orm";
|
||||
import type { PluginDatabase } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export interface ProxmoxNodeHistoryRow {
|
||||
id: number;
|
||||
hostId: number;
|
||||
ts: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxNodeHistoryCreateInput {
|
||||
hostId: number;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
diskPercent: number | null;
|
||||
netRxBytes: number | null;
|
||||
netTxBytes: number | null;
|
||||
}
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
type Table = any;
|
||||
type Drizzle = any;
|
||||
/* eslint-enable @typescript-eslint/no-explicit-any */
|
||||
|
||||
export type ProxmoxNodeHistoryRepository = ReturnType<
|
||||
typeof createProxmoxNodeHistoryRepository
|
||||
>;
|
||||
|
||||
export function createProxmoxNodeHistoryRepository(
|
||||
db: PluginDatabase,
|
||||
table: Table,
|
||||
) {
|
||||
const client = () => db.client<Drizzle>();
|
||||
|
||||
return {
|
||||
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
|
||||
const drizzle = await client();
|
||||
await drizzle.insert(table).values(input);
|
||||
await db.persist();
|
||||
},
|
||||
|
||||
async listRange(
|
||||
hostId: number,
|
||||
fromIso: string,
|
||||
toIso: string,
|
||||
): Promise<ProxmoxNodeHistoryRow[]> {
|
||||
const drizzle = await client();
|
||||
return drizzle
|
||||
.select()
|
||||
.from(table)
|
||||
.where(
|
||||
and(
|
||||
eq(table.hostId, hostId),
|
||||
gte(table.ts, fromIso),
|
||||
lte(table.ts, toIso),
|
||||
),
|
||||
)
|
||||
.orderBy(asc(table.ts));
|
||||
},
|
||||
|
||||
async pruneOlderThan(hostId: number, retentionDays: number): Promise<void> {
|
||||
// ts columns store CURRENT_TIMESTAMP as "YYYY-MM-DD HH:MM:SS" UTC, which
|
||||
// sorts lexicographically, so the cutoff is a plain string comparison.
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000)
|
||||
.toISOString()
|
||||
.slice(0, 19)
|
||||
.replace("T", " ");
|
||||
const drizzle = await client();
|
||||
await drizzle
|
||||
.delete(table)
|
||||
.where(and(eq(table.hostId, hostId), lt(table.ts, cutoff)));
|
||||
await db.persist();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself,
|
||||
// but we validate defensively before using in a shell command.
|
||||
const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/;
|
||||
|
||||
export function isSafeNodeName(name: string): boolean {
|
||||
return SAFE_NODE_RE.test(name);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
import {
|
||||
collectProxmoxStats,
|
||||
type ProxmoxStatsSnapshot,
|
||||
} from "./proxmox/collect-proxmox-stats.js";
|
||||
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
|
||||
import { pluginCtx } from "./plugin-ctx.js";
|
||||
import type { Client } from "ssh2";
|
||||
|
||||
/**
|
||||
* Proxmox stats polling gets its own concurrency limiter so a burst of polls
|
||||
* on many hosts cannot starve one host's collection.
|
||||
*/
|
||||
class ConcurrentLimiter {
|
||||
private running = 0;
|
||||
private queue: Array<() => void> = [];
|
||||
constructor(private readonly max: number) {}
|
||||
|
||||
async run<T>(fn: () => Promise<T>): Promise<T> {
|
||||
if (this.running >= this.max) {
|
||||
await new Promise<void>((resolve) => this.queue.push(resolve));
|
||||
}
|
||||
this.running++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
this.running--;
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const proxmoxStatsPollLimiter = new ConcurrentLimiter(5);
|
||||
|
||||
export interface ProxmoxStatsPollableHost {
|
||||
id: number;
|
||||
userId: string;
|
||||
proxmoxStatsConfig?: string | ProxmoxStatsPollConfig | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsPollConfig {
|
||||
nodeName?: string | null;
|
||||
pollInterval?: number;
|
||||
enabledCards?: string[];
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_INTERVAL_SECONDS = 60;
|
||||
|
||||
export function parseProxmoxStatsConfig(
|
||||
raw: string | ProxmoxStatsPollConfig | null | undefined,
|
||||
): ProxmoxStatsPollConfig {
|
||||
if (!raw) {
|
||||
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
|
||||
}
|
||||
if (typeof raw === "object") {
|
||||
return {
|
||||
nodeName: raw.nodeName ?? null,
|
||||
pollInterval: raw.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
enabledCards: raw.enabledCards,
|
||||
};
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as ProxmoxStatsPollConfig;
|
||||
return {
|
||||
nodeName: parsed.nodeName ?? null,
|
||||
pollInterval: parsed.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
enabledCards: parsed.enabledCards,
|
||||
};
|
||||
} catch {
|
||||
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
|
||||
}
|
||||
}
|
||||
|
||||
interface HostPollingEntry<THost extends ProxmoxStatsPollableHost> {
|
||||
host: THost;
|
||||
timer?: NodeJS.Timeout;
|
||||
viewerUserId?: string;
|
||||
}
|
||||
|
||||
interface ViewerDetail {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
hostId: number;
|
||||
lastHeartbeat: number;
|
||||
}
|
||||
|
||||
interface CachedSnapshot {
|
||||
data: ProxmoxStatsSnapshot;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface ErrorSnapshot {
|
||||
error: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export class ProxmoxPollingManager<
|
||||
THost extends ProxmoxStatsPollableHost = ProxmoxStatsPollableHost,
|
||||
> {
|
||||
private pollingConfigs = new Map<number, HostPollingEntry<THost>>();
|
||||
private snapshotStore = new Map<number, CachedSnapshot>();
|
||||
private errorStore = new Map<number, ErrorSnapshot>();
|
||||
private activeViewers = new Map<number, Set<string>>();
|
||||
private viewerDetails = new Map<string, ViewerDetail>();
|
||||
private inFlight = new Set<number>();
|
||||
private viewerCleanupInterval: NodeJS.Timeout;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
fetchHostById: (
|
||||
hostId: number,
|
||||
userId: string,
|
||||
) => Promise<THost | undefined>;
|
||||
withSshConnection: <T>(
|
||||
host: THost,
|
||||
fn: (client: Client) => Promise<T>,
|
||||
) => Promise<T>;
|
||||
historyRepository: ProxmoxNodeHistoryRepository;
|
||||
historyEnabled?: () => boolean;
|
||||
},
|
||||
) {
|
||||
this.viewerCleanupInterval = setInterval(() => {
|
||||
this.cleanupInactiveViewers();
|
||||
}, 60000);
|
||||
}
|
||||
|
||||
private intervalWithJitter(intervalMs: number, hostId: number): number {
|
||||
const spread = Math.min(intervalMs * 0.2, 15_000);
|
||||
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
|
||||
return intervalMs + jitter;
|
||||
}
|
||||
|
||||
// Host Metrics' own retention setting is not on the core settings
|
||||
// allowlist, so this plugin uses a fixed default rather than reaching for
|
||||
// it. See FINISH-LIST.md.
|
||||
private readonly retentionDays = 7;
|
||||
|
||||
private async pollHostStats(
|
||||
host: THost,
|
||||
viewerUserId?: string,
|
||||
): Promise<void> {
|
||||
if (this.inFlight.has(host.id)) return;
|
||||
this.inFlight.add(host.id);
|
||||
|
||||
try {
|
||||
await proxmoxStatsPollLimiter.run(async () => {
|
||||
const userId = viewerUserId || host.userId;
|
||||
const refreshed =
|
||||
(await this.deps.fetchHostById(host.id, userId)) ?? host;
|
||||
const config = parseProxmoxStatsConfig(refreshed.proxmoxStatsConfig);
|
||||
|
||||
try {
|
||||
const snapshot = await this.deps.withSshConnection(
|
||||
refreshed,
|
||||
(client) => collectProxmoxStats(client, config.nodeName),
|
||||
);
|
||||
|
||||
this.snapshotStore.set(host.id, {
|
||||
data: snapshot,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
this.errorStore.delete(host.id);
|
||||
|
||||
if (this.deps.historyEnabled?.() ?? true) {
|
||||
await this.insertHistory(host.id, snapshot);
|
||||
}
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
this.errorStore.set(host.id, {
|
||||
error: message,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
pluginCtx().log.warn(
|
||||
`Proxmox stats poll failed for host ${host.id}: ${message}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
this.inFlight.delete(host.id);
|
||||
}
|
||||
}
|
||||
|
||||
private async insertHistory(
|
||||
hostId: number,
|
||||
snapshot: ProxmoxStatsSnapshot,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const iface = snapshot.network?.interfaces?.[0];
|
||||
const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null;
|
||||
const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null;
|
||||
|
||||
await this.deps.historyRepository.create({
|
||||
hostId,
|
||||
cpuPercent: snapshot.node?.cpu?.percent ?? null,
|
||||
memPercent: snapshot.node?.memory?.percent ?? null,
|
||||
diskPercent: snapshot.node?.disk?.percent ?? null,
|
||||
netRxBytes: rxRaw !== null && !isNaN(rxRaw) ? rxRaw : null,
|
||||
netTxBytes: txRaw !== null && !isNaN(txRaw) ? txRaw : null,
|
||||
});
|
||||
|
||||
await this.deps.historyRepository.pruneOlderThan(
|
||||
hostId,
|
||||
this.retentionDays,
|
||||
);
|
||||
} catch (err) {
|
||||
pluginCtx().log.warn(
|
||||
`Failed to write proxmox node history for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private startPollingForHost(host: THost, viewerUserId?: string): void {
|
||||
const existing = this.pollingConfigs.get(host.id);
|
||||
if (existing?.timer) {
|
||||
clearInterval(existing.timer);
|
||||
}
|
||||
|
||||
const config = parseProxmoxStatsConfig(host.proxmoxStatsConfig);
|
||||
const intervalMs = this.intervalWithJitter(
|
||||
(config.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
|
||||
host.id,
|
||||
);
|
||||
|
||||
void this.pollHostStats(host, viewerUserId);
|
||||
|
||||
const timer = setInterval(() => {
|
||||
const latest = this.pollingConfigs.get(host.id);
|
||||
if (latest) {
|
||||
void this.pollHostStats(latest.host, latest.viewerUserId);
|
||||
}
|
||||
}, intervalMs);
|
||||
|
||||
this.pollingConfigs.set(host.id, { host, timer, viewerUserId });
|
||||
}
|
||||
|
||||
private stopPollingForHost(hostId: number): void {
|
||||
const config = this.pollingConfigs.get(hostId);
|
||||
if (config?.timer) {
|
||||
clearInterval(config.timer);
|
||||
}
|
||||
this.pollingConfigs.delete(hostId);
|
||||
}
|
||||
|
||||
getStats(
|
||||
hostId: number,
|
||||
): { data: ProxmoxStatsSnapshot; timestamp: number } | undefined {
|
||||
return this.snapshotStore.get(hostId);
|
||||
}
|
||||
|
||||
getError(hostId: number): ErrorSnapshot | undefined {
|
||||
return this.errorStore.get(hostId);
|
||||
}
|
||||
|
||||
async ensurePolling(host: THost, viewerUserId?: string): Promise<void> {
|
||||
if (!this.pollingConfigs.has(host.id)) {
|
||||
this.startPollingForHost(host, viewerUserId);
|
||||
}
|
||||
if (!this.snapshotStore.has(host.id) && !this.inFlight.has(host.id)) {
|
||||
await this.pollHostStats(host, viewerUserId);
|
||||
}
|
||||
}
|
||||
|
||||
registerViewer = (
|
||||
hostId: number,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
): void => {
|
||||
if (!this.activeViewers.has(hostId)) {
|
||||
this.activeViewers.set(hostId, new Set());
|
||||
}
|
||||
this.activeViewers.get(hostId)!.add(sessionId);
|
||||
|
||||
this.viewerDetails.set(sessionId, {
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
lastHeartbeat: Date.now(),
|
||||
});
|
||||
|
||||
if (this.activeViewers.get(hostId)!.size === 1) {
|
||||
Promise.resolve()
|
||||
.then(async () => {
|
||||
const host = await this.deps.fetchHostById(hostId, userId);
|
||||
if (host) {
|
||||
this.startPollingForHost(host, userId);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
pluginCtx().log.warn(
|
||||
`Proxmox stats startPollingForHost rejected (non-fatal) for host ${hostId}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
unregisterViewer = (hostId: number, sessionId: string): void => {
|
||||
const viewers = this.activeViewers.get(hostId);
|
||||
if (viewers) {
|
||||
viewers.delete(sessionId);
|
||||
if (viewers.size === 0) {
|
||||
this.activeViewers.delete(hostId);
|
||||
this.stopPollingForHost(hostId);
|
||||
}
|
||||
}
|
||||
this.viewerDetails.delete(sessionId);
|
||||
};
|
||||
|
||||
updateHeartbeat(sessionId: string): boolean {
|
||||
const viewer = this.viewerDetails.get(sessionId);
|
||||
if (viewer) {
|
||||
viewer.lastHeartbeat = Date.now();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private cleanupInactiveViewers(): void {
|
||||
const now = Date.now();
|
||||
const maxInactivity = 120000;
|
||||
|
||||
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
|
||||
if (now - viewer.lastHeartbeat > maxInactivity) {
|
||||
this.unregisterViewer(viewer.hostId, sessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
clearInterval(this.viewerCleanupInterval);
|
||||
for (const hostId of this.pollingConfigs.keys()) {
|
||||
this.stopPollingForHost(hostId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
|
||||
export interface ProxmoxClusterNodeEntry {
|
||||
name: string;
|
||||
online: boolean;
|
||||
local: boolean;
|
||||
ip: string | null;
|
||||
}
|
||||
|
||||
export type ProxmoxClusterHealthResult =
|
||||
| { clustered: false }
|
||||
| {
|
||||
clustered: true;
|
||||
quorate: boolean;
|
||||
clusterName: string | null;
|
||||
nodes: ProxmoxClusterNodeEntry[];
|
||||
};
|
||||
|
||||
const EMPTY_RESULT: ProxmoxClusterHealthResult = { clustered: false };
|
||||
|
||||
export async function collectProxmoxClusterHealth(
|
||||
client: Client,
|
||||
): Promise<ProxmoxClusterHealthResult> {
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
client,
|
||||
"pvesh get /cluster/status --output-format json",
|
||||
15000,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const data = JSON.parse(stdout);
|
||||
if (!Array.isArray(data)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const entries = data as Array<Record<string, unknown>>;
|
||||
const clusterEntry = entries.find((e) => e.type === "cluster");
|
||||
if (!clusterEntry) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const nodes: ProxmoxClusterNodeEntry[] = entries
|
||||
.filter((e) => e.type === "node")
|
||||
.map((e) => ({
|
||||
name: typeof e.name === "string" ? e.name : "",
|
||||
online: e.online === 1 || e.online === true,
|
||||
local: e.local === 1 || e.local === true,
|
||||
ip: typeof e.ip === "string" && e.ip ? e.ip : null,
|
||||
}));
|
||||
|
||||
return {
|
||||
clustered: true,
|
||||
quorate: clusterEntry.quorate === 1 || clusterEntry.quorate === true,
|
||||
clusterName:
|
||||
typeof clusterEntry.name === "string" && clusterEntry.name
|
||||
? clusterEntry.name
|
||||
: null,
|
||||
nodes,
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { isSafeNodeName } from "../proxmox-shared.js";
|
||||
import {
|
||||
collectProxmoxNodeStatus,
|
||||
type ProxmoxNodeStatusResult,
|
||||
} from "./node-status-collector.js";
|
||||
import {
|
||||
collectProxmoxNodeNetwork,
|
||||
type ProxmoxNodeNetworkResult,
|
||||
} from "./node-network-collector.js";
|
||||
import {
|
||||
collectProxmoxGuestsSummary,
|
||||
type ProxmoxGuestsSummaryResult,
|
||||
} from "./guests-collector.js";
|
||||
import {
|
||||
collectProxmoxStorage,
|
||||
type ProxmoxStorageResult,
|
||||
} from "./storage-collector.js";
|
||||
import {
|
||||
collectProxmoxClusterHealth,
|
||||
type ProxmoxClusterHealthResult,
|
||||
} from "./cluster-health-collector.js";
|
||||
|
||||
export interface ProxmoxStatsSnapshot {
|
||||
node: ProxmoxNodeStatusResult;
|
||||
network: ProxmoxNodeNetworkResult;
|
||||
guests: ProxmoxGuestsSummaryResult;
|
||||
storage: ProxmoxStorageResult;
|
||||
cluster: ProxmoxClusterHealthResult;
|
||||
lastChecked: string;
|
||||
}
|
||||
|
||||
async function resolveNodeName(
|
||||
client: Client,
|
||||
configuredNodeName: string | null | undefined,
|
||||
): Promise<string> {
|
||||
if (configuredNodeName) {
|
||||
// An explicitly configured name is validated and used as-is - an unsafe
|
||||
// value is rejected outright rather than silently falling back to
|
||||
// auto-detection, which would mask a misconfigured (or malicious) override.
|
||||
if (!isSafeNodeName(configuredNodeName)) {
|
||||
throw new Error("Unable to determine a valid Proxmox node name");
|
||||
}
|
||||
return configuredNodeName;
|
||||
}
|
||||
|
||||
const { stdout } = await execCommand(client, "hostname", 10000);
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
export async function collectProxmoxStats(
|
||||
client: Client,
|
||||
configuredNodeName: string | null | undefined,
|
||||
): Promise<ProxmoxStatsSnapshot> {
|
||||
const pveshCheck = await execCommand(
|
||||
client,
|
||||
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
|
||||
10000,
|
||||
);
|
||||
if (pveshCheck.stdout.trim() !== "ok") {
|
||||
throw new Error("pvesh not found — is this a Proxmox node?");
|
||||
}
|
||||
|
||||
const nodeName = await resolveNodeName(client, configuredNodeName);
|
||||
if (!isSafeNodeName(nodeName)) {
|
||||
throw new Error("Unable to determine a valid Proxmox node name");
|
||||
}
|
||||
|
||||
const [node, network, guests, storage, cluster] = await Promise.all([
|
||||
collectProxmoxNodeStatus(client, nodeName),
|
||||
collectProxmoxNodeNetwork(client, nodeName),
|
||||
collectProxmoxGuestsSummary(client, nodeName),
|
||||
collectProxmoxStorage(client, nodeName),
|
||||
collectProxmoxClusterHealth(client),
|
||||
]);
|
||||
|
||||
return {
|
||||
node,
|
||||
network,
|
||||
guests,
|
||||
storage,
|
||||
cluster,
|
||||
lastChecked: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
|
||||
/** Runs a command over an already-open ssh2 client and collects its output. */
|
||||
export function execCommand(
|
||||
client: Client,
|
||||
command: string,
|
||||
timeoutMs = 30000,
|
||||
): Promise<{
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
code: number | null;
|
||||
}> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let stream: ClientChannel | null = null;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(new Error(`Command timeout after ${timeoutMs}ms: ${command}`));
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
const cleanup = () => {
|
||||
clearTimeout(timeout);
|
||||
if (stream) {
|
||||
try {
|
||||
stream.removeAllListeners();
|
||||
if (stream.stderr) {
|
||||
stream.stderr.removeAllListeners();
|
||||
}
|
||||
stream.destroy();
|
||||
} catch {
|
||||
// expected - cleanup errors ignored
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
client.exec(command, { pty: false }, (err, _stream) => {
|
||||
if (err) {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
stream = _stream;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let exitCode: number | null = null;
|
||||
|
||||
stream
|
||||
.on("close", (code: number | undefined) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
exitCode = typeof code === "number" ? code : null;
|
||||
cleanup();
|
||||
resolve({ stdout, stderr, code: exitCode });
|
||||
}
|
||||
})
|
||||
.on("data", (data: Buffer) => {
|
||||
stdout += data.toString("utf8");
|
||||
})
|
||||
.on("error", (streamErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(streamErr);
|
||||
}
|
||||
});
|
||||
|
||||
if (stream.stderr) {
|
||||
stream.stderr
|
||||
.on("data", (data: Buffer) => {
|
||||
stderr += data.toString("utf8");
|
||||
})
|
||||
.on("error", (stderrErr: Error) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(stderrErr);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function toFixedNum(
|
||||
n: number | null | undefined,
|
||||
digits = 2,
|
||||
): number | null {
|
||||
if (typeof n !== "number" || !Number.isFinite(n)) return null;
|
||||
return Number(n.toFixed(digits));
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
import { isSafeNodeName } from "../proxmox-shared.js";
|
||||
|
||||
export interface ProxmoxGuestSummaryEntry {
|
||||
vmid: number;
|
||||
name: string;
|
||||
type: "qemu" | "lxc";
|
||||
status: string;
|
||||
cpuPercent: number | null;
|
||||
memPercent: number | null;
|
||||
memUsedGiB: number | null;
|
||||
memTotalGiB: number | null;
|
||||
diskPercent: number | null;
|
||||
diskUsedGiB: number | null;
|
||||
diskTotalGiB: number | null;
|
||||
uptimeSeconds: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxGuestsSummaryResult {
|
||||
guests: ProxmoxGuestSummaryEntry[];
|
||||
counts: { running: number; stopped: number; total: number };
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: ProxmoxGuestsSummaryResult = {
|
||||
guests: [],
|
||||
counts: { running: 0, stopped: 0, total: 0 },
|
||||
};
|
||||
|
||||
function bytesToGiB(bytes: number): number {
|
||||
return bytes / (1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export async function collectProxmoxGuestsSummary(
|
||||
client: Client,
|
||||
nodeName: string,
|
||||
): Promise<ProxmoxGuestsSummaryResult> {
|
||||
if (!isSafeNodeName(nodeName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
client,
|
||||
"pvesh get /cluster/resources --output-format json",
|
||||
25000,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const resources = JSON.parse(stdout);
|
||||
if (!Array.isArray(resources)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const guests: ProxmoxGuestSummaryEntry[] = [];
|
||||
for (const r of resources as Array<Record<string, unknown>>) {
|
||||
const type = r.type;
|
||||
if (type !== "qemu" && type !== "lxc") continue;
|
||||
if (r.node !== nodeName) continue;
|
||||
if (r.template === true || r.template === 1) continue;
|
||||
|
||||
const cpuFraction = typeof r.cpu === "number" ? r.cpu : null;
|
||||
const mem = typeof r.mem === "number" ? r.mem : null;
|
||||
const maxmem = typeof r.maxmem === "number" ? r.maxmem : null;
|
||||
const memPercent =
|
||||
mem !== null && maxmem !== null && maxmem > 0
|
||||
? Math.max(0, Math.min(100, (mem / maxmem) * 100))
|
||||
: null;
|
||||
|
||||
const disk = typeof r.disk === "number" ? r.disk : null;
|
||||
const maxdisk = typeof r.maxdisk === "number" ? r.maxdisk : null;
|
||||
// QEMU guests without a running agent report maxdisk: 0 - a false 0%
|
||||
// is worse than an honest "unknown", so treat that as no data at all.
|
||||
const hasDiskData = maxdisk !== null && maxdisk > 0;
|
||||
const diskPercent =
|
||||
hasDiskData && disk !== null
|
||||
? Math.max(0, Math.min(100, (disk / maxdisk) * 100))
|
||||
: null;
|
||||
|
||||
guests.push({
|
||||
vmid: typeof r.vmid === "number" ? r.vmid : Number(r.vmid),
|
||||
name:
|
||||
typeof r.name === "string" && r.name ? r.name : String(r.vmid ?? ""),
|
||||
type,
|
||||
status: typeof r.status === "string" ? r.status : "unknown",
|
||||
cpuPercent: toFixedNum(
|
||||
cpuFraction !== null ? cpuFraction * 100 : null,
|
||||
0,
|
||||
),
|
||||
memPercent: toFixedNum(memPercent, 0),
|
||||
memUsedGiB: mem !== null ? toFixedNum(bytesToGiB(mem), 2) : null,
|
||||
memTotalGiB: maxmem !== null ? toFixedNum(bytesToGiB(maxmem), 2) : null,
|
||||
diskPercent: toFixedNum(diskPercent, 0),
|
||||
diskUsedGiB:
|
||||
hasDiskData && disk !== null ? toFixedNum(bytesToGiB(disk), 2) : null,
|
||||
diskTotalGiB: hasDiskData ? toFixedNum(bytesToGiB(maxdisk), 2) : null,
|
||||
uptimeSeconds: typeof r.uptime === "number" ? r.uptime : null,
|
||||
});
|
||||
}
|
||||
|
||||
const running = guests.filter((g) => g.status === "running").length;
|
||||
const stopped = guests.filter((g) => g.status !== "running").length;
|
||||
|
||||
return {
|
||||
guests,
|
||||
counts: { running, stopped, total: guests.length },
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand } from "./common-utils.js";
|
||||
import { isSafeNodeName } from "../proxmox-shared.js";
|
||||
|
||||
export interface ProxmoxNodeNetworkInterface {
|
||||
name: string;
|
||||
ip: string | null;
|
||||
state: string | null;
|
||||
rxBytes: string | null;
|
||||
txBytes: string | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxNodeNetworkResult {
|
||||
interfaces: ProxmoxNodeNetworkInterface[];
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: ProxmoxNodeNetworkResult = { interfaces: [] };
|
||||
|
||||
async function collectFromProcNetDev(
|
||||
client: Client,
|
||||
): Promise<ProxmoxNodeNetworkResult> {
|
||||
const interfaces: ProxmoxNodeNetworkInterface[] = [];
|
||||
|
||||
try {
|
||||
const [addrOut, stateOut, procNetOut] = await Promise.all([
|
||||
execCommand(
|
||||
client,
|
||||
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
|
||||
),
|
||||
execCommand(
|
||||
client,
|
||||
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
|
||||
),
|
||||
execCommand(client, "cat /proc/net/dev"),
|
||||
]);
|
||||
|
||||
const ifMap = new Map<
|
||||
string,
|
||||
{ ip: string | null; state: string | null }
|
||||
>();
|
||||
for (const line of addrOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const name = parts[0];
|
||||
const ip = parts[1].split("/")[0];
|
||||
if (!ifMap.has(name)) ifMap.set(name, { ip, state: null });
|
||||
}
|
||||
}
|
||||
for (const line of stateOut.stdout
|
||||
.split("\n")
|
||||
.map((l) => l.trim())
|
||||
.filter(Boolean)) {
|
||||
const parts = line.split(/\s+/);
|
||||
if (parts.length >= 2) {
|
||||
const existing = ifMap.get(parts[0]);
|
||||
if (existing) existing.state = parts[1];
|
||||
}
|
||||
}
|
||||
|
||||
const rxTxMap = new Map<string, { rx: string; tx: string }>();
|
||||
for (const line of procNetOut.stdout.split("\n").slice(2)) {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
if (parts.length >= 10) {
|
||||
const ifName = parts[0].replace(":", "");
|
||||
rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] });
|
||||
}
|
||||
}
|
||||
|
||||
for (const [name, data] of ifMap.entries()) {
|
||||
const rxTx = rxTxMap.get(name);
|
||||
interfaces.push({
|
||||
name,
|
||||
ip: data.ip,
|
||||
state: data.state,
|
||||
rxBytes: rxTx?.rx ?? null,
|
||||
txBytes: rxTx?.tx ?? null,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
}
|
||||
|
||||
export async function collectProxmoxNodeNetwork(
|
||||
client: Client,
|
||||
nodeName: string,
|
||||
): Promise<ProxmoxNodeNetworkResult> {
|
||||
if (!isSafeNodeName(nodeName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${nodeName}/netstat --output-format json`,
|
||||
15000,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return collectFromProcNetDev(client);
|
||||
}
|
||||
|
||||
const data = JSON.parse(stdout);
|
||||
if (!Array.isArray(data)) {
|
||||
return collectFromProcNetDev(client);
|
||||
}
|
||||
|
||||
const interfaces: ProxmoxNodeNetworkInterface[] = data
|
||||
.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
!!entry && typeof entry === "object",
|
||||
)
|
||||
.map((entry) => ({
|
||||
name:
|
||||
typeof entry.dev === "string" ? entry.dev : String(entry.dev ?? ""),
|
||||
ip: null,
|
||||
state: null,
|
||||
rxBytes:
|
||||
typeof entry.in === "number"
|
||||
? String(entry.in)
|
||||
: typeof entry.received === "number"
|
||||
? String(entry.received)
|
||||
: null,
|
||||
txBytes:
|
||||
typeof entry.out === "number"
|
||||
? String(entry.out)
|
||||
: typeof entry.transmitted === "number"
|
||||
? String(entry.transmitted)
|
||||
: null,
|
||||
}))
|
||||
.filter((iface) => iface.name && iface.name !== "lo");
|
||||
|
||||
if (interfaces.length === 0) {
|
||||
return collectFromProcNetDev(client);
|
||||
}
|
||||
|
||||
return { interfaces };
|
||||
} catch {
|
||||
return collectFromProcNetDev(client);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
import { isSafeNodeName } from "../proxmox-shared.js";
|
||||
|
||||
export interface ProxmoxNodeStatusResult {
|
||||
cpu: {
|
||||
percent: number | null;
|
||||
cores: number | null;
|
||||
load: [number, number, number] | null;
|
||||
};
|
||||
memory: {
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
};
|
||||
disk: {
|
||||
percent: number | null;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
};
|
||||
uptime: {
|
||||
seconds: number | null;
|
||||
formatted: string | null;
|
||||
};
|
||||
system: {
|
||||
hostname: string | null;
|
||||
kernel: string | null;
|
||||
pveVersion: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: ProxmoxNodeStatusResult = {
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedGiB: null, totalGiB: null },
|
||||
uptime: { seconds: null, formatted: null },
|
||||
system: { hostname: null, kernel: null, pveVersion: null },
|
||||
};
|
||||
|
||||
function bytesToGiB(bytes: number): number {
|
||||
return bytes / (1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${days}d ${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
export async function collectProxmoxNodeStatus(
|
||||
client: Client,
|
||||
nodeName: string,
|
||||
): Promise<ProxmoxNodeStatusResult> {
|
||||
if (!isSafeNodeName(nodeName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${nodeName}/status --output-format json`,
|
||||
25000,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const data = JSON.parse(stdout) as Record<string, unknown>;
|
||||
|
||||
const cpuFraction = typeof data.cpu === "number" ? data.cpu : null;
|
||||
const cpuinfo = (data.cpuinfo as Record<string, unknown>) || {};
|
||||
const cores = typeof cpuinfo.cores === "number" ? cpuinfo.cores : null;
|
||||
const loadavgRaw = data.loadavg;
|
||||
let load: [number, number, number] | null = null;
|
||||
if (Array.isArray(loadavgRaw) && loadavgRaw.length >= 3) {
|
||||
const parsed = loadavgRaw
|
||||
.slice(0, 3)
|
||||
.map((v) => Number(v))
|
||||
.map((v) => (Number.isFinite(v) ? v : 0));
|
||||
load = parsed as [number, number, number];
|
||||
}
|
||||
|
||||
const memory = (data.memory as Record<string, unknown>) || {};
|
||||
const memUsed = typeof memory.used === "number" ? memory.used : null;
|
||||
const memTotal = typeof memory.total === "number" ? memory.total : null;
|
||||
const memPercent =
|
||||
memUsed !== null && memTotal !== null && memTotal > 0
|
||||
? Math.max(0, Math.min(100, (memUsed / memTotal) * 100))
|
||||
: null;
|
||||
|
||||
const rootfs = (data.rootfs as Record<string, unknown>) || {};
|
||||
const diskUsed = typeof rootfs.used === "number" ? rootfs.used : null;
|
||||
const diskTotal = typeof rootfs.total === "number" ? rootfs.total : null;
|
||||
const diskPercent =
|
||||
diskUsed !== null && diskTotal !== null && diskTotal > 0
|
||||
? Math.max(0, Math.min(100, (diskUsed / diskTotal) * 100))
|
||||
: null;
|
||||
|
||||
const uptimeSeconds = typeof data.uptime === "number" ? data.uptime : null;
|
||||
|
||||
return {
|
||||
cpu: {
|
||||
percent: toFixedNum(cpuFraction !== null ? cpuFraction * 100 : null, 0),
|
||||
cores,
|
||||
load,
|
||||
},
|
||||
memory: {
|
||||
percent: toFixedNum(memPercent, 0),
|
||||
usedGiB: memUsed !== null ? toFixedNum(bytesToGiB(memUsed), 2) : null,
|
||||
totalGiB:
|
||||
memTotal !== null ? toFixedNum(bytesToGiB(memTotal), 2) : null,
|
||||
},
|
||||
disk: {
|
||||
percent: toFixedNum(diskPercent, 0),
|
||||
usedGiB: diskUsed !== null ? toFixedNum(bytesToGiB(diskUsed), 2) : null,
|
||||
totalGiB:
|
||||
diskTotal !== null ? toFixedNum(bytesToGiB(diskTotal), 2) : null,
|
||||
},
|
||||
uptime: {
|
||||
seconds: uptimeSeconds,
|
||||
formatted: uptimeSeconds !== null ? formatUptime(uptimeSeconds) : null,
|
||||
},
|
||||
system: {
|
||||
hostname:
|
||||
typeof data.hostname === "string" && data.hostname
|
||||
? data.hostname
|
||||
: null,
|
||||
kernel:
|
||||
typeof data.kversion === "string" && data.kversion
|
||||
? data.kversion
|
||||
: null,
|
||||
pveVersion:
|
||||
typeof data.pveversion === "string" && data.pveversion
|
||||
? data.pveversion
|
||||
: null,
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import type { Client } from "ssh2";
|
||||
import { execCommand, toFixedNum } from "./common-utils.js";
|
||||
import { isSafeNodeName } from "../proxmox-shared.js";
|
||||
|
||||
export interface ProxmoxStoragePoolEntry {
|
||||
name: string;
|
||||
type: string;
|
||||
active: boolean;
|
||||
enabled: boolean;
|
||||
usedGiB: number | null;
|
||||
totalGiB: number | null;
|
||||
availGiB: number | null;
|
||||
percent: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxStorageResult {
|
||||
pools: ProxmoxStoragePoolEntry[];
|
||||
}
|
||||
|
||||
const EMPTY_RESULT: ProxmoxStorageResult = { pools: [] };
|
||||
|
||||
function bytesToGiB(bytes: number): number {
|
||||
return bytes / (1024 * 1024 * 1024);
|
||||
}
|
||||
|
||||
export async function collectProxmoxStorage(
|
||||
client: Client,
|
||||
nodeName: string,
|
||||
): Promise<ProxmoxStorageResult> {
|
||||
if (!isSafeNodeName(nodeName)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout, code } = await execCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${nodeName}/storage --output-format json`,
|
||||
25000,
|
||||
);
|
||||
if (code !== 0) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const data = JSON.parse(stdout);
|
||||
if (!Array.isArray(data)) {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
|
||||
const pools: ProxmoxStoragePoolEntry[] = (
|
||||
data as Array<Record<string, unknown>>
|
||||
).map((entry) => {
|
||||
const used = typeof entry.used === "number" ? entry.used : null;
|
||||
const total = typeof entry.total === "number" ? entry.total : null;
|
||||
const avail = typeof entry.avail === "number" ? entry.avail : null;
|
||||
const percent =
|
||||
used !== null && total !== null && total > 0
|
||||
? Math.max(0, Math.min(100, (used / total) * 100))
|
||||
: null;
|
||||
|
||||
return {
|
||||
name: typeof entry.storage === "string" ? entry.storage : "",
|
||||
type: typeof entry.type === "string" ? entry.type : "unknown",
|
||||
active: entry.active === 1 || entry.active === true,
|
||||
enabled: entry.enabled === 1 || entry.enabled === true,
|
||||
usedGiB: used !== null ? toFixedNum(bytesToGiB(used), 2) : null,
|
||||
totalGiB: total !== null ? toFixedNum(bytesToGiB(total), 2) : null,
|
||||
availGiB: avail !== null ? toFixedNum(bytesToGiB(avail), 2) : null,
|
||||
percent: toFixedNum(percent, 0),
|
||||
};
|
||||
});
|
||||
|
||||
return { pools };
|
||||
} catch {
|
||||
return EMPTY_RESULT;
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,27 @@
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import express, { type Router } from "express";
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import {
|
||||
type AuthenticatedRequest,
|
||||
type SSHHost,
|
||||
} from "../../../../src/types/index.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import type {
|
||||
PluginHostCreateInput,
|
||||
PluginHostRecord,
|
||||
PluginHostUpdateInput,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { connectSsh } from "./ssh.js";
|
||||
import { pluginCtx } from "./plugin-ctx.js";
|
||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
|
||||
import {
|
||||
parseProxmoxJumpHosts,
|
||||
serializeProxmoxJumpHosts,
|
||||
} from "./proxmox-jump-hosts.js";
|
||||
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
|
||||
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||
import { isSafeNodeName } from "./proxmox-shared.js";
|
||||
|
||||
const router = express.Router();
|
||||
const proxmoxLogger = logger;
|
||||
const runningSyncs = new Set<string>();
|
||||
|
||||
const MIN_SYNC_INTERVAL_MINUTES = 5;
|
||||
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
|
||||
|
||||
const authManager = AuthManager.getInstance();
|
||||
const authenticateJWT = authManager.createAuthMiddleware();
|
||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
||||
|
||||
// Helpers
|
||||
|
||||
function execCommand(
|
||||
@@ -189,6 +180,9 @@ type ProxmoxGuest = {
|
||||
enableDocker: boolean;
|
||||
};
|
||||
|
||||
/** The resolved host ctx.ssh.connect hands back, plus the sudo secret proxmox needs for pvesh. */
|
||||
type PluginSshHostWithSudo = PluginHostRecord & { sudoPassword?: string };
|
||||
|
||||
type ProxmoxSource = {
|
||||
source: "proxmox";
|
||||
sourceHostId: number;
|
||||
@@ -285,44 +279,30 @@ async function discoverProxmoxGuestsForHost(
|
||||
parsedHostId: number,
|
||||
onProgress?: (done: number, total: number) => void,
|
||||
): Promise<{
|
||||
host: SSHHost;
|
||||
host: PluginSshHostWithSudo;
|
||||
guests: ProxmoxGuest[];
|
||||
credentialId: number | null;
|
||||
defaultCredentialId: number | null;
|
||||
jumpHosts: unknown[] | null;
|
||||
config: ReturnType<typeof parseProxmoxConfig>;
|
||||
}> {
|
||||
if (!DataCrypto.canUserAccessData(userId)) {
|
||||
const error = new Error("Session expired — please log in again");
|
||||
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
|
||||
throw error;
|
||||
}
|
||||
|
||||
const resolvedHost = await resolveHostById(parsedHostId, userId);
|
||||
if (!resolvedHost) {
|
||||
const error = new Error("Host not found");
|
||||
(error as Error & { status?: number }).status = 404;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const host = resolvedHost as SSHHost;
|
||||
const proxmoxCfgRaw = parseJsonObject(host.proxmoxConfig);
|
||||
const ctx = pluginCtx();
|
||||
const proxmoxCfgRaw = await ctx.settings.getHost(
|
||||
parsedHostId,
|
||||
"proxmoxConfig",
|
||||
);
|
||||
const config = parseProxmoxConfig(proxmoxCfgRaw);
|
||||
|
||||
const hostCredentialId = host.credentialId ?? null;
|
||||
|
||||
const { client } = await connectSsh(host as unknown as PluginSshHost, {
|
||||
const { client, host } = await connectSsh(parsedHostId, {
|
||||
purpose: "proxmox",
|
||||
timeoutMs: 35000,
|
||||
overrides: { tryKeyboard: false, readyTimeout: 30000 },
|
||||
});
|
||||
const hostWithSudo = host as PluginSshHostWithSudo;
|
||||
const hostCredentialId = (hostWithSudo.credentialId as number | null) ?? null;
|
||||
|
||||
try {
|
||||
proxmoxLogger.info("Proxmox discovery SSH connection established", {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
ctx.log.info("Proxmox discovery SSH connection established");
|
||||
|
||||
const pveshCheck = await execCommand(
|
||||
client,
|
||||
@@ -337,7 +317,7 @@ async function discoverProxmoxGuestsForHost(
|
||||
const resourcesJson = await execPveshCommand(
|
||||
client,
|
||||
"pvesh get /cluster/resources --output-format json 2>/dev/null",
|
||||
host.sudoPassword,
|
||||
hostWithSudo.sudoPassword,
|
||||
);
|
||||
|
||||
let resources: Array<Record<string, unknown>>;
|
||||
@@ -366,11 +346,9 @@ async function discoverProxmoxGuestsForHost(
|
||||
if (r.template) continue;
|
||||
const node = r.node as string;
|
||||
if (!isSafeNodeName(node)) {
|
||||
proxmoxLogger.warn("Skipping guest with unsafe node name", {
|
||||
operation: "proxmox_discover",
|
||||
node,
|
||||
vmid: r.vmid,
|
||||
});
|
||||
ctx.log.warn(
|
||||
`Skipping guest with unsafe node name: ${node} (vmid ${r.vmid})`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
guestBases.push({
|
||||
@@ -389,7 +367,7 @@ async function discoverProxmoxGuestsForHost(
|
||||
const cfgJson = await execPveshCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
||||
host.sudoPassword,
|
||||
hostWithSudo.sudoPassword,
|
||||
25000,
|
||||
);
|
||||
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
||||
@@ -404,7 +382,7 @@ async function discoverProxmoxGuestsForHost(
|
||||
const ifRaw = await execPveshCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
|
||||
host.sudoPassword,
|
||||
hostWithSudo.sudoPassword,
|
||||
12000,
|
||||
);
|
||||
const data = JSON.parse(ifRaw);
|
||||
@@ -437,7 +415,7 @@ async function discoverProxmoxGuestsForHost(
|
||||
const ifJson = await execPveshCommand(
|
||||
client,
|
||||
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
|
||||
host.sudoPassword,
|
||||
hostWithSudo.sudoPassword,
|
||||
12000,
|
||||
);
|
||||
const data = JSON.parse(ifJson);
|
||||
@@ -508,21 +486,16 @@ async function discoverProxmoxGuestsForHost(
|
||||
enableDocker: matchesAny(g.name, config.dockerPatterns),
|
||||
}));
|
||||
|
||||
proxmoxLogger.info("Proxmox discovery completed", {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
guestCount: guests.length,
|
||||
});
|
||||
ctx.log.info(
|
||||
`Proxmox discovery completed for host ${parsedHostId}: ${guests.length} guest(s)`,
|
||||
);
|
||||
|
||||
return {
|
||||
host,
|
||||
host: hostWithSudo,
|
||||
guests,
|
||||
credentialId: hostCredentialId,
|
||||
defaultCredentialId: config.defaultCredentialId,
|
||||
jumpHosts: parseProxmoxJumpHosts(
|
||||
(host as unknown as { jumpHosts?: unknown }).jumpHosts,
|
||||
),
|
||||
jumpHosts: parseProxmoxJumpHosts(hostWithSudo.jumpHosts),
|
||||
config,
|
||||
};
|
||||
} finally {
|
||||
@@ -538,6 +511,7 @@ async function syncProxmoxHost(
|
||||
userId: string,
|
||||
sourceHostId: number,
|
||||
): Promise<ProxmoxSyncResult> {
|
||||
const ctx = pluginCtx();
|
||||
const lockKey = `${userId}:${sourceHostId}`;
|
||||
if (runningSyncs.has(lockKey)) {
|
||||
return {
|
||||
@@ -570,10 +544,10 @@ async function syncProxmoxHost(
|
||||
);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const existingHosts =
|
||||
(await createCurrentHostRepository().listDecryptedByUserId(
|
||||
userId,
|
||||
)) as unknown as Record<string, unknown>[];
|
||||
const existingHosts = (await ctx.hosts.listOwned()) as unknown as Record<
|
||||
string,
|
||||
unknown
|
||||
>[];
|
||||
const existingBySource = new Map<string, Record<string, unknown>>();
|
||||
for (const host of existingHosts) {
|
||||
const source = getProxmoxSource(host);
|
||||
@@ -647,10 +621,9 @@ async function syncProxmoxHost(
|
||||
update.credentialId = importAuth.credentialId;
|
||||
update.overrideCredentialUsername = false;
|
||||
}
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
await ctx.hosts.update(
|
||||
existing.id as number,
|
||||
update,
|
||||
update as PluginHostUpdateInput,
|
||||
);
|
||||
result.updated++;
|
||||
continue;
|
||||
@@ -665,9 +638,8 @@ async function syncProxmoxHost(
|
||||
enableRdp: connectionType === "rdp",
|
||||
});
|
||||
|
||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
||||
await ctx.hosts.create({
|
||||
...update,
|
||||
userId,
|
||||
createdAt: now,
|
||||
pin: false,
|
||||
authType: connectionType === "rdp" ? "password" : importAuth.authType,
|
||||
@@ -709,7 +681,7 @@ async function syncProxmoxHost(
|
||||
showTunnelInSidebar: 0,
|
||||
showDockerInSidebar: 0,
|
||||
showServerStatsInSidebar: 0,
|
||||
});
|
||||
} as unknown as PluginHostCreateInput);
|
||||
result.created++;
|
||||
}
|
||||
|
||||
@@ -720,8 +692,7 @@ async function syncProxmoxHost(
|
||||
const source = getProxmoxSource(existing);
|
||||
if (!source) continue;
|
||||
const missingSince = source.missingSince || now;
|
||||
await createCurrentHostRepository().updateEncryptedForUser(
|
||||
userId,
|
||||
await ctx.hosts.update(
|
||||
existing.id as number,
|
||||
{
|
||||
tags: mergeTags(existing.tags, ["proxmox-missing"]),
|
||||
@@ -733,13 +704,13 @@ async function syncProxmoxHost(
|
||||
},
|
||||
}),
|
||||
updatedAt: now,
|
||||
},
|
||||
} as PluginHostUpdateInput,
|
||||
);
|
||||
result.markedMissing++;
|
||||
}
|
||||
}
|
||||
|
||||
await writeSyncStatus(userId, sourceHostId, {
|
||||
await writeSyncStatus(sourceHostId, {
|
||||
lastSyncAt: startedAt,
|
||||
lastSyncStatus: "success",
|
||||
lastSyncError: null,
|
||||
@@ -750,7 +721,7 @@ async function syncProxmoxHost(
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
result.errors.push(message);
|
||||
await writeSyncStatus(userId, sourceHostId, {
|
||||
await writeSyncStatus(sourceHostId, {
|
||||
lastSyncAt: startedAt,
|
||||
lastSyncStatus: "error",
|
||||
lastSyncError: message,
|
||||
@@ -763,22 +734,46 @@ async function syncProxmoxHost(
|
||||
}
|
||||
|
||||
async function writeSyncStatus(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
patch: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
const row = await hostRepository.findByIdForUser(userId, hostId);
|
||||
if (!row) return;
|
||||
const config = parseJsonObject(row.proxmoxConfig);
|
||||
await hostRepository.updateForUser(userId, hostId, {
|
||||
proxmoxConfig: JSON.stringify({ ...config, ...patch }),
|
||||
});
|
||||
const ctx = pluginCtx();
|
||||
const config = parseJsonObject(
|
||||
await ctx.settings.getHost(hostId, "proxmoxConfig"),
|
||||
);
|
||||
await ctx.settings.setHost(hostId, "proxmoxConfig", { ...config, ...patch });
|
||||
}
|
||||
|
||||
router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
|
||||
/**
|
||||
* @openapi
|
||||
* /proxmox/sync:
|
||||
* post:
|
||||
* summary: Sync Proxmox guests for a host
|
||||
* description: Re-runs discovery for a Proxmox node and creates, updates or marks missing the imported guest hosts.
|
||||
* tags: [Proxmox]
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [hostId]
|
||||
* properties:
|
||||
* hostId:
|
||||
* type: number
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Sync result.
|
||||
* 400:
|
||||
* description: Missing or invalid hostId.
|
||||
* 500:
|
||||
* description: Sync failed.
|
||||
*/
|
||||
router.post("/sync", async (req, res) => {
|
||||
const { hostId } = req.body as { hostId?: unknown };
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const userId = pluginCtx().currentActor()!;
|
||||
|
||||
const parsedHostId = Number(hostId);
|
||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
@@ -790,30 +785,48 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
|
||||
return res.json(result);
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
const status =
|
||||
(err as Error & { code?: string; status?: number }).code ===
|
||||
"SESSION_EXPIRED"
|
||||
? 401
|
||||
: (err as Error & { status?: number }).status || 500;
|
||||
proxmoxLogger.error("Proxmox sync failed", err, {
|
||||
operation: "proxmox_sync",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
const status = (err as Error & { status?: number }).status || 500;
|
||||
pluginCtx().log.error(
|
||||
`Proxmox sync failed for host ${parsedHostId}`,
|
||||
err as Error,
|
||||
);
|
||||
return res.status(status).json({ error: `Sync failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
async function runDueProxmoxAutoSyncs(): Promise<void> {
|
||||
const ctx = pluginCtx();
|
||||
try {
|
||||
const rows = await createCurrentHostRepository().listProxmoxEnabled();
|
||||
const {
|
||||
createCurrentPluginSettingsRepository,
|
||||
createCurrentHostRepository,
|
||||
} =
|
||||
await import("../../../../src/backend/database/repositories/factory.js");
|
||||
const enabledRows = await createCurrentPluginSettingsRepository().listByKey(
|
||||
"proxmox",
|
||||
"host",
|
||||
"enableProxmox",
|
||||
);
|
||||
const hostRepository = createCurrentHostRepository();
|
||||
|
||||
const now = Date.now();
|
||||
for (const row of rows) {
|
||||
const configRaw = parseJsonObject(row.proxmoxConfig);
|
||||
for (const row of enabledRows) {
|
||||
if (row.value !== "true") continue;
|
||||
const hostId = Number(row.scopeId);
|
||||
if (!Number.isInteger(hostId)) continue;
|
||||
|
||||
const host = await hostRepository.findById(hostId);
|
||||
if (!host) continue;
|
||||
|
||||
const configRow = await createCurrentPluginSettingsRepository().get(
|
||||
"proxmox",
|
||||
"host",
|
||||
String(hostId),
|
||||
"proxmoxConfig",
|
||||
);
|
||||
const configRaw = configRow?.value ? JSON.parse(configRow.value) : {};
|
||||
const config = parseProxmoxConfig(configRaw);
|
||||
if (!config.autoSyncEnabled) continue;
|
||||
if (!DataCrypto.canUserAccessData(row.userId)) continue;
|
||||
|
||||
const lastSyncAt =
|
||||
typeof configRaw.lastSyncAt === "string"
|
||||
@@ -822,18 +835,17 @@ async function runDueProxmoxAutoSyncs(): Promise<void> {
|
||||
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
|
||||
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
|
||||
|
||||
syncProxmoxHost(row.userId, row.id).catch((error) => {
|
||||
proxmoxLogger.error("Scheduled Proxmox sync failed", error, {
|
||||
operation: "proxmox_auto_sync",
|
||||
hostId: row.id,
|
||||
userId: row.userId,
|
||||
ctx
|
||||
.asUser(host.userId, () => syncProxmoxHost(host.userId, hostId))
|
||||
.catch((error) => {
|
||||
ctx.log.error(
|
||||
`Scheduled Proxmox sync failed for host ${hostId}`,
|
||||
error as Error,
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, {
|
||||
operation: "proxmox_auto_sync_scan",
|
||||
});
|
||||
ctx.log.error("Failed to scan Proxmox auto sync jobs", error as Error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -915,127 +927,109 @@ let proxmoxAutoSyncStartupTimer: NodeJS.Timeout | undefined;
|
||||
* 500:
|
||||
* description: Discovery failed.
|
||||
*/
|
||||
router.get(
|
||||
"/discover/stream",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req, res) => {
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
|
||||
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||
router.get("/discover/stream", async (req, res) => {
|
||||
const userId = pluginCtx().currentActor()!;
|
||||
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
|
||||
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
let closed = false;
|
||||
const send = (event: string, data: unknown) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
|
||||
res.writeHead(200, {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-store, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
});
|
||||
res.flushHeaders?.();
|
||||
|
||||
let closed = false;
|
||||
const send = (event: string, data: unknown) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
const heartbeat = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
}
|
||||
}, 15000);
|
||||
req.on("close", () => {
|
||||
};
|
||||
const heartbeat = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
res.write(": keepalive\n\n");
|
||||
} catch {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
}
|
||||
}, 15000);
|
||||
req.on("close", () => {
|
||||
closed = true;
|
||||
clearInterval(heartbeat);
|
||||
});
|
||||
|
||||
try {
|
||||
const discovery = await discoverProxmoxGuestsForHost(
|
||||
userId,
|
||||
parsedHostId,
|
||||
(done, total) => send("progress", { done, total }),
|
||||
);
|
||||
send("result", {
|
||||
guests: discovery.guests,
|
||||
credentialId: discovery.credentialId,
|
||||
defaultCredentialId: discovery.defaultCredentialId,
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
send("fail", { message });
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
if (!closed) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ignore end errors
|
||||
}
|
||||
try {
|
||||
const discovery = await discoverProxmoxGuestsForHost(
|
||||
userId,
|
||||
parsedHostId,
|
||||
(done, total) => send("progress", { done, total }),
|
||||
);
|
||||
send("result", {
|
||||
guests: discovery.guests,
|
||||
credentialId: discovery.credentialId,
|
||||
defaultCredentialId: discovery.defaultCredentialId,
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
pluginCtx().log.error(
|
||||
`Proxmox discovery (stream) failed for host ${parsedHostId}`,
|
||||
err as Error,
|
||||
);
|
||||
send("fail", { message });
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
if (!closed) {
|
||||
try {
|
||||
res.end();
|
||||
} catch {
|
||||
// ignore end errors
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
router.post(
|
||||
"/discover",
|
||||
authenticateJWT,
|
||||
requireDataAccess,
|
||||
async (req, res) => {
|
||||
const { hostId } = req.body as { hostId?: unknown };
|
||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
||||
router.post("/discover", async (req, res) => {
|
||||
const { hostId } = req.body as { hostId?: unknown };
|
||||
const userId = pluginCtx().currentActor()!;
|
||||
|
||||
const parsedHostId = Number(hostId);
|
||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||
}
|
||||
const parsedHostId = Number(hostId);
|
||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||
}
|
||||
|
||||
try {
|
||||
const discovery = await discoverProxmoxGuestsForHost(
|
||||
userId,
|
||||
parsedHostId,
|
||||
);
|
||||
return res.json({
|
||||
guests: discovery.guests,
|
||||
credentialId: discovery.credentialId,
|
||||
defaultCredentialId: discovery.defaultCredentialId,
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
proxmoxLogger.error("Proxmox discovery failed", err, {
|
||||
operation: "proxmox_discover",
|
||||
hostId: parsedHostId,
|
||||
userId,
|
||||
});
|
||||
try {
|
||||
const discovery = await discoverProxmoxGuestsForHost(userId, parsedHostId);
|
||||
return res.json({
|
||||
guests: discovery.guests,
|
||||
credentialId: discovery.credentialId,
|
||||
defaultCredentialId: discovery.defaultCredentialId,
|
||||
jumpHosts: discovery.jumpHosts,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = getErrorMessage(err);
|
||||
pluginCtx().log.error(
|
||||
`Proxmox discovery failed for host ${parsedHostId}`,
|
||||
err as Error,
|
||||
);
|
||||
|
||||
const status =
|
||||
(err as Error & { code?: string; status?: number }).code ===
|
||||
"SESSION_EXPIRED"
|
||||
? 401
|
||||
: (err as Error & { status?: number }).status ||
|
||||
(message.includes("Authentication failed") ||
|
||||
message.includes("connect ECONNREFUSED") ||
|
||||
message.includes("connect ETIMEDOUT")
|
||||
? 422
|
||||
: 500);
|
||||
return res.status(status).json({ error: `Discovery failed: ${message}` });
|
||||
}
|
||||
},
|
||||
);
|
||||
const status =
|
||||
(err as Error & { status?: number }).status ||
|
||||
(message.includes("Authentication failed") ||
|
||||
message.includes("connect ECONNREFUSED") ||
|
||||
message.includes("connect ETIMEDOUT")
|
||||
? 422
|
||||
: 500);
|
||||
return res.status(status).json({ error: `Discovery failed: ${message}` });
|
||||
}
|
||||
});
|
||||
|
||||
/** Called from activate(). Mounts this router at /proxmox via the shared
|
||||
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
/**
|
||||
* Proxmox node stats: polling, viewer sessions and their routes.
|
||||
*
|
||||
* Moved here from host-metrics, which only ever hosted this because the
|
||||
* plugin split predates B5. host-metrics must not import proxmox code, so
|
||||
* this plugin owns its own polling manager, SSH connections and routes.
|
||||
*/
|
||||
|
||||
import type { Router } from "express";
|
||||
import express from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { pluginCtx } from "./plugin-ctx.js";
|
||||
import {
|
||||
ProxmoxPollingManager,
|
||||
parseProxmoxStatsConfig,
|
||||
type ProxmoxStatsPollableHost,
|
||||
} from "./proxmox-stats-polling.js";
|
||||
import type { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js";
|
||||
|
||||
const EMPTY_SNAPSHOT = {
|
||||
node: {
|
||||
cpu: { percent: null, cores: null, load: null },
|
||||
memory: { percent: null, usedGiB: null, totalGiB: null },
|
||||
disk: { percent: null, usedGiB: null, totalGiB: null },
|
||||
uptime: { seconds: null, formatted: null },
|
||||
system: { hostname: null, kernel: null, pveVersion: null },
|
||||
},
|
||||
network: { interfaces: [] },
|
||||
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
|
||||
storage: { pools: [] },
|
||||
cluster: { clustered: false },
|
||||
lastChecked: new Date(0).toISOString(),
|
||||
};
|
||||
|
||||
type PollableHost = ProxmoxStatsPollableHost & { enableProxmoxStats: boolean };
|
||||
|
||||
let pollingManager: ProxmoxPollingManager<PollableHost> | undefined;
|
||||
let router: Router | undefined;
|
||||
|
||||
async function fetchHostForPolling(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
userId: string,
|
||||
): Promise<PollableHost | undefined> {
|
||||
const enabled = await ctx.asUser(userId, () =>
|
||||
ctx.settings.getHost<boolean>(hostId, "enableProxmoxStats"),
|
||||
);
|
||||
if (!enabled) return undefined;
|
||||
const host = await ctx.asUser(userId, () => ctx.hosts.get(hostId));
|
||||
if (!host) return undefined;
|
||||
const proxmoxStatsConfig = await ctx.asUser(userId, () =>
|
||||
ctx.settings.getHost(hostId, "proxmoxStatsConfig"),
|
||||
);
|
||||
return {
|
||||
id: host.id,
|
||||
userId: host.userId,
|
||||
proxmoxStatsConfig: proxmoxStatsConfig as string | undefined,
|
||||
enableProxmoxStats: true,
|
||||
};
|
||||
}
|
||||
|
||||
/** Called from activate(). Builds the polling manager and mounts its routes. */
|
||||
export function startProxmoxStatsService(
|
||||
ctx: PluginContext,
|
||||
mountOn: Router,
|
||||
historyRepository: ProxmoxNodeHistoryRepository,
|
||||
): void {
|
||||
pollingManager = new ProxmoxPollingManager<PollableHost>({
|
||||
fetchHostById: (hostId, userId) => fetchHostForPolling(ctx, hostId, userId),
|
||||
withSshConnection: (host, fn) =>
|
||||
ctx.ssh.withConnection(
|
||||
host.id,
|
||||
{
|
||||
pool: "proxmox-stats",
|
||||
purpose: "proxmox",
|
||||
overrides: { readyTimeout: 60000 },
|
||||
},
|
||||
fn,
|
||||
),
|
||||
historyRepository,
|
||||
});
|
||||
|
||||
router = express.Router();
|
||||
registerRoutes(router, pollingManager, historyRepository);
|
||||
mountOn.use("/stats", router);
|
||||
}
|
||||
|
||||
export function stopProxmoxStatsService(): void {
|
||||
pollingManager?.destroy();
|
||||
pollingManager = undefined;
|
||||
router = undefined;
|
||||
}
|
||||
|
||||
function registerRoutes(
|
||||
app: Router,
|
||||
manager: ProxmoxPollingManager<PollableHost>,
|
||||
historyRepository: ProxmoxNodeHistoryRepository,
|
||||
): void {
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/proxmox/stats/{id}:
|
||||
* get:
|
||||
* summary: Get cached Proxmox node stats for a host
|
||||
* description: Returns the most recently polled Proxmox stats snapshot for a host, or an empty skeleton if none has been collected yet.
|
||||
* tags: [Proxmox]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Proxmox stats snapshot.
|
||||
* 404:
|
||||
* description: Stats not available yet.
|
||||
*/
|
||||
app.get("/:id", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const cached = manager.getStats(id);
|
||||
if (!cached) {
|
||||
const errorState = manager.getError(id);
|
||||
return res.status(404).json({
|
||||
error: errorState?.error || "Stats not available",
|
||||
...EMPTY_SNAPSHOT,
|
||||
lastChecked: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
res.json({
|
||||
...cached.data,
|
||||
lastChecked: new Date(cached.timestamp).toISOString(),
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/proxmox/stats/start/{id}:
|
||||
* post:
|
||||
* summary: Start Proxmox stats collection
|
||||
* description: Registers a viewer and starts (or reuses) polling for a host's Proxmox node stats.
|
||||
* tags: [Proxmox]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Polling started, snapshot returned if already available.
|
||||
* 403:
|
||||
* description: Proxmox stats is not enabled for this host.
|
||||
* 404:
|
||||
* description: Host not found.
|
||||
*/
|
||||
app.post("/start/:id", async (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const userId = pluginCtx().currentActor()!;
|
||||
|
||||
try {
|
||||
const host = await fetchHostForPolling(pluginCtx(), id, userId);
|
||||
if (!host) {
|
||||
return res
|
||||
.status(403)
|
||||
.json({ error: "Proxmox stats is not enabled for this host" });
|
||||
}
|
||||
|
||||
const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
manager.registerViewer(id, viewerSessionId, userId);
|
||||
await manager.ensurePolling(host, userId);
|
||||
|
||||
const cached = manager.getStats(id);
|
||||
if (cached) {
|
||||
return res.json({
|
||||
success: true,
|
||||
viewerSessionId,
|
||||
...cached.data,
|
||||
lastChecked: new Date(cached.timestamp).toISOString(),
|
||||
});
|
||||
}
|
||||
const errorState = manager.getError(id);
|
||||
if (errorState) {
|
||||
return res.json({
|
||||
success: true,
|
||||
viewerSessionId,
|
||||
status: "error",
|
||||
error: errorState.error,
|
||||
});
|
||||
}
|
||||
return res.json({ success: true, viewerSessionId, status: "collecting" });
|
||||
} catch (error) {
|
||||
pluginCtx().log.error(
|
||||
`Failed to start proxmox stats collection for host ${id}`,
|
||||
error as Error,
|
||||
);
|
||||
res
|
||||
.status(500)
|
||||
.json({ error: "Failed to start proxmox stats collection" });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/proxmox/stats/stop/{id}:
|
||||
* post:
|
||||
* summary: Stop Proxmox stats collection
|
||||
* description: Unregisters a viewer session for a host's Proxmox node stats polling.
|
||||
* tags: [Proxmox]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* requestBody:
|
||||
* required: false
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Polling stopped successfully.
|
||||
*/
|
||||
app.post("/stop/:id", (req, res) => {
|
||||
const id = Number(req.params.id);
|
||||
const { viewerSessionId } = req.body as { viewerSessionId?: string };
|
||||
if (viewerSessionId && typeof viewerSessionId === "string") {
|
||||
manager.unregisterViewer(id, viewerSessionId);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/proxmox/stats/heartbeat:
|
||||
* post:
|
||||
* summary: Update Proxmox stats viewer heartbeat
|
||||
* description: Keeps a Proxmox stats viewer session alive.
|
||||
* tags: [Proxmox]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* viewerSessionId:
|
||||
* type: string
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Heartbeat updated.
|
||||
* 400:
|
||||
* description: Invalid viewerSessionId.
|
||||
* 404:
|
||||
* description: Viewer session not found.
|
||||
*/
|
||||
app.post("/heartbeat", (req, res) => {
|
||||
const { viewerSessionId } = req.body as { viewerSessionId?: string };
|
||||
if (!viewerSessionId || typeof viewerSessionId !== "string") {
|
||||
return res.status(400).json({ error: "Invalid viewerSessionId" });
|
||||
}
|
||||
const ok = manager.updateHeartbeat(viewerSessionId);
|
||||
if (!ok) return res.status(404).json({ error: "Viewer session not found" });
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /plugin-api/proxmox/stats/history/{hostId}:
|
||||
* get:
|
||||
* summary: Get historical Proxmox node stats for a host
|
||||
* tags: [Proxmox]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: hostId
|
||||
* required: true
|
||||
* schema:
|
||||
* type: integer
|
||||
* - in: query
|
||||
* name: range
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [1h, 6h, 24h, 7d, 30d]
|
||||
* - in: query
|
||||
* name: from
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* - in: query
|
||||
* name: to
|
||||
* schema:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Array of node history rows.
|
||||
* 400:
|
||||
* description: Invalid range or date.
|
||||
*/
|
||||
app.get("/history/:hostId", async (req, res) => {
|
||||
const hostId = Number(req.params.hostId);
|
||||
const { range, from, to } = req.query as Record<string, string | undefined>;
|
||||
|
||||
const RANGE_OFFSETS: Record<string, number> = {
|
||||
"1h": 1 * 60 * 60 * 1000,
|
||||
"6h": 6 * 60 * 60 * 1000,
|
||||
"24h": 24 * 60 * 60 * 1000,
|
||||
"7d": 7 * 24 * 60 * 60 * 1000,
|
||||
"30d": 30 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
let fromTs: string;
|
||||
let toTs = new Date().toISOString();
|
||||
if (range) {
|
||||
const offsetMs = RANGE_OFFSETS[range];
|
||||
if (!offsetMs) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
|
||||
}
|
||||
fromTs = new Date(Date.now() - offsetMs).toISOString();
|
||||
} else if (from && to) {
|
||||
const fromDate = new Date(from);
|
||||
const toDate = new Date(to);
|
||||
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
|
||||
return res.status(400).json({ error: "Invalid from/to date format" });
|
||||
}
|
||||
fromTs = fromDate.toISOString();
|
||||
toTs = toDate.toISOString();
|
||||
} else {
|
||||
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
try {
|
||||
const toSqlite = (iso: string) =>
|
||||
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
|
||||
const rows = (
|
||||
await historyRepository.listRange(
|
||||
hostId,
|
||||
toSqlite(fromTs),
|
||||
toSqlite(toTs),
|
||||
)
|
||||
).map((row) => ({
|
||||
ts: row.ts,
|
||||
cpu_percent: row.cpuPercent,
|
||||
mem_percent: row.memPercent,
|
||||
disk_percent: row.diskPercent,
|
||||
net_rx_bytes: row.netRxBytes,
|
||||
net_tx_bytes: row.netTxBytes,
|
||||
}));
|
||||
res.json({ rows, fromTs, toTs });
|
||||
} catch (error) {
|
||||
pluginCtx().log.error(
|
||||
`Failed to fetch proxmox stats history for host ${hostId}`,
|
||||
error as Error,
|
||||
);
|
||||
res.status(500).json({ error: "Failed to fetch proxmox stats history" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export { parseProxmoxStatsConfig };
|
||||
@@ -0,0 +1,64 @@
|
||||
import {
|
||||
adoptLegacyTable,
|
||||
defineTable,
|
||||
id,
|
||||
integer,
|
||||
real,
|
||||
refHost,
|
||||
refUser,
|
||||
text,
|
||||
timestamp,
|
||||
} from "@termix/plugin-sdk/db";
|
||||
|
||||
/**
|
||||
* Per-host historical node stats samples (CPU, memory, disk, network),
|
||||
* polled on an interval while a viewer has the Proxmox stats tab open.
|
||||
*
|
||||
* Adopted from core's proxmox_node_history, so the column and index names are
|
||||
* the legacy ones: the rename carries the existing rows across.
|
||||
*/
|
||||
export const proxmoxNodeHistory = adoptLegacyTable(
|
||||
"proxmox_node_history",
|
||||
defineTable("node_history", {
|
||||
id: id(),
|
||||
hostId: refHost(),
|
||||
ts: timestamp().notNull().defaultNow(),
|
||||
cpuPercent: real(),
|
||||
memPercent: real(),
|
||||
diskPercent: real(),
|
||||
netRxBytes: integer(),
|
||||
netTxBytes: integer(),
|
||||
}),
|
||||
);
|
||||
|
||||
/**
|
||||
* One saved dashboard layout per user per host for the Proxmox stats tab.
|
||||
*
|
||||
* Adopted from core's proxmox_stats_preferences.
|
||||
*/
|
||||
export const proxmoxStatsPreferences = adoptLegacyTable(
|
||||
"proxmox_stats_preferences",
|
||||
defineTable(
|
||||
"stats_preferences",
|
||||
{
|
||||
id: id(),
|
||||
userId: refUser(),
|
||||
hostId: refHost(),
|
||||
// JSON-encoded layout. No secrets, so plain text.
|
||||
layout: text().notNull(),
|
||||
createdAt: timestamp().notNull().defaultNow(),
|
||||
updatedAt: timestamp().notNull().defaultNow(),
|
||||
},
|
||||
{
|
||||
indexes: [
|
||||
{
|
||||
name: "idx_proxmox_stats_prefs_user_host",
|
||||
columns: ["userId", "hostId"],
|
||||
unique: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
export const tables = [proxmoxNodeHistory, proxmoxStatsPreferences];
|
||||
@@ -68,7 +68,8 @@ export function ProxmoxDiscoverDialog({
|
||||
|
||||
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
|
||||
const sshHosts = hosts.filter(
|
||||
(h) => !("isFolder" in h) && h.enableProxmox === true,
|
||||
(h) =>
|
||||
!("isFolder" in h) && h.pluginSettings?.proxmox?.enableProxmox === true,
|
||||
);
|
||||
|
||||
// The Proxmox host the discovery runs against — imported guests are grouped
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { useEffect, useState, type ComponentType } from "react";
|
||||
import { Boxes, Server } from "lucide-react";
|
||||
import { Boxes, HardDrive, Server } from "lucide-react";
|
||||
import {
|
||||
useHosts,
|
||||
useTranslation,
|
||||
type HostEditorSectionProps,
|
||||
type StandaloneViewProps,
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
|
||||
import { HostProxmoxTab } from "./HostProxmoxTab";
|
||||
import { ProxmoxStatsTab } from "./stats/ProxmoxStatsTab";
|
||||
import ProxmoxStatsApp from "./stats/ProxmoxStatsApp";
|
||||
import { HostProxmoxStatsTab } from "./stats/HostProxmoxStatsTab";
|
||||
import type { SSHHost } from "@/types";
|
||||
|
||||
interface DiscoverRequest {
|
||||
hostId?: number;
|
||||
@@ -26,12 +32,32 @@ function ProxmoxHostSection({ form, setField }: HostEditorSectionProps) {
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
|
||||
/>
|
||||
{/* Other plugins add Proxmox-related settings here (stats). */}
|
||||
<HostProxmoxStatsTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
|
||||
/>
|
||||
{/* Other plugins add Proxmox-related settings here. */}
|
||||
<ComponentSlot slotId="proxmox.hostEditor" props={{ form, setField }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxStatsTabView({ sshHost, label, isVisible }: TabProps) {
|
||||
return (
|
||||
<ProxmoxStatsTab
|
||||
hostConfig={sshHost as unknown as SSHHost}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxStatsStandalone({ hostId }: StandaloneViewProps) {
|
||||
return <ProxmoxStatsApp hostId={hostId} />;
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// The dialog lives in the hosts panel; the menu item and host action open
|
||||
// it through this, created per activation.
|
||||
@@ -125,4 +151,24 @@ export function activate(app: TermixApp): void {
|
||||
order: 50,
|
||||
component: ProxmoxHostSection,
|
||||
});
|
||||
|
||||
app.registerTab("proxmox-stats", ProxmoxStatsTabView, {
|
||||
icon: HardDrive,
|
||||
titleKey: "nav.proxmoxStats",
|
||||
requiresHost: true,
|
||||
noHostMessageKey: "proxmoxStats.noHostSelected",
|
||||
standalone: ProxmoxStatsStandalone,
|
||||
preload: () => import("./stats/ProxmoxStatsTab"),
|
||||
});
|
||||
|
||||
app.registerHostAction({
|
||||
id: "proxmox-stats",
|
||||
titleKey: "nav.proxmoxStats",
|
||||
icon: HardDrive,
|
||||
kind: "open",
|
||||
order: 60,
|
||||
tabType: "proxmox-stats",
|
||||
copyUrlView: "proxmox-stats",
|
||||
when: (host) => host.enableProxmoxStats === true,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Box, Search, Server as ServerIcon } from "lucide-react";
|
||||
import { Input } from "@termix/plugin-sdk/ui";
|
||||
import type { ProxmoxGuestSummary } from "../types";
|
||||
import { cn } from "./utils";
|
||||
|
||||
type StatusFilter = "all" | "running" | "stopped";
|
||||
type TypeFilter = "all" | "qemu" | "lxc";
|
||||
|
||||
function formatUptime(seconds: number | null): string {
|
||||
if (seconds === null || seconds <= 0) return "-";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function UsageCell({
|
||||
percent,
|
||||
usedGiB,
|
||||
totalGiB,
|
||||
}: {
|
||||
percent: number | null;
|
||||
usedGiB?: number | null;
|
||||
totalGiB?: number | null;
|
||||
}) {
|
||||
if (percent === null) {
|
||||
return <span className="text-[11px] text-muted-foreground">-</span>;
|
||||
}
|
||||
const clamped = Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
return (
|
||||
<div className="flex min-w-[92px] flex-col gap-1">
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-xs font-semibold tabular-nums">
|
||||
{clamped.toFixed(0)}%
|
||||
</span>
|
||||
{usedGiB != null && totalGiB != null && (
|
||||
<span className="text-[10px] tabular-nums text-muted-foreground">
|
||||
{usedGiB.toFixed(1)}/{totalGiB.toFixed(1)}G
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full", barColor)}
|
||||
style={{ width: `${clamped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GuestTable({ guests }: { guests: ProxmoxGuestSummary[] }) {
|
||||
const { t } = useTranslation();
|
||||
const [query, setQuery] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
|
||||
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
return guests
|
||||
.filter(
|
||||
(g) =>
|
||||
statusFilter === "all" ||
|
||||
(statusFilter === "running") === (g.status === "running"),
|
||||
)
|
||||
.filter((g) => typeFilter === "all" || g.type === typeFilter)
|
||||
.filter(
|
||||
(g) =>
|
||||
!q || g.name.toLowerCase().includes(q) || String(g.vmid).includes(q),
|
||||
)
|
||||
.sort((a, b) => {
|
||||
if (a.status === b.status) return a.name.localeCompare(b.name);
|
||||
return a.status === "running" ? -1 : 1;
|
||||
});
|
||||
}, [guests, query, statusFilter, typeFilter]);
|
||||
|
||||
const running = guests.filter((g) => g.status === "running").length;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[420px] flex-1 flex-col overflow-hidden border border-border bg-card">
|
||||
<div className="flex shrink-0 flex-wrap items-center gap-2 border-b border-border px-3 py-2.5">
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{t("proxmoxStats.guestsSummary")}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground">
|
||||
{t("proxmoxStats.guestCounts", { running, total: guests.length })}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-3 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={t("proxmoxStats.searchGuests")}
|
||||
className="h-7 w-40 pl-6 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "qemu", "lxc"] as TypeFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setTypeFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
typeFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "qemu"
|
||||
? t("proxmoxStats.filterVm")
|
||||
: t("proxmoxStats.filterLxc")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex overflow-hidden rounded-none border border-border">
|
||||
{(["all", "running", "stopped"] as StatusFilter[]).map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className={cn(
|
||||
"px-2 py-1 text-[10px] font-semibold uppercase tracking-wide transition-colors",
|
||||
statusFilter === f
|
||||
? "bg-accent-brand text-white"
|
||||
: "bg-background text-muted-foreground hover:bg-muted",
|
||||
)}
|
||||
>
|
||||
{f === "all"
|
||||
? t("proxmoxStats.filterAll")
|
||||
: f === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto thin-scrollbar">
|
||||
{guests.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuests")}
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="flex h-full items-center justify-center py-16 text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.noGuestsMatch")}
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead className="sticky top-0 z-10 bg-card">
|
||||
<tr className="border-b border-border text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.name")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.status")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">ID</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.cpu")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.mem")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.disk")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-semibold">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-border">
|
||||
{filtered.map((guest) => (
|
||||
<tr
|
||||
key={`${guest.type}-${guest.vmid}`}
|
||||
className="transition-colors hover:bg-muted/40"
|
||||
>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{guest.type === "lxc" ? (
|
||||
<Box className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
) : (
|
||||
<ServerIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate font-semibold">
|
||||
{guest.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[9px] uppercase text-muted-foreground">
|
||||
{guest.type === "lxc" ? "LXC" : "VM"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
guest.status === "running"
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
guest.status === "running"
|
||||
? "text-foreground"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{guest.status === "running"
|
||||
? t("proxmoxStats.running")
|
||||
: t("proxmoxStats.stopped")}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-muted-foreground">
|
||||
{guest.vmid}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell percent={guest.cpuPercent} />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.memPercent}
|
||||
usedGiB={guest.memUsedGiB}
|
||||
totalGiB={guest.memTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<UsageCell
|
||||
percent={guest.diskPercent}
|
||||
usedGiB={guest.diskUsedGiB}
|
||||
totalGiB={guest.diskTotalGiB}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 whitespace-nowrap text-muted-foreground">
|
||||
{formatUptime(guest.uptimeSeconds)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Server } from "lucide-react";
|
||||
import {
|
||||
Input,
|
||||
SectionCard,
|
||||
SettingRow,
|
||||
FakeSwitch,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { ProxmoxStatsConfig } from "../types";
|
||||
|
||||
interface HostProxmoxStatsForm {
|
||||
enableProxmoxStats: boolean;
|
||||
proxmoxStatsConfig?: ProxmoxStatsConfig | null;
|
||||
}
|
||||
|
||||
type SetHostField = (key: string, value: unknown) => void;
|
||||
|
||||
export function HostProxmoxStatsTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostProxmoxStatsForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.enableProxmoxStats")}
|
||||
icon={<Server className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-0 py-1">
|
||||
<SettingRow
|
||||
label={t("hosts.enableProxmoxStats")}
|
||||
description={t("hosts.enableProxmoxStatsDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableProxmoxStats}
|
||||
onChange={(v) => setField("enableProxmoxStats", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.enableProxmoxStats && (
|
||||
<>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxStatsPollInterval")}
|
||||
description={t("hosts.proxmoxStatsPollIntervalDesc")}
|
||||
>
|
||||
<Input
|
||||
className="w-24 h-7 text-xs"
|
||||
type="number"
|
||||
min={15}
|
||||
value={form.proxmoxStatsConfig?.pollInterval ?? 60}
|
||||
onChange={(e) =>
|
||||
setField("proxmoxStatsConfig", {
|
||||
...form.proxmoxStatsConfig,
|
||||
pollInterval: Math.max(
|
||||
15,
|
||||
Number.parseInt(e.target.value || "60", 10),
|
||||
),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("hosts.proxmoxStatsNodeOverride")}
|
||||
description={t("hosts.proxmoxStatsNodeOverrideDesc")}
|
||||
>
|
||||
<Input
|
||||
className="w-44 h-7 text-xs"
|
||||
value={form.proxmoxStatsConfig?.nodeName ?? ""}
|
||||
placeholder={t("hosts.proxmoxStatsNodeOverridePlaceholder")}
|
||||
onChange={(e) =>
|
||||
setField("proxmoxStatsConfig", {
|
||||
...form.proxmoxStatsConfig,
|
||||
nodeName: e.target.value || null,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</SettingRow>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Cpu, MemoryStick, HardDrive, Clock, Server } from "lucide-react";
|
||||
import type { ProxmoxNodeStats } from "../types";
|
||||
import { cn } from "./utils";
|
||||
|
||||
function Tile({
|
||||
icon,
|
||||
label,
|
||||
percent,
|
||||
detail,
|
||||
sparkline,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
percent: number | null;
|
||||
detail?: string | null;
|
||||
sparkline?: number[];
|
||||
}) {
|
||||
const clamped = percent === null ? null : Math.min(100, Math.max(0, percent));
|
||||
const barColor =
|
||||
clamped === null
|
||||
? "bg-muted-foreground/30"
|
||||
: clamped >= 90
|
||||
? "bg-red-500"
|
||||
: clamped >= 75
|
||||
? "bg-yellow-500"
|
||||
: "bg-accent-brand";
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
{icon}
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<span className="text-2xl font-bold tabular-nums">
|
||||
{clamped === null ? "N/A" : `${clamped.toFixed(0)}%`}
|
||||
</span>
|
||||
{detail && (
|
||||
<span className="truncate text-[11px] text-muted-foreground">
|
||||
{detail}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className={cn("h-full rounded-full transition-all", barColor)}
|
||||
style={{ width: `${clamped ?? 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{sparkline && sparkline.length > 1 && <MiniSparkline data={sparkline} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniSparkline({ data }: { data: number[] }) {
|
||||
const w = 100;
|
||||
const h = 20;
|
||||
const max = 100;
|
||||
const points = data
|
||||
.map((v, i) => {
|
||||
const x = (i / (data.length - 1)) * w;
|
||||
const y = h - (Math.min(100, Math.max(0, v)) / max) * h;
|
||||
return `${x.toFixed(1)},${y.toFixed(1)}`;
|
||||
})
|
||||
.join(" ");
|
||||
return (
|
||||
<svg viewBox={`0 0 ${w} ${h}`} className="h-5 w-full text-accent-brand/70">
|
||||
<polyline
|
||||
points={points}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinejoin="round"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeSummaryStrip({
|
||||
node,
|
||||
histories,
|
||||
}: {
|
||||
node: ProxmoxNodeStats | undefined;
|
||||
histories: { cpu: number[]; memory: number[]; disk: number[] };
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Tile
|
||||
icon={<Cpu className="size-3.5" />}
|
||||
label={t("proxmoxStats.cpuUsage")}
|
||||
percent={node?.cpu.percent ?? null}
|
||||
detail={
|
||||
node?.cpu.cores != null
|
||||
? t("proxmoxStats.cpuCores", { count: node.cpu.cores })
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.cpu}
|
||||
/>
|
||||
<Tile
|
||||
icon={<MemoryStick className="size-3.5" />}
|
||||
label={t("proxmoxStats.memoryUsage")}
|
||||
percent={node?.memory.percent ?? null}
|
||||
detail={
|
||||
node?.memory.usedGiB != null && node?.memory.totalGiB != null
|
||||
? `${node.memory.usedGiB.toFixed(1)} / ${node.memory.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.memory}
|
||||
/>
|
||||
<Tile
|
||||
icon={<HardDrive className="size-3.5" />}
|
||||
label={t("proxmoxStats.diskUsage")}
|
||||
percent={node?.disk.percent ?? null}
|
||||
detail={
|
||||
node?.disk.usedGiB != null && node?.disk.totalGiB != null
|
||||
? `${node.disk.usedGiB.toFixed(1)} / ${node.disk.totalGiB.toFixed(1)} GiB`
|
||||
: undefined
|
||||
}
|
||||
sparkline={histories.disk}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-between gap-2 border border-border bg-card px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Clock className="size-3.5" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.uptime")}
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-2xl font-bold">
|
||||
{node?.uptime.formatted ?? "N/A"}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 truncate text-[11px] text-muted-foreground">
|
||||
<Server className="size-3 shrink-0" />
|
||||
<span className="truncate">
|
||||
{node?.system.hostname ?? "-"}
|
||||
{node?.system.pveVersion ? ` · ${node.system.pveVersion}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { FullScreenAppWrapper, ConnectionScreen } from "@termix/plugin-sdk/ui";
|
||||
import { ProxmoxStatsTab } from "./ProxmoxStatsTab";
|
||||
|
||||
interface ProxmoxStatsAppProps {
|
||||
hostId?: string;
|
||||
}
|
||||
|
||||
const ProxmoxStatsApp: React.FC<ProxmoxStatsAppProps> = ({ hostId }) => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<FullScreenAppWrapper hostId={hostId}>
|
||||
{(hostConfig, phase) => {
|
||||
if (phase === "loading") {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="connecting"
|
||||
message={t("hosts.loadingHost")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hostConfig) {
|
||||
return (
|
||||
<div className="relative h-full w-full">
|
||||
<ConnectionScreen
|
||||
status="disconnected"
|
||||
message={t("hosts.hostNotFound")}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ProxmoxStatsTab
|
||||
hostConfig={hostConfig}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
</FullScreenAppWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxmoxStatsApp;
|
||||
@@ -0,0 +1,382 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import {
|
||||
Button,
|
||||
ConnectionScreen,
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
useTranslation,
|
||||
usePluginApi,
|
||||
useHosts,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { RefreshCw, Server } from "lucide-react";
|
||||
import { createProxmoxStatsApi } from "./proxmox-stats-api";
|
||||
import type { ProxmoxStatsSnapshot, ProxmoxStatsConfig } from "../types";
|
||||
import { NodeSummaryStrip } from "./NodeSummaryStrip";
|
||||
import { GuestTable } from "./GuestTable";
|
||||
import { NodeNetworkCard } from "./cards/NodeNetworkCard";
|
||||
import { StoragePoolsCard } from "./cards/StoragePoolsCard";
|
||||
import { ClusterHealthCard } from "./cards/ClusterHealthCard";
|
||||
import { useConnectionRetry } from "./useConnectionRetry";
|
||||
import { runAdaptivePolling } from "./adaptive-polling";
|
||||
|
||||
const HISTORY_LEN = 30;
|
||||
|
||||
function statsChangeKey(data: ProxmoxStatsSnapshot): string {
|
||||
const bucket = (value: number | null | undefined) =>
|
||||
value == null ? null : Math.round(value / 5) * 5;
|
||||
return JSON.stringify({
|
||||
cpu: bucket(data.node.cpu.percent),
|
||||
memory: bucket(data.node.memory.percent),
|
||||
disk: bucket(data.node.disk.percent),
|
||||
guests: data.guests.counts,
|
||||
cluster: data.cluster,
|
||||
});
|
||||
}
|
||||
const DEFAULT_POLL_INTERVAL = 60;
|
||||
|
||||
interface HostConfig {
|
||||
id: number;
|
||||
name: string;
|
||||
ip: string;
|
||||
username: string;
|
||||
enableProxmoxStats?: boolean;
|
||||
proxmoxStatsConfig?: string | ProxmoxStatsConfig | null;
|
||||
authType?: string;
|
||||
port?: number;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsProps {
|
||||
hostConfig?: HostConfig;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
embedded?: boolean;
|
||||
}
|
||||
|
||||
interface ProxmoxStatsHistories {
|
||||
cpu: number[];
|
||||
memory: number[];
|
||||
disk: number[];
|
||||
}
|
||||
|
||||
function parseProxmoxStatsConfig(
|
||||
raw?: string | ProxmoxStatsConfig | null,
|
||||
): Required<Pick<ProxmoxStatsConfig, "pollInterval">> & ProxmoxStatsConfig {
|
||||
const defaults = { pollInterval: DEFAULT_POLL_INTERVAL, nodeName: null };
|
||||
if (!raw) return defaults;
|
||||
try {
|
||||
const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
|
||||
return { ...defaults, ...parsed };
|
||||
} catch {
|
||||
return defaults;
|
||||
}
|
||||
}
|
||||
|
||||
export function ProxmoxStatsTab(props: ProxmoxStatsProps): React.ReactElement {
|
||||
return (
|
||||
<ConnectionLogProvider>
|
||||
<ProxmoxStatsInner {...props} />
|
||||
</ConnectionLogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function ProxmoxStatsInner({
|
||||
hostConfig,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
embedded = false,
|
||||
}: ProxmoxStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
const pluginApi = usePluginApi();
|
||||
const api = React.useMemo(
|
||||
() => createProxmoxStatsApi(pluginApi),
|
||||
[pluginApi],
|
||||
);
|
||||
const { hosts } = useHosts();
|
||||
|
||||
const [snapshot, setSnapshot] = React.useState<ProxmoxStatsSnapshot | null>(
|
||||
null,
|
||||
);
|
||||
const [histories, setHistories] = React.useState<ProxmoxStatsHistories>({
|
||||
cpu: [],
|
||||
memory: [],
|
||||
disk: [],
|
||||
});
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const [isPageVisible, setIsPageVisible] = React.useState(!document.hidden);
|
||||
const [viewerSessionId, setViewerSessionId] = React.useState<string | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const statsConfig = React.useMemo(
|
||||
() => parseProxmoxStatsConfig(currentHostConfig?.proxmoxStatsConfig),
|
||||
[currentHostConfig?.proxmoxStatsConfig],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
const onVis = () => setIsPageVisible(!document.hidden);
|
||||
document.addEventListener("visibilitychange", onVis);
|
||||
return () => document.removeEventListener("visibilitychange", onVis);
|
||||
}, []);
|
||||
|
||||
const isActuallyVisible = isVisible && isPageVisible;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!viewerSessionId || !isActuallyVisible) return;
|
||||
const interval = setInterval(() => {
|
||||
api.sendHeartbeat(viewerSessionId).catch(() => {});
|
||||
}, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [viewerSessionId, isActuallyVisible, api]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setSnapshot(null);
|
||||
setHistories({ cpu: [], memory: [], disk: [] });
|
||||
}
|
||||
setCurrentHostConfig(hostConfig);
|
||||
}, [hostConfig?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!hostConfig?.id) return;
|
||||
const updated = hosts.find((h) => String(h.id) === String(hostConfig.id));
|
||||
if (updated) setCurrentHostConfig(updated as unknown as HostConfig);
|
||||
}, [hostConfig?.id, hosts]);
|
||||
|
||||
const pushHistory = React.useCallback((data: ProxmoxStatsSnapshot) => {
|
||||
setHistories((prev) => {
|
||||
const add = (arr: number[], v: number | null | undefined) =>
|
||||
[...arr, v ?? 0].slice(-HISTORY_LEN);
|
||||
return {
|
||||
cpu: add(prev.cpu, data.node?.cpu?.percent),
|
||||
memory: add(prev.memory, data.node?.memory?.percent),
|
||||
disk: add(prev.disk, data.node?.disk?.percent),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const notEnabled = currentHostConfig?.enableProxmoxStats !== true;
|
||||
const stopPollingRef = React.useRef<(() => void) | null>(null);
|
||||
|
||||
const fetchSnapshot = React.useCallback(async (): Promise<void> => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_connecting",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const result = await api.startPolling(currentHostConfig.id);
|
||||
if (result.viewerSessionId) setViewerSessionId(result.viewerSessionId);
|
||||
|
||||
addLog({
|
||||
type: "info",
|
||||
stage: "stats_polling",
|
||||
message: t("proxmoxStats.connecting"),
|
||||
});
|
||||
|
||||
const data = await api.getStats(currentHostConfig.id);
|
||||
if (!data) {
|
||||
throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
}
|
||||
|
||||
setSnapshot(data);
|
||||
addLog({
|
||||
type: "success",
|
||||
stage: "connected",
|
||||
message: t("terminal.connected"),
|
||||
});
|
||||
|
||||
const intervalMs =
|
||||
(statsConfig.pollInterval ?? DEFAULT_POLL_INTERVAL) * 1000;
|
||||
let signature = statsChangeKey(data);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = runAdaptivePolling(
|
||||
async () => {
|
||||
const next = await api.getStats(currentHostConfig.id);
|
||||
if (!next) throw new Error(t("proxmoxStats.connectionFailed"));
|
||||
const nextSignature = statsChangeKey(next);
|
||||
const changed = nextSignature !== signature;
|
||||
signature = nextSignature;
|
||||
setSnapshot(next);
|
||||
pushHistory(next);
|
||||
return changed;
|
||||
},
|
||||
{
|
||||
minIntervalMs: intervalMs,
|
||||
maxIntervalMs: Math.min(120_000, intervalMs * 6),
|
||||
stablePollsPerStep: 3,
|
||||
},
|
||||
{ runImmediately: false },
|
||||
);
|
||||
}, [
|
||||
currentHostConfig?.id,
|
||||
statsConfig.pollInterval,
|
||||
addLog,
|
||||
t,
|
||||
pushHistory,
|
||||
api,
|
||||
]);
|
||||
|
||||
const retry = useConnectionRetry({
|
||||
connect: async () => {
|
||||
try {
|
||||
await fetchSnapshot();
|
||||
retry.markConnected();
|
||||
} catch (error: unknown) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "error",
|
||||
message:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: t("proxmoxStats.connectionFailed"),
|
||||
});
|
||||
retry.markFailed();
|
||||
}
|
||||
},
|
||||
enabled: isPageVisible && !notEnabled && !!currentHostConfig?.id,
|
||||
autoStart: false,
|
||||
});
|
||||
|
||||
const retryRef = React.useRef(retry);
|
||||
retryRef.current = retry;
|
||||
|
||||
// Connects once per host and stays connected while this tab exists, even
|
||||
// when the user switches to another tab and back. Only the browser tab
|
||||
// going into the background (isPageVisible) pauses/resumes it -- switching
|
||||
// between Termix tabs must not tear down and reconnect the session.
|
||||
React.useEffect(() => {
|
||||
if (notEnabled || !currentHostConfig?.id) return;
|
||||
|
||||
let cancelled = false;
|
||||
const debounce = setTimeout(() => {
|
||||
if (isPageVisible && !cancelled) {
|
||||
clearLogs();
|
||||
retryRef.current.reset();
|
||||
retryRef.current.retryNow();
|
||||
} else if (!isPageVisible) {
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
api.stopPolling(currentHostConfig.id, undefined).catch(() => {});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearTimeout(debounce);
|
||||
stopPollingRef.current?.();
|
||||
stopPollingRef.current = null;
|
||||
if (currentHostConfig?.id) {
|
||||
api.stopPolling(currentHostConfig.id).catch(() => {});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, notEnabled, isPageVisible, api]);
|
||||
|
||||
const wrapperStyle: React.CSSProperties = embedded
|
||||
? { opacity: isVisible ? 1 : 0, height: "100%", width: "100%" }
|
||||
: {
|
||||
opacity: isVisible ? 1 : 0,
|
||||
margin: isTopbarOpen ? "74px 17px 8px 8px" : "16px 17px 8px 8px",
|
||||
height: isTopbarOpen ? "calc(100vh - 82px)" : "calc(100vh - 24px)",
|
||||
};
|
||||
|
||||
const handleRefresh = async () => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
if (retry.status !== "connected") {
|
||||
retry.retryNow();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setIsRefreshing(true);
|
||||
const data = await api.getStats(currentHostConfig.id);
|
||||
if (data) {
|
||||
setSnapshot(data);
|
||||
pushHistory(data);
|
||||
}
|
||||
} finally {
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showContent = !notEnabled && retry.status === "connected" && snapshot;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={wrapperStyle}
|
||||
className="relative flex flex-col overflow-hidden"
|
||||
>
|
||||
<div className="flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
{retry.status === "connected" && !notEnabled && (
|
||||
<div className="mx-3 mt-3 flex shrink-0 items-center justify-between border border-border bg-card px-3 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex size-10 shrink-0 items-center justify-center border border-border bg-muted">
|
||||
<Server className="size-5 text-accent-brand" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold md:text-2xl">{title}</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="default"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
className="gap-2 font-semibold"
|
||||
>
|
||||
<RefreshCw
|
||||
className={`size-3.5 ${isRefreshing ? "animate-spin" : ""}`}
|
||||
/>
|
||||
{t("proxmoxStats.refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showContent && (
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-3 pb-3 pt-3">
|
||||
<div className="shrink-0">
|
||||
<NodeSummaryStrip node={snapshot.node} histories={histories} />
|
||||
</div>
|
||||
|
||||
<GuestTable guests={snapshot.guests.guests} />
|
||||
|
||||
<div className="grid shrink-0 grid-cols-1 gap-3 pb-1 md:grid-cols-3">
|
||||
<NodeNetworkCard snapshot={snapshot} />
|
||||
<StoragePoolsCard snapshot={snapshot} />
|
||||
{snapshot.cluster.clustered && (
|
||||
<ClusterHealthCard snapshot={snapshot} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectionScreen
|
||||
status={notEnabled ? "connected" : retry.status}
|
||||
message={t("proxmoxStats.connecting")}
|
||||
attempt={retry.attempt}
|
||||
maxAttempts={retry.maxAttempts}
|
||||
nextRetryInMs={retry.nextRetryInMs}
|
||||
onManualRetry={retry.retryNow}
|
||||
emptyState={
|
||||
notEnabled ? (
|
||||
<div className="text-center opacity-40">
|
||||
<Server className="mx-auto mb-4 size-16" />
|
||||
<p className="text-xl font-bold uppercase tracking-widest">
|
||||
{t("proxmoxStats.noHostSelected")}
|
||||
</p>
|
||||
</div>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
export interface AdaptivePollingPolicy {
|
||||
minIntervalMs: number;
|
||||
maxIntervalMs: number;
|
||||
stablePollsPerStep?: number;
|
||||
jitterRatio?: number;
|
||||
/** Prevent expensive requests from consuming more than this share of time. */
|
||||
maxRequestDutyCycle?: number;
|
||||
}
|
||||
|
||||
export interface AdaptivePollingState {
|
||||
stablePolls: number;
|
||||
consecutiveFailures: number;
|
||||
lastPollDurationMs?: number;
|
||||
}
|
||||
|
||||
export type AdaptivePollResult = boolean | void;
|
||||
|
||||
export function computeAdaptivePollDelay(
|
||||
policy: AdaptivePollingPolicy,
|
||||
state: AdaptivePollingState,
|
||||
random = Math.random,
|
||||
): number {
|
||||
const min = Math.max(250, policy.minIntervalMs);
|
||||
const max = Math.max(min, policy.maxIntervalMs);
|
||||
const stableStep = Math.floor(
|
||||
state.stablePolls / Math.max(1, policy.stablePollsPerStep ?? 3),
|
||||
);
|
||||
const exponent = state.consecutiveFailures || stableStep;
|
||||
let base = Math.min(max, min * 2 ** Math.min(exponent, 10));
|
||||
const dutyCycle = policy.maxRequestDutyCycle;
|
||||
if (dutyCycle && dutyCycle > 0 && dutyCycle < 1) {
|
||||
const duration = Math.max(0, state.lastPollDurationMs ?? 0);
|
||||
base = Math.max(base, duration * ((1 - dutyCycle) / dutyCycle));
|
||||
}
|
||||
base = Math.min(max, base);
|
||||
const jitterRatio = Math.max(0, Math.min(0.5, policy.jitterRatio ?? 0.1));
|
||||
const jitter = base * jitterRatio * (random() * 2 - 1);
|
||||
return Math.max(min, Math.min(max, Math.round(base + jitter)));
|
||||
}
|
||||
|
||||
// Copy of src/ui/lib/adaptive-polling.ts. Duplicated here rather than exposed
|
||||
// through the SDK because only this plugin's Proxmox stats tab uses it
|
||||
// today; promote to @termix/plugin-sdk/frontend once a second plugin needs it.
|
||||
export function runAdaptivePolling(
|
||||
poll: () => AdaptivePollResult | Promise<AdaptivePollResult>,
|
||||
policy: AdaptivePollingPolicy,
|
||||
options: {
|
||||
enabled?: () => boolean;
|
||||
visible?: () => boolean;
|
||||
runImmediately?: boolean;
|
||||
random?: () => number;
|
||||
intervalMultiplier?: () => number;
|
||||
onError?: (error: unknown) => void;
|
||||
} = {},
|
||||
): () => void {
|
||||
const enabled = options.enabled ?? (() => true);
|
||||
const visible =
|
||||
options.visible ??
|
||||
(() =>
|
||||
typeof document === "undefined" || document.visibilityState !== "hidden");
|
||||
const state: AdaptivePollingState = {
|
||||
stablePolls: 0,
|
||||
consecutiveFailures: 0,
|
||||
};
|
||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||
let stopped = false;
|
||||
let inFlight = false;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (!timer) return;
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
};
|
||||
|
||||
const schedule = () => {
|
||||
clearTimer();
|
||||
if (stopped || !enabled() || !visible()) return;
|
||||
timer = setTimeout(
|
||||
() => void tick(),
|
||||
Math.min(
|
||||
policy.maxIntervalMs,
|
||||
Math.round(
|
||||
computeAdaptivePollDelay(policy, state, options.random) *
|
||||
Math.max(1, options.intervalMultiplier?.() ?? 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
const tick = async () => {
|
||||
if (stopped || inFlight || !enabled() || !visible()) return;
|
||||
inFlight = true;
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
const changed = await poll();
|
||||
state.consecutiveFailures = 0;
|
||||
state.stablePolls = changed === false ? state.stablePolls + 1 : 0;
|
||||
} catch (error) {
|
||||
state.stablePolls = 0;
|
||||
state.consecutiveFailures += 1;
|
||||
options.onError?.(error);
|
||||
} finally {
|
||||
state.lastPollDurationMs = Date.now() - startedAt;
|
||||
inFlight = false;
|
||||
schedule();
|
||||
}
|
||||
};
|
||||
|
||||
const onVisibility = () => {
|
||||
if (!visible()) {
|
||||
clearTimer();
|
||||
return;
|
||||
}
|
||||
state.stablePolls = 0;
|
||||
state.consecutiveFailures = 0;
|
||||
void tick();
|
||||
};
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
if (options.runImmediately ?? true) void tick();
|
||||
else schedule();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
clearTimer();
|
||||
if (typeof document !== "undefined") {
|
||||
document.removeEventListener("visibilitychange", onVisibility);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import type { ProxmoxStatsSnapshot } from "../../types";
|
||||
import { cn } from "../utils";
|
||||
|
||||
export function ClusterHealthCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const cluster = snapshot?.cluster;
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.clusterHealth")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{!cluster || !cluster.clustered ? (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("proxmoxStats.notClustered")}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
cluster.quorate ? "bg-green-500" : "bg-red-500",
|
||||
)}
|
||||
/>
|
||||
<span className="font-medium">
|
||||
{cluster.clusterName ?? t("proxmoxStats.clusterHealth")}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{cluster.quorate
|
||||
? t("proxmoxStats.quorate")
|
||||
: t("proxmoxStats.notQuorate")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{cluster.nodes.map((node) => (
|
||||
<div key={node.name} className="flex items-center gap-2 py-1.5">
|
||||
<span
|
||||
className={cn(
|
||||
"size-1.5 shrink-0 rounded-full",
|
||||
node.online ? "bg-green-500" : "bg-muted-foreground/50",
|
||||
)}
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{node.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{node.online
|
||||
? t("proxmoxStats.nodeOnline")
|
||||
: t("proxmoxStats.nodeOffline")}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Network } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { StatRow, MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import type { ProxmoxStatsSnapshot } from "../../types";
|
||||
|
||||
export function NodeNetworkCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const interfaces = snapshot?.network.interfaces ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.networkInterfaces")}
|
||||
icon={<Network className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{interfaces.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{interfaces.map((iface) => (
|
||||
<StatRow
|
||||
key={iface.name}
|
||||
label={iface.name}
|
||||
value={iface.ip ?? iface.state ?? "-"}
|
||||
mono
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Database } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { MetricCard } from "@termix/plugin-sdk/ui";
|
||||
import type { ProxmoxStatsSnapshot } from "../../types";
|
||||
|
||||
export function StoragePoolsCard({
|
||||
snapshot,
|
||||
}: {
|
||||
snapshot: ProxmoxStatsSnapshot | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const pools = snapshot?.storage.pools ?? [];
|
||||
|
||||
return (
|
||||
<MetricCard
|
||||
title={t("proxmoxStats.storagePools")}
|
||||
icon={<Database className="size-3.5" />}
|
||||
scroll
|
||||
>
|
||||
{pools.length === 0 ? (
|
||||
<span className="text-xs text-muted-foreground">N/A</span>
|
||||
) : (
|
||||
<div className="flex flex-col divide-y divide-border">
|
||||
{pools.map((pool) => (
|
||||
<div key={pool.name} className="flex flex-col gap-1 py-2 text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="min-w-0 flex-1 truncate font-medium">
|
||||
{pool.name}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full border border-border px-1.5 py-0.5 text-[10px] uppercase text-muted-foreground">
|
||||
{pool.type}
|
||||
</span>
|
||||
{!pool.active && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
{t("proxmoxStats.inactive")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted">
|
||||
<div
|
||||
className="h-full bg-accent-brand"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, pool.percent ?? 0))}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 text-[10px] tabular-nums text-muted-foreground">
|
||||
{pool.usedGiB !== null && pool.totalGiB !== null
|
||||
? `${pool.usedGiB.toFixed(1)} / ${pool.totalGiB.toFixed(1)} GiB`
|
||||
: "N/A"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</MetricCard>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import type { ProxmoxStatsSnapshot } from "../types";
|
||||
|
||||
/**
|
||||
* The Proxmox node-stats routes, through the plugin's own client. Paths are
|
||||
* relative to /plugin-api/proxmox/, which the client already points at.
|
||||
*/
|
||||
export function createProxmoxStatsApi(api: PluginApiClient) {
|
||||
return {
|
||||
async getStats(hostId: number): Promise<ProxmoxStatsSnapshot | null> {
|
||||
try {
|
||||
const response = await api.get<ProxmoxStatsSnapshot>(
|
||||
`/stats/${hostId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error &&
|
||||
"response" in error &&
|
||||
(error as { response?: { status?: number } }).response?.status === 404
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
async startPolling(hostId: number): Promise<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}> {
|
||||
const response = await api.post<{
|
||||
success: boolean;
|
||||
viewerSessionId?: string;
|
||||
status?: string;
|
||||
error?: string;
|
||||
}>(`/stats/start/${hostId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async stopPolling(hostId: number, viewerSessionId?: string): Promise<void> {
|
||||
await api.post(`/stats/stop/${hostId}`, { viewerSessionId });
|
||||
},
|
||||
|
||||
async sendHeartbeat(viewerSessionId: string): Promise<void> {
|
||||
await api.post("/stats/heartbeat", { viewerSessionId });
|
||||
},
|
||||
|
||||
async getHistory(
|
||||
hostId: number,
|
||||
opts: { range?: string; from?: string; to?: string },
|
||||
): Promise<ProxmoxStatsHistoryResponse> {
|
||||
const response = await api.get<ProxmoxStatsHistoryResponse>(
|
||||
`/stats/history/${hostId}`,
|
||||
{ params: opts },
|
||||
);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type ProxmoxStatsApi = ReturnType<typeof createProxmoxStatsApi>;
|
||||
|
||||
export interface ProxmoxStatsHistoryRow {
|
||||
ts: string;
|
||||
cpu_percent: number | null;
|
||||
mem_percent: number | null;
|
||||
disk_percent: number | null;
|
||||
net_rx_bytes: number | null;
|
||||
net_tx_bytes: number | null;
|
||||
}
|
||||
|
||||
export interface ProxmoxStatsHistoryResponse {
|
||||
rows: ProxmoxStatsHistoryRow[];
|
||||
fromTs: string;
|
||||
toTs: string;
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
type ConnectionStatus = "connecting" | "connected" | "error" | "disconnected";
|
||||
|
||||
interface UseConnectionRetryOptions {
|
||||
connect: () => void | Promise<void>;
|
||||
maxAttempts?: number;
|
||||
baseDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
enabled?: boolean;
|
||||
autoStart?: boolean;
|
||||
}
|
||||
|
||||
interface UseConnectionRetryResult {
|
||||
status: ConnectionStatus;
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
nextRetryInMs: number | null;
|
||||
markConnected: () => void;
|
||||
markFailed: () => void;
|
||||
retryNow: () => void;
|
||||
reset: () => void;
|
||||
}
|
||||
|
||||
const COUNTDOWN_TICK_MS = 250;
|
||||
const RETRY_JITTER_RATIO = 0.1;
|
||||
|
||||
export function computeReconnectDelay(
|
||||
attempt: number,
|
||||
baseDelayMs: number,
|
||||
maxDelayMs: number,
|
||||
random = Math.random,
|
||||
): number {
|
||||
const base = Math.min(
|
||||
baseDelayMs * Math.pow(2, Math.max(0, attempt - 1)),
|
||||
maxDelayMs,
|
||||
);
|
||||
const jitter = base * RETRY_JITTER_RATIO * (random() * 2 - 1);
|
||||
return Math.max(baseDelayMs, Math.min(maxDelayMs, Math.round(base + jitter)));
|
||||
}
|
||||
|
||||
// Copy of src/ui/lib/useConnectionRetry.ts. Duplicated here rather than
|
||||
// exposed through the SDK because only this plugin's Proxmox stats tab uses
|
||||
// it today; promote to @termix/plugin-sdk/ui once a second plugin needs it.
|
||||
export function useConnectionRetry({
|
||||
connect,
|
||||
maxAttempts = 8,
|
||||
baseDelayMs = 2000,
|
||||
maxDelayMs = 8000,
|
||||
enabled = true,
|
||||
autoStart = true,
|
||||
}: UseConnectionRetryOptions): UseConnectionRetryResult {
|
||||
const [status, setStatus] = useState<ConnectionStatus>("connecting");
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const [nextRetryInMs, setNextRetryInMs] = useState<number | null>(null);
|
||||
|
||||
const connectRef = useRef(connect);
|
||||
connectRef.current = connect;
|
||||
|
||||
const enabledRef = useRef(enabled);
|
||||
enabledRef.current = enabled;
|
||||
|
||||
const attemptRef = useRef(0);
|
||||
const retryTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const countdownIntervalRef = useRef<ReturnType<typeof setInterval> | null>(
|
||||
null,
|
||||
);
|
||||
const isMountedRef = useRef(true);
|
||||
const markFailedRef = useRef<() => void>(() => {});
|
||||
|
||||
const clearTimers = useCallback(() => {
|
||||
if (retryTimeoutRef.current) {
|
||||
clearTimeout(retryTimeoutRef.current);
|
||||
retryTimeoutRef.current = null;
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
countdownIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const runConnect = useCallback(() => {
|
||||
if (!isMountedRef.current) return;
|
||||
setStatus("connecting");
|
||||
try {
|
||||
void Promise.resolve(connectRef.current()).catch(() => {
|
||||
markFailedRef.current();
|
||||
});
|
||||
} catch {
|
||||
markFailedRef.current();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const scheduleRetry = useCallback(() => {
|
||||
if (attemptRef.current >= maxAttempts) {
|
||||
setStatus("disconnected");
|
||||
setNextRetryInMs(null);
|
||||
return;
|
||||
}
|
||||
|
||||
attemptRef.current += 1;
|
||||
setAttempt(attemptRef.current);
|
||||
|
||||
const delay = computeReconnectDelay(
|
||||
attemptRef.current,
|
||||
baseDelayMs,
|
||||
maxDelayMs,
|
||||
);
|
||||
|
||||
let remaining = delay;
|
||||
setNextRetryInMs(remaining);
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
remaining = Math.max(0, remaining - COUNTDOWN_TICK_MS);
|
||||
setNextRetryInMs(remaining);
|
||||
}, COUNTDOWN_TICK_MS);
|
||||
|
||||
retryTimeoutRef.current = setTimeout(() => {
|
||||
clearTimers();
|
||||
if (!isMountedRef.current || !enabledRef.current) return;
|
||||
runConnect();
|
||||
}, delay);
|
||||
}, [baseDelayMs, maxDelayMs, maxAttempts, clearTimers, runConnect]);
|
||||
|
||||
const markConnected = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
if (isMountedRef.current) setStatus("connected");
|
||||
}, [clearTimers]);
|
||||
|
||||
const markFailed = useCallback(() => {
|
||||
clearTimers();
|
||||
if (!isMountedRef.current) return;
|
||||
setStatus("error");
|
||||
if (enabledRef.current) {
|
||||
scheduleRetry();
|
||||
} else {
|
||||
setNextRetryInMs(null);
|
||||
}
|
||||
}, [clearTimers, scheduleRetry]);
|
||||
markFailedRef.current = markFailed;
|
||||
|
||||
const retryNow = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
runConnect();
|
||||
}, [clearTimers, runConnect]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
clearTimers();
|
||||
attemptRef.current = 0;
|
||||
setAttempt(0);
|
||||
setNextRetryInMs(null);
|
||||
if (isMountedRef.current) setStatus("connecting");
|
||||
}, [clearTimers]);
|
||||
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
if (autoStart) {
|
||||
runConnect();
|
||||
}
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
clearTimers();
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && status === "error" && !retryTimeoutRef.current) {
|
||||
scheduleRetry();
|
||||
}
|
||||
if (!enabled) {
|
||||
clearTimers();
|
||||
setNextRetryInMs(null);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled]);
|
||||
|
||||
return {
|
||||
status,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
nextRetryInMs,
|
||||
markConnected,
|
||||
markFailed,
|
||||
retryNow,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
|
||||
import { pluginDir } from "./helpers.js";
|
||||
|
||||
// proxmox_node_history and proxmox_stats_preferences as core's SQLite
|
||||
// bootstrap created them before 2.9.0.
|
||||
const LEGACY_DDL = `
|
||||
CREATE TABLE proxmox_node_history (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
cpu_percent REAL,
|
||||
mem_percent REAL,
|
||||
disk_percent REAL,
|
||||
net_rx_bytes INTEGER,
|
||||
net_tx_bytes INTEGER
|
||||
);
|
||||
CREATE TABLE proxmox_stats_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE,
|
||||
layout TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_proxmox_stats_prefs_user_host ON proxmox_stats_preferences (user_id, host_id);
|
||||
`;
|
||||
|
||||
let db: TestDb | null = null;
|
||||
|
||||
afterEach(() => {
|
||||
db?.close();
|
||||
db = null;
|
||||
});
|
||||
|
||||
const tableExists = (name: string) =>
|
||||
!!db!.sqlite
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(name);
|
||||
|
||||
const indexOn = (table: string) =>
|
||||
(
|
||||
db!.sqlite
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ? AND name NOT LIKE 'sqlite_%'",
|
||||
)
|
||||
.all(table) as { name: string }[]
|
||||
).map((row) => row.name);
|
||||
|
||||
describe("adopting proxmox_node_history and proxmox_stats_preferences", () => {
|
||||
it("keeps every existing row when the legacy tables are there", async () => {
|
||||
db = await createTestDb(pluginDir, {
|
||||
before: (sqlite) => {
|
||||
sqlite.exec(
|
||||
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1')",
|
||||
);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
sqlite.exec(LEGACY_DDL);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO proxmox_node_history (host_id, ts, cpu_percent) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run(1, "2026-01-01 00:00:00", 42);
|
||||
sqlite
|
||||
.prepare(
|
||||
"INSERT INTO proxmox_stats_preferences (user_id, host_id, layout) VALUES (?, ?, ?)",
|
||||
)
|
||||
.run("user-1", 1, '{"cards":[]}');
|
||||
},
|
||||
});
|
||||
|
||||
expect(db.applied).toEqual(["0001_adopt_proxmox_stats"]);
|
||||
expect(tableExists("proxmox_node_history")).toBe(false);
|
||||
expect(tableExists("proxmox_stats_preferences")).toBe(false);
|
||||
expect(tableExists("p_proxmox_node_history")).toBe(true);
|
||||
expect(tableExists("p_proxmox_stats_preferences")).toBe(true);
|
||||
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT cpu_percent FROM p_proxmox_node_history").all(),
|
||||
).toEqual([{ cpu_percent: 42 }]);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT layout FROM p_proxmox_stats_preferences").all(),
|
||||
).toEqual([{ layout: '{"cards":[]}' }]);
|
||||
expect(indexOn("p_proxmox_stats_preferences")).toEqual([
|
||||
"idx_proxmox_stats_prefs_user_host",
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates the tables fresh when there is no legacy one", async () => {
|
||||
db = await createTestDb(pluginDir);
|
||||
|
||||
expect(tableExists("proxmox_node_history")).toBe(false);
|
||||
expect(tableExists("p_proxmox_node_history")).toBe(true);
|
||||
expect(tableExists("p_proxmox_stats_preferences")).toBe(true);
|
||||
expect(
|
||||
db.sqlite.prepare("SELECT * FROM p_proxmox_node_history").all(),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user