mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 15:15:24 +00:00
feat: finish the fleets plugin, add ctx.hosts and host-commands SDK (B4)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user