mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-11-01 16:15: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
+3697
-149
No files matched your search
@@ -24,10 +24,39 @@
|
|||||||
"ui:surface"
|
"ui:surface"
|
||||||
],
|
],
|
||||||
"contributes": {
|
"contributes": {
|
||||||
"hostCapability": {
|
"tabs": [
|
||||||
|
{
|
||||||
|
"id": "proxmox-stats",
|
||||||
|
"titleKey": "nav.proxmoxStats",
|
||||||
|
"icon": "HardDrive",
|
||||||
|
"openFrom": ["host-context-menu", "palette"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"settings": {
|
||||||
|
"host": {
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
"key": "enableProxmox",
|
"key": "enableProxmox",
|
||||||
"labelKey": "hosts.proxmoxIntegration",
|
"type": "custom",
|
||||||
"editorTab": "proxmox"
|
"component": "proxmoxHostSettings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "proxmoxConfig",
|
||||||
|
"type": "custom",
|
||||||
|
"component": "proxmoxHostSettings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "enableProxmoxStats",
|
||||||
|
"type": "custom",
|
||||||
|
"component": "proxmoxHostSettings"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "proxmoxStatsConfig",
|
||||||
|
"type": "custom",
|
||||||
|
"component": "proxmoxHostSettings"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"locales": "locales",
|
"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 type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
import { setPluginSsh } from "./ssh.js";
|
import { setPluginSsh } from "./ssh.js";
|
||||||
|
import { setPluginCtx } from "./plugin-ctx.js";
|
||||||
import { startProxmoxService, stopProxmoxService } from "./routes.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) {
|
export async function activate(ctx: PluginContext) {
|
||||||
setPluginSsh(ctx.ssh);
|
setPluginSsh(ctx.ssh);
|
||||||
|
setPluginCtx(ctx);
|
||||||
ctx.disposables.add(() => setPluginSsh(null));
|
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");
|
ctx.log.info("Proxmox routes mounted at /plugin-api/proxmox");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deactivate() {
|
export async function deactivate() {
|
||||||
stopProxmoxService();
|
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 { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||||
import express, { type Router } from "express";
|
import express, { type Router } from "express";
|
||||||
import type { Client as SSHClient } from "ssh2";
|
import type { Client as SSHClient } from "ssh2";
|
||||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
||||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
import type {
|
||||||
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
|
PluginHostCreateInput,
|
||||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
PluginHostRecord,
|
||||||
import {
|
PluginHostUpdateInput,
|
||||||
type AuthenticatedRequest,
|
} from "@termix/plugin-sdk/backend";
|
||||||
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 { connectSsh } from "./ssh.js";
|
import { connectSsh } from "./ssh.js";
|
||||||
|
import { pluginCtx } from "./plugin-ctx.js";
|
||||||
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
|
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
|
||||||
import {
|
import {
|
||||||
parseProxmoxJumpHosts,
|
parseProxmoxJumpHosts,
|
||||||
serializeProxmoxJumpHosts,
|
serializeProxmoxJumpHosts,
|
||||||
} from "./proxmox-jump-hosts.js";
|
} from "./proxmox-jump-hosts.js";
|
||||||
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
|
import { isSafeNodeName } from "./proxmox-shared.js";
|
||||||
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const proxmoxLogger = logger;
|
|
||||||
const runningSyncs = new Set<string>();
|
const runningSyncs = new Set<string>();
|
||||||
|
|
||||||
const MIN_SYNC_INTERVAL_MINUTES = 5;
|
const MIN_SYNC_INTERVAL_MINUTES = 5;
|
||||||
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
|
const DEFAULT_SYNC_INTERVAL_MINUTES = 15;
|
||||||
|
|
||||||
const authManager = AuthManager.getInstance();
|
|
||||||
const authenticateJWT = authManager.createAuthMiddleware();
|
|
||||||
const requireDataAccess = authManager.createDataAccessMiddleware();
|
|
||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
function execCommand(
|
function execCommand(
|
||||||
@@ -189,6 +180,9 @@ type ProxmoxGuest = {
|
|||||||
enableDocker: boolean;
|
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 = {
|
type ProxmoxSource = {
|
||||||
source: "proxmox";
|
source: "proxmox";
|
||||||
sourceHostId: number;
|
sourceHostId: number;
|
||||||
@@ -285,44 +279,30 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
parsedHostId: number,
|
parsedHostId: number,
|
||||||
onProgress?: (done: number, total: number) => void,
|
onProgress?: (done: number, total: number) => void,
|
||||||
): Promise<{
|
): Promise<{
|
||||||
host: SSHHost;
|
host: PluginSshHostWithSudo;
|
||||||
guests: ProxmoxGuest[];
|
guests: ProxmoxGuest[];
|
||||||
credentialId: number | null;
|
credentialId: number | null;
|
||||||
defaultCredentialId: number | null;
|
defaultCredentialId: number | null;
|
||||||
jumpHosts: unknown[] | null;
|
jumpHosts: unknown[] | null;
|
||||||
config: ReturnType<typeof parseProxmoxConfig>;
|
config: ReturnType<typeof parseProxmoxConfig>;
|
||||||
}> {
|
}> {
|
||||||
if (!DataCrypto.canUserAccessData(userId)) {
|
const ctx = pluginCtx();
|
||||||
const error = new Error("Session expired — please log in again");
|
const proxmoxCfgRaw = await ctx.settings.getHost(
|
||||||
(error as Error & { code?: string }).code = "SESSION_EXPIRED";
|
parsedHostId,
|
||||||
throw error;
|
"proxmoxConfig",
|
||||||
}
|
);
|
||||||
|
|
||||||
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 config = parseProxmoxConfig(proxmoxCfgRaw);
|
const config = parseProxmoxConfig(proxmoxCfgRaw);
|
||||||
|
|
||||||
const hostCredentialId = host.credentialId ?? null;
|
const { client, host } = await connectSsh(parsedHostId, {
|
||||||
|
|
||||||
const { client } = await connectSsh(host as unknown as PluginSshHost, {
|
|
||||||
purpose: "proxmox",
|
purpose: "proxmox",
|
||||||
timeoutMs: 35000,
|
timeoutMs: 35000,
|
||||||
overrides: { tryKeyboard: false, readyTimeout: 30000 },
|
overrides: { tryKeyboard: false, readyTimeout: 30000 },
|
||||||
});
|
});
|
||||||
|
const hostWithSudo = host as PluginSshHostWithSudo;
|
||||||
|
const hostCredentialId = (hostWithSudo.credentialId as number | null) ?? null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
proxmoxLogger.info("Proxmox discovery SSH connection established", {
|
ctx.log.info("Proxmox discovery SSH connection established");
|
||||||
operation: "proxmox_discover",
|
|
||||||
hostId: parsedHostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const pveshCheck = await execCommand(
|
const pveshCheck = await execCommand(
|
||||||
client,
|
client,
|
||||||
@@ -337,7 +317,7 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
const resourcesJson = await execPveshCommand(
|
const resourcesJson = await execPveshCommand(
|
||||||
client,
|
client,
|
||||||
"pvesh get /cluster/resources --output-format json 2>/dev/null",
|
"pvesh get /cluster/resources --output-format json 2>/dev/null",
|
||||||
host.sudoPassword,
|
hostWithSudo.sudoPassword,
|
||||||
);
|
);
|
||||||
|
|
||||||
let resources: Array<Record<string, unknown>>;
|
let resources: Array<Record<string, unknown>>;
|
||||||
@@ -366,11 +346,9 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
if (r.template) continue;
|
if (r.template) continue;
|
||||||
const node = r.node as string;
|
const node = r.node as string;
|
||||||
if (!isSafeNodeName(node)) {
|
if (!isSafeNodeName(node)) {
|
||||||
proxmoxLogger.warn("Skipping guest with unsafe node name", {
|
ctx.log.warn(
|
||||||
operation: "proxmox_discover",
|
`Skipping guest with unsafe node name: ${node} (vmid ${r.vmid})`,
|
||||||
node,
|
);
|
||||||
vmid: r.vmid,
|
|
||||||
});
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
guestBases.push({
|
guestBases.push({
|
||||||
@@ -389,7 +367,7 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
const cfgJson = await execPveshCommand(
|
const cfgJson = await execPveshCommand(
|
||||||
client,
|
client,
|
||||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/config --output-format json 2>/dev/null`,
|
||||||
host.sudoPassword,
|
hostWithSudo.sudoPassword,
|
||||||
25000,
|
25000,
|
||||||
);
|
);
|
||||||
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
configIp = parseLxcIp(JSON.parse(cfgJson), config.preferredPrefixes);
|
||||||
@@ -404,7 +382,7 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
const ifRaw = await execPveshCommand(
|
const ifRaw = await execPveshCommand(
|
||||||
client,
|
client,
|
||||||
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
|
`pvesh get /nodes/${g.node}/lxc/${g.vmid}/interfaces --output-format json 2>/dev/null`,
|
||||||
host.sudoPassword,
|
hostWithSudo.sudoPassword,
|
||||||
12000,
|
12000,
|
||||||
);
|
);
|
||||||
const data = JSON.parse(ifRaw);
|
const data = JSON.parse(ifRaw);
|
||||||
@@ -437,7 +415,7 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
const ifJson = await execPveshCommand(
|
const ifJson = await execPveshCommand(
|
||||||
client,
|
client,
|
||||||
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
|
`pvesh get /nodes/${g.node}/qemu/${g.vmid}/agent/network-get-interfaces --output-format json 2>/dev/null`,
|
||||||
host.sudoPassword,
|
hostWithSudo.sudoPassword,
|
||||||
12000,
|
12000,
|
||||||
);
|
);
|
||||||
const data = JSON.parse(ifJson);
|
const data = JSON.parse(ifJson);
|
||||||
@@ -508,21 +486,16 @@ async function discoverProxmoxGuestsForHost(
|
|||||||
enableDocker: matchesAny(g.name, config.dockerPatterns),
|
enableDocker: matchesAny(g.name, config.dockerPatterns),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
proxmoxLogger.info("Proxmox discovery completed", {
|
ctx.log.info(
|
||||||
operation: "proxmox_discover",
|
`Proxmox discovery completed for host ${parsedHostId}: ${guests.length} guest(s)`,
|
||||||
hostId: parsedHostId,
|
);
|
||||||
userId,
|
|
||||||
guestCount: guests.length,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
host,
|
host: hostWithSudo,
|
||||||
guests,
|
guests,
|
||||||
credentialId: hostCredentialId,
|
credentialId: hostCredentialId,
|
||||||
defaultCredentialId: config.defaultCredentialId,
|
defaultCredentialId: config.defaultCredentialId,
|
||||||
jumpHosts: parseProxmoxJumpHosts(
|
jumpHosts: parseProxmoxJumpHosts(hostWithSudo.jumpHosts),
|
||||||
(host as unknown as { jumpHosts?: unknown }).jumpHosts,
|
|
||||||
),
|
|
||||||
config,
|
config,
|
||||||
};
|
};
|
||||||
} finally {
|
} finally {
|
||||||
@@ -538,6 +511,7 @@ async function syncProxmoxHost(
|
|||||||
userId: string,
|
userId: string,
|
||||||
sourceHostId: number,
|
sourceHostId: number,
|
||||||
): Promise<ProxmoxSyncResult> {
|
): Promise<ProxmoxSyncResult> {
|
||||||
|
const ctx = pluginCtx();
|
||||||
const lockKey = `${userId}:${sourceHostId}`;
|
const lockKey = `${userId}:${sourceHostId}`;
|
||||||
if (runningSyncs.has(lockKey)) {
|
if (runningSyncs.has(lockKey)) {
|
||||||
return {
|
return {
|
||||||
@@ -570,10 +544,10 @@ async function syncProxmoxHost(
|
|||||||
);
|
);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
const existingHosts =
|
const existingHosts = (await ctx.hosts.listOwned()) as unknown as Record<
|
||||||
(await createCurrentHostRepository().listDecryptedByUserId(
|
string,
|
||||||
userId,
|
unknown
|
||||||
)) as unknown as Record<string, unknown>[];
|
>[];
|
||||||
const existingBySource = new Map<string, Record<string, unknown>>();
|
const existingBySource = new Map<string, Record<string, unknown>>();
|
||||||
for (const host of existingHosts) {
|
for (const host of existingHosts) {
|
||||||
const source = getProxmoxSource(host);
|
const source = getProxmoxSource(host);
|
||||||
@@ -647,10 +621,9 @@ async function syncProxmoxHost(
|
|||||||
update.credentialId = importAuth.credentialId;
|
update.credentialId = importAuth.credentialId;
|
||||||
update.overrideCredentialUsername = false;
|
update.overrideCredentialUsername = false;
|
||||||
}
|
}
|
||||||
await createCurrentHostRepository().updateEncryptedForUser(
|
await ctx.hosts.update(
|
||||||
userId,
|
|
||||||
existing.id as number,
|
existing.id as number,
|
||||||
update,
|
update as PluginHostUpdateInput,
|
||||||
);
|
);
|
||||||
result.updated++;
|
result.updated++;
|
||||||
continue;
|
continue;
|
||||||
@@ -665,9 +638,8 @@ async function syncProxmoxHost(
|
|||||||
enableRdp: connectionType === "rdp",
|
enableRdp: connectionType === "rdp",
|
||||||
});
|
});
|
||||||
|
|
||||||
await createCurrentHostRepository().createEncryptedForUser(userId, {
|
await ctx.hosts.create({
|
||||||
...update,
|
...update,
|
||||||
userId,
|
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
pin: false,
|
pin: false,
|
||||||
authType: connectionType === "rdp" ? "password" : importAuth.authType,
|
authType: connectionType === "rdp" ? "password" : importAuth.authType,
|
||||||
@@ -709,7 +681,7 @@ async function syncProxmoxHost(
|
|||||||
showTunnelInSidebar: 0,
|
showTunnelInSidebar: 0,
|
||||||
showDockerInSidebar: 0,
|
showDockerInSidebar: 0,
|
||||||
showServerStatsInSidebar: 0,
|
showServerStatsInSidebar: 0,
|
||||||
});
|
} as unknown as PluginHostCreateInput);
|
||||||
result.created++;
|
result.created++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -720,8 +692,7 @@ async function syncProxmoxHost(
|
|||||||
const source = getProxmoxSource(existing);
|
const source = getProxmoxSource(existing);
|
||||||
if (!source) continue;
|
if (!source) continue;
|
||||||
const missingSince = source.missingSince || now;
|
const missingSince = source.missingSince || now;
|
||||||
await createCurrentHostRepository().updateEncryptedForUser(
|
await ctx.hosts.update(
|
||||||
userId,
|
|
||||||
existing.id as number,
|
existing.id as number,
|
||||||
{
|
{
|
||||||
tags: mergeTags(existing.tags, ["proxmox-missing"]),
|
tags: mergeTags(existing.tags, ["proxmox-missing"]),
|
||||||
@@ -733,13 +704,13 @@ async function syncProxmoxHost(
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
updatedAt: now,
|
updatedAt: now,
|
||||||
},
|
} as PluginHostUpdateInput,
|
||||||
);
|
);
|
||||||
result.markedMissing++;
|
result.markedMissing++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await writeSyncStatus(userId, sourceHostId, {
|
await writeSyncStatus(sourceHostId, {
|
||||||
lastSyncAt: startedAt,
|
lastSyncAt: startedAt,
|
||||||
lastSyncStatus: "success",
|
lastSyncStatus: "success",
|
||||||
lastSyncError: null,
|
lastSyncError: null,
|
||||||
@@ -750,7 +721,7 @@ async function syncProxmoxHost(
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = getErrorMessage(error);
|
const message = getErrorMessage(error);
|
||||||
result.errors.push(message);
|
result.errors.push(message);
|
||||||
await writeSyncStatus(userId, sourceHostId, {
|
await writeSyncStatus(sourceHostId, {
|
||||||
lastSyncAt: startedAt,
|
lastSyncAt: startedAt,
|
||||||
lastSyncStatus: "error",
|
lastSyncStatus: "error",
|
||||||
lastSyncError: message,
|
lastSyncError: message,
|
||||||
@@ -763,22 +734,46 @@ async function syncProxmoxHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function writeSyncStatus(
|
async function writeSyncStatus(
|
||||||
userId: string,
|
|
||||||
hostId: number,
|
hostId: number,
|
||||||
patch: Record<string, unknown>,
|
patch: Record<string, unknown>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const hostRepository = createCurrentHostRepository();
|
const ctx = pluginCtx();
|
||||||
const row = await hostRepository.findByIdForUser(userId, hostId);
|
const config = parseJsonObject(
|
||||||
if (!row) return;
|
await ctx.settings.getHost(hostId, "proxmoxConfig"),
|
||||||
const config = parseJsonObject(row.proxmoxConfig);
|
);
|
||||||
await hostRepository.updateForUser(userId, hostId, {
|
await ctx.settings.setHost(hostId, "proxmoxConfig", { ...config, ...patch });
|
||||||
proxmoxConfig: JSON.stringify({ ...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 { hostId } = req.body as { hostId?: unknown };
|
||||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
const userId = pluginCtx().currentActor()!;
|
||||||
|
|
||||||
const parsedHostId = Number(hostId);
|
const parsedHostId = Number(hostId);
|
||||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||||
@@ -790,30 +785,48 @@ router.post("/sync", authenticateJWT, requireDataAccess, async (req, res) => {
|
|||||||
return res.json(result);
|
return res.json(result);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
const status =
|
const status = (err as Error & { status?: number }).status || 500;
|
||||||
(err as Error & { code?: string; status?: number }).code ===
|
pluginCtx().log.error(
|
||||||
"SESSION_EXPIRED"
|
`Proxmox sync failed for host ${parsedHostId}`,
|
||||||
? 401
|
err as Error,
|
||||||
: (err as Error & { status?: number }).status || 500;
|
);
|
||||||
proxmoxLogger.error("Proxmox sync failed", err, {
|
|
||||||
operation: "proxmox_sync",
|
|
||||||
hostId: parsedHostId,
|
|
||||||
userId,
|
|
||||||
});
|
|
||||||
return res.status(status).json({ error: `Sync failed: ${message}` });
|
return res.status(status).json({ error: `Sync failed: ${message}` });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
async function runDueProxmoxAutoSyncs(): Promise<void> {
|
async function runDueProxmoxAutoSyncs(): Promise<void> {
|
||||||
|
const ctx = pluginCtx();
|
||||||
try {
|
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();
|
const now = Date.now();
|
||||||
for (const row of rows) {
|
for (const row of enabledRows) {
|
||||||
const configRaw = parseJsonObject(row.proxmoxConfig);
|
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);
|
const config = parseProxmoxConfig(configRaw);
|
||||||
if (!config.autoSyncEnabled) continue;
|
if (!config.autoSyncEnabled) continue;
|
||||||
if (!DataCrypto.canUserAccessData(row.userId)) continue;
|
|
||||||
|
|
||||||
const lastSyncAt =
|
const lastSyncAt =
|
||||||
typeof configRaw.lastSyncAt === "string"
|
typeof configRaw.lastSyncAt === "string"
|
||||||
@@ -822,18 +835,17 @@ async function runDueProxmoxAutoSyncs(): Promise<void> {
|
|||||||
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
|
const intervalMs = config.syncIntervalMinutes * 60 * 1000;
|
||||||
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
|
if (lastSyncAt && now - lastSyncAt < intervalMs) continue;
|
||||||
|
|
||||||
syncProxmoxHost(row.userId, row.id).catch((error) => {
|
ctx
|
||||||
proxmoxLogger.error("Scheduled Proxmox sync failed", error, {
|
.asUser(host.userId, () => syncProxmoxHost(host.userId, hostId))
|
||||||
operation: "proxmox_auto_sync",
|
.catch((error) => {
|
||||||
hostId: row.id,
|
ctx.log.error(
|
||||||
userId: row.userId,
|
`Scheduled Proxmox sync failed for host ${hostId}`,
|
||||||
});
|
error as Error,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
proxmoxLogger.error("Failed to scan Proxmox auto sync jobs", error, {
|
ctx.log.error("Failed to scan Proxmox auto sync jobs", error as Error);
|
||||||
operation: "proxmox_auto_sync_scan",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -915,12 +927,8 @@ let proxmoxAutoSyncStartupTimer: NodeJS.Timeout | undefined;
|
|||||||
* 500:
|
* 500:
|
||||||
* description: Discovery failed.
|
* description: Discovery failed.
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get("/discover/stream", async (req, res) => {
|
||||||
"/discover/stream",
|
const userId = pluginCtx().currentActor()!;
|
||||||
authenticateJWT,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req, res) => {
|
|
||||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
|
||||||
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
|
const parsedHostId = Number((req.query as { hostId?: unknown }).hostId);
|
||||||
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
if (!parsedHostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||||
return res.status(400).json({ error: "Missing or invalid hostId" });
|
return res.status(400).json({ error: "Missing or invalid hostId" });
|
||||||
@@ -971,11 +979,10 @@ router.get(
|
|||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
proxmoxLogger.error("Proxmox discovery (stream) failed", err, {
|
pluginCtx().log.error(
|
||||||
operation: "proxmox_discover",
|
`Proxmox discovery (stream) failed for host ${parsedHostId}`,
|
||||||
hostId: parsedHostId,
|
err as Error,
|
||||||
userId,
|
);
|
||||||
});
|
|
||||||
send("fail", { message });
|
send("fail", { message });
|
||||||
} finally {
|
} finally {
|
||||||
clearInterval(heartbeat);
|
clearInterval(heartbeat);
|
||||||
@@ -987,16 +994,11 @@ router.get(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
router.post(
|
router.post("/discover", async (req, res) => {
|
||||||
"/discover",
|
|
||||||
authenticateJWT,
|
|
||||||
requireDataAccess,
|
|
||||||
async (req, res) => {
|
|
||||||
const { hostId } = req.body as { hostId?: unknown };
|
const { hostId } = req.body as { hostId?: unknown };
|
||||||
const userId = (req as unknown as AuthenticatedRequest).userId;
|
const userId = pluginCtx().currentActor()!;
|
||||||
|
|
||||||
const parsedHostId = Number(hostId);
|
const parsedHostId = Number(hostId);
|
||||||
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
if (!hostId || !Number.isInteger(parsedHostId) || parsedHostId <= 0) {
|
||||||
@@ -1004,10 +1006,7 @@ router.post(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const discovery = await discoverProxmoxGuestsForHost(
|
const discovery = await discoverProxmoxGuestsForHost(userId, parsedHostId);
|
||||||
userId,
|
|
||||||
parsedHostId,
|
|
||||||
);
|
|
||||||
return res.json({
|
return res.json({
|
||||||
guests: discovery.guests,
|
guests: discovery.guests,
|
||||||
credentialId: discovery.credentialId,
|
credentialId: discovery.credentialId,
|
||||||
@@ -1016,17 +1015,13 @@ router.post(
|
|||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
const message = getErrorMessage(err);
|
const message = getErrorMessage(err);
|
||||||
proxmoxLogger.error("Proxmox discovery failed", err, {
|
pluginCtx().log.error(
|
||||||
operation: "proxmox_discover",
|
`Proxmox discovery failed for host ${parsedHostId}`,
|
||||||
hostId: parsedHostId,
|
err as Error,
|
||||||
userId,
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const status =
|
const status =
|
||||||
(err as Error & { code?: string; status?: number }).code ===
|
(err as Error & { status?: number }).status ||
|
||||||
"SESSION_EXPIRED"
|
|
||||||
? 401
|
|
||||||
: (err as Error & { status?: number }).status ||
|
|
||||||
(message.includes("Authentication failed") ||
|
(message.includes("Authentication failed") ||
|
||||||
message.includes("connect ECONNREFUSED") ||
|
message.includes("connect ECONNREFUSED") ||
|
||||||
message.includes("connect ETIMEDOUT")
|
message.includes("connect ETIMEDOUT")
|
||||||
@@ -1034,8 +1029,7 @@ router.post(
|
|||||||
: 500);
|
: 500);
|
||||||
return res.status(status).json({ error: `Discovery failed: ${message}` });
|
return res.status(status).json({ error: `Discovery failed: ${message}` });
|
||||||
}
|
}
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
/** Called from activate(). Mounts this router at /proxmox via the shared
|
/** Called from activate(). Mounts this router at /proxmox via the shared
|
||||||
* dispatcher, and starts the background auto-sync scan (a 60s interval plus
|
* 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
|
// When opened from the dropdown (no preselectedHostId), only show Proxmox-enabled hosts
|
||||||
const sshHosts = hosts.filter(
|
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
|
// The Proxmox host the discovery runs against — imported guests are grouped
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
import { useEffect, useState, type ComponentType } from "react";
|
import { useEffect, useState, type ComponentType } from "react";
|
||||||
import { Boxes, Server } from "lucide-react";
|
import { Boxes, HardDrive, Server } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
useHosts,
|
useHosts,
|
||||||
useTranslation,
|
useTranslation,
|
||||||
type HostEditorSectionProps,
|
type HostEditorSectionProps,
|
||||||
|
type StandaloneViewProps,
|
||||||
|
type TabProps,
|
||||||
type TermixApp,
|
type TermixApp,
|
||||||
} from "@termix/plugin-sdk/frontend";
|
} from "@termix/plugin-sdk/frontend";
|
||||||
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
|
import { ComponentSlot, DropdownMenuItem } from "@termix/plugin-sdk/ui";
|
||||||
import type { SSHHostWithStatus } from "@/main-axios";
|
import type { SSHHostWithStatus } from "@/main-axios";
|
||||||
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
|
import { ProxmoxDiscoverDialog } from "./ProxmoxDiscoverDialog";
|
||||||
import { HostProxmoxTab } from "./HostProxmoxTab";
|
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 {
|
interface DiscoverRequest {
|
||||||
hostId?: number;
|
hostId?: number;
|
||||||
@@ -26,12 +32,32 @@ function ProxmoxHostSection({ form, setField }: HostEditorSectionProps) {
|
|||||||
form={form}
|
form={form}
|
||||||
setField={setField as Parameters<typeof HostProxmoxTab>[0]["setField"]}
|
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 }} />
|
<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 {
|
export function activate(app: TermixApp): void {
|
||||||
// The dialog lives in the hosts panel; the menu item and host action open
|
// The dialog lives in the hosts panel; the menu item and host action open
|
||||||
// it through this, created per activation.
|
// it through this, created per activation.
|
||||||
@@ -125,4 +151,24 @@ export function activate(app: TermixApp): void {
|
|||||||
order: 50,
|
order: 50,
|
||||||
component: ProxmoxHostSection,
|
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