feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)

This commit is contained in:
LukeGus committed 2026-09-23 13:10:18 -05:00
1 parent 37c015a760
commit c381955ed0
43 files changed
+835 -1999

No files matched your search

@@ -1,135 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentProxmoxNodeHistoryRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import type { HostAction } from "../../utils/permission-manager.js";
type ProxmoxStatsHistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
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,
};
export function registerProxmoxStatsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: ProxmoxStatsHistoryRoutesDeps,
): void {
/**
* @openapi
* /proxmox-stats/history/{hostId}:
* get:
* summary: Get historical Proxmox node stats for a host
* tags:
* - Proxmox Stats
* 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.
* 403:
* description: Access denied.
*/
app.get(
"/proxmox-stats/history/:hostId",
validateHostId,
async (req, res) => {
const hostId = Number(req.params.hostId);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = 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();
}
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentProxmoxNodeHistoryRepository().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) {
statsLogger.error("Failed to fetch proxmox stats history", {
operation: "proxmox_stats_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch proxmox stats history" });
}
},
);
}
@@ -1,332 +0,0 @@
import { statsLogger } from "../../utils/logger.js";
import {
createCurrentProxmoxNodeHistoryRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { ConcurrentLimiter } from "../metrics-shared/state.js";
import {
collectProxmoxStats,
type ProxmoxStatsSnapshot,
} from "./proxmox/collect-proxmox-stats.js";
import type { Client } from "ssh2";
/**
* Proxmox Stats polling gets its own limiter, separate from Host Metrics'
* metricsPollLimiter in state.ts, so a burst of Proxmox polls can never starve
* regular Host Metrics collection (or vice versa).
*/
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>;
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;
}
private getRetentionDays(): number {
try {
const value = getCurrentSettingValue("metrics_history_retention_days");
const days = value ? parseInt(value, 10) : 7;
return isNaN(days) || days < 1 ? 7 : Math.min(days, 90);
} catch {
return 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(),
});
statsLogger.warn("Proxmox stats poll failed", {
operation: "proxmox_stats_poll_failed",
hostId: host.id,
error: 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;
const repository = createCurrentProxmoxNodeHistoryRepository();
await repository.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 repository.pruneOlderThan(hostId, this.getRetentionDays());
} catch (err) {
statsLogger.warn("Failed to write proxmox node history", {
operation: "insert_proxmox_node_history",
hostId,
error: 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) => {
statsLogger.warn(
"Proxmox stats startPollingForHost rejected (non-fatal)",
{
operation: "proxmox_stats_start_unhandled",
hostId,
userId,
error: 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);
}
}
}
@@ -1,258 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { statsLogger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { HostAction } from "../../utils/permission-manager.js";
import {
type ProxmoxPollingManager,
type ProxmoxStatsPollableHost,
} from "./proxmox-stats-polling.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 ProxmoxStatsHost = ProxmoxStatsPollableHost & {
enableProxmoxStats?: boolean;
};
type ProxmoxStatsRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (
hostId: number,
userId: string,
) => Promise<ProxmoxStatsHost | undefined>;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
pollingManager: ProxmoxPollingManager<ProxmoxStatsHost>;
};
export function registerProxmoxStatsRoutes(
app: Express,
{
validateHostId,
fetchHostById,
canAccessHost,
pollingManager,
}: ProxmoxStatsRoutesDeps,
): void {
/**
* @openapi
* /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 Stats
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proxmox stats snapshot.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Stats not available yet.
*/
app.get("/proxmox-stats/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const cached = pollingManager.getStats(id);
if (!cached) {
const errorState = pollingManager.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
* /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 Stats
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Polling started, snapshot returned if already available.
* 401:
* description: Session expired - please log in again.
* 403:
* description: Proxmox Stats is not enabled for this host.
* 404:
* description: Host not found.
*/
app.post("/proxmox-stats/start/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
if (!(await canAccessHost(userId, id, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (!host.enableProxmoxStats) {
return res
.status(403)
.json({ error: "Proxmox Stats is not enabled for this host" });
}
const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(id, viewerSessionId, userId);
await pollingManager.ensurePolling(host, userId);
const cached = pollingManager.getStats(id);
if (cached) {
return res.json({
success: true,
viewerSessionId,
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
}
const errorState = pollingManager.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) {
statsLogger.error("Failed to start proxmox stats collection", {
operation: "proxmox_stats_start_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: getErrorMessage(
error,
"Failed to start proxmox stats collection",
),
});
}
});
/**
* @openapi
* /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 Stats
* 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.
* 401:
* description: Session expired - please log in again.
*/
app.post("/proxmox-stats/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
if (viewerSessionId && typeof viewerSessionId === "string") {
pollingManager.unregisterViewer(id, viewerSessionId);
}
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to stop proxmox stats collection", {
operation: "proxmox_stats_stop_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: getErrorMessage(
error,
"Failed to stop proxmox stats collection",
),
});
}
});
}
@@ -1,67 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/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;
}
}
@@ -1,86 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/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(),
};
}
@@ -1,113 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/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;
}
}
@@ -1,145 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/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);
}
}
@@ -1,142 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/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;
}
}
@@ -1,76 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/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;
}
}
-7
View File
@@ -1,7 +0,0 @@
// 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);
}