feat: make each plugin an npm workspace built by a termix-plugin CLI

This commit is contained in:
LukeGus committed 2026-09-22 15:09:50 -05:00
1 parent 4758016f41
commit 5796186a1c
448 files changed
+3087 -2003

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# Changelog
## 1.0.0
Bundled with Termix 2.9.0.
+22
View File
@@ -0,0 +1,22 @@
# Proxmox
Discover Proxmox VE guests over an existing SSH host, import them as Termix hosts, and keep them in sync.
Bundled with Termix and enabled by default.
## Layout
manifest.json id, capabilities and what this plugin contributes
src/backend/ activate(ctx) / deactivate(), plus the routes
src/frontend/ the UI it registers
locales/en.json its strings (English only; the rest are translated)
tests/backend/ vitest, node
tests/frontend/ vitest, jsdom
## Commands
npm run build bundle into dist/
npm run test run this plugin's tests
npm run typecheck
See packages/plugin-sdk/ARCHITECTURE.md for the contract this follows.
-63
View File
@@ -1,63 +0,0 @@
/**
* Proxmox discovery/import/sync - first-party, in-process plugin.
*
* This plugin runs on the main thread rather than in a worker for the same
* reason ai does (see src/backend/plugins/first-party.ts): it writes
* directly across hosts through the host repository, resolves and connects
* to hosts over the shared SSH pool via resolveHostById and
* createJumpHostChain, verifies host keys via SSHHostKeyVerifier, and
* streams its discovery progress as server-sent events. None of that fits
* the worker ctx -- ctx.hosts is a 13-field read-only view with no write
* methods, and ctx.http.route replies with a single postMessage value
* rather than a stream.
*
* Like ai, this plugin does not own a live transport (no WebSocket server,
* no dedicated port), so it registers its Express router with the
* always-mounted /proxmox dispatcher in
* src/backend/database/routes/proxmox-dispatch.ts instead of getting its
* own port.
*
* routes.ts is what used to be src/backend/database/routes/proxmox.ts,
* moved here with its sibling modules (proxmox-import-auth.ts,
* proxmox-jump-hosts.ts) and rewritten from `export default router` into
* startProxmoxService()/stopProxmoxService(), called from
* activate()/deactivate() below. Those two functions also start and stop
* the background auto-sync interval that used to run at module scope in
* proxmox.ts -- keeping it there would mean it fires even when this plugin
* is disabled and can never be torn down.
*/
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Loads a module by its path relative to this file's own directory.
*
* The plugin ships beside dist/backend, so in a built server the compiled
* .js for the moved Proxmox files (routes.js, ...) sits right next to this
* .mjs, and core modules resolve via ../../../backend/backend/... from
* there (dist/plugins/proxmox/backend -> dist/backend/backend). Under
* vitest/dev nothing imports this plugin directly (the loader loads
* in-process plugins with a plain dynamic import, no tsx loader involved),
* so the only layout that needs to resolve here is the built one.
*/
async function loadRelative(relativePath) {
const here = path.dirname(fileURLToPath(import.meta.url));
const target = path.join(here, `${relativePath}.js`);
return import(new URL(`file://${target.replace(/\\/g, "/")}`).href);
}
let routesModule = null;
export async function activate(ctx) {
routesModule = await loadRelative("routes");
routesModule.startProxmoxService();
ctx.log.info("Proxmox router registered at /proxmox");
}
export async function deactivate() {
if (routesModule) {
routesModule.stopProxmoxService();
routesModule = null;
}
}
+1
View File
@@ -0,0 +1 @@
{}
-2
View File
@@ -29,8 +29,6 @@
"editorTab": "proxmox"
}
},
"backend": "backend/index.mjs",
"frontend": "frontend/index.mjs",
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "@termix-plugin/proxmox",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
}
}
+11
View File
@@ -0,0 +1,11 @@
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { startProxmoxService, stopProxmoxService } from "./routes.js";
export async function activate(ctx: PluginContext) {
startProxmoxService();
ctx.log.info("Proxmox router registered at /proxmox");
}
export async function deactivate() {
stopProxmoxService();
}
@@ -1,28 +1,28 @@
import { getErrorMessage } from "../../../src/backend/utils/error-message.js";
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
import express from "express";
import { Client as SSHClient } from "ssh2";
import { logger } from "../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../src/backend/utils/data-crypto.js";
import { createCurrentHostRepository } from "../../../src/backend/database/repositories/factory.js";
import { AuthManager } from "../../../src/backend/utils/auth-manager.js";
import { logger } from "../../../../src/backend/utils/logger.js";
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { createCurrentHostRepository } from "../../../../src/backend/database/repositories/factory.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
import {
type AuthenticatedRequest,
type SSHHost,
} from "../../../src/types/index.js";
import { SSHHostKeyVerifier } from "../../../src/backend/hosts/host-key-verifier.js";
import { resolveHostById } from "../../../src/backend/hosts/host-resolver.js";
import { createJumpHostChain } from "../../../src/backend/hosts/jump-host-chain.js";
} from "../../../../src/types/index.js";
import { SSHHostKeyVerifier } from "../../../../src/backend/hosts/host-key-verifier.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
import { createJumpHostChain } from "../../../../src/backend/hosts/jump-host-chain.js";
import { resolveProxmoxImportAuth } from "./proxmox-import-auth.js";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "./proxmox-jump-hosts.js";
import { isSafeNodeName } from "../../../src/backend/hosts/proxmox-shared.js";
import { execElevated } from "../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import { isSafeNodeName } from "../../../../src/backend/hosts/proxmox-shared.js";
import { execElevated } from "../../../../src/backend/hosts/metrics-shared/exec-elevated.js";
import {
registerProxmoxRouter,
unregisterProxmoxRouter,
} from "../../../src/backend/database/routes/proxmox-dispatch.js";
} from "../../../../src/backend/database/routes/proxmox-dispatch.js";
const router = express.Router();
const proxmoxLogger = logger;
@@ -350,7 +350,7 @@ async function discoverProxmoxGuestsForHost(
sshConfig.passphrase = resolvedCredentials.keyPassword;
} else if (authType === "agent") {
const { applyAgentAuth } =
await import("../../../src/backend/hosts/terminal-auth-helpers.js");
await import("../../../../src/backend/hosts/terminal-auth-helpers.js");
const result = await applyAgentAuth(
sshConfig,
host.terminalConfig as unknown as Record<string, unknown> | undefined,
@@ -29,7 +29,7 @@ export const hostEditorTabId = "proxmox";
export async function register({ registerHostEditorTab, icons }) {
if (registerHostEditorTab) {
const { HostProxmoxTab } =
await import("../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
await import("../../../../src/ui/sidebar/HostEditorFeatureTabs.tsx");
registerHostEditorTab({
id: hostEditorTabId,
labelKey: "hosts.tabProxmox",
@@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../src/backend/proxmox-import-auth.js";
// The frontend carries its own copy of this decision in
// plugins/proxmox/frontend/proxmox-import-auth.ts. The two drifting apart is
// what produced the reported bug, so both are held to the same matrix.
describe("resolveProxmoxImportAuth", () => {
it("uses the default credential for key auth when one is configured", () => {
expect(resolveProxmoxImportAuth("key", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: 0,
});
});
it("uses the default credential for password auth when one is configured", () => {
expect(resolveProxmoxImportAuth("password", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: 0,
});
});
it("falls back to none when a secret-backed default has no credential", () => {
for (const authType of ["password", "key", "credential"]) {
expect(resolveProxmoxImportAuth(authType, null)).toEqual({
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
});
}
});
it("uses the credential when no default auth type is configured", () => {
expect(resolveProxmoxImportAuth(undefined, 42)).toEqual({
authType: "credential",
credentialId: 42,
overrideCredentialUsername: 0,
});
expect(resolveProxmoxImportAuth(undefined, null)).toEqual({
authType: "none",
credentialId: null,
overrideCredentialUsername: 0,
});
});
it("keeps secretless auth types, with or without a credential", () => {
for (const authType of ["none", "agent", "opkssh", "tailscale", "vault"]) {
for (const credentialId of [null, 7]) {
expect(resolveProxmoxImportAuth(authType, credentialId)).toEqual({
authType,
credentialId: null,
overrideCredentialUsername: 0,
});
}
}
});
});
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import {
parseProxmoxJumpHosts,
serializeProxmoxJumpHosts,
} from "../../src/backend/proxmox-jump-hosts.js";
describe("Proxmox jump-host persistence", () => {
const jumpHosts = [{ hostId: 7 }, { hostId: 9 }];
it("serializes resolved arrays before inserting a synced guest", () => {
expect(serializeProxmoxJumpHosts(jumpHosts)).toBe(
'[{"hostId":7},{"hostId":9}]',
);
});
it("keeps stored JSON strings stable", () => {
const stored = '[{"hostId":7}]';
expect(serializeProxmoxJumpHosts(stored)).toBe(stored);
expect(parseProxmoxJumpHosts(stored)).toEqual([{ hostId: 7 }]);
});
it("turns missing or malformed values into null", () => {
expect(serializeProxmoxJumpHosts(null)).toBeNull();
expect(serializeProxmoxJumpHosts("invalid")).toBeNull();
});
});
@@ -0,0 +1,117 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* The Proxmox Stats feature adds `enable_proxmox_stats` and
* `proxmox_stats_config` to `ssh_data`, backfilled via `addColumnIfNotExists`
* next to the existing `enable_proxmox`/`proxmox_config` columns. Verify the
* migration adds both columns, with the right default, on a database that
* predates them.
*/
describe("proxmox stats columns migration", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-proxmox-stats-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function writePreUpgradeDatabase(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
enable_proxmox INTEGER NOT NULL DEFAULT 0,
proxmox_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('owner', 'alice', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, enable_proxmox)
VALUES (1, 'owner', 'pve node', '10.0.0.9', 22, 'root', 'password', 1);
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
it("adds enable_proxmox_stats (default 0) and proxmox_stats_config (nullable) columns", async () => {
writePreUpgradeDatabase();
const db = await import("../../../../src/backend/database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
const columns = sqlite
.prepare("PRAGMA table_info(ssh_data)")
.all() as Array<{
name: string;
notnull: number;
dflt_value: string | null;
}>;
const enableCol = columns.find((c) => c.name === "enable_proxmox_stats");
expect(enableCol).toBeDefined();
expect(enableCol?.notnull).toBe(1);
const configCol = columns.find((c) => c.name === "proxmox_stats_config");
expect(configCol).toBeDefined();
expect(configCol?.notnull).toBe(0);
const row = sqlite
.prepare(
"SELECT enable_proxmox_stats, proxmox_stats_config FROM ssh_data WHERE id = 1",
)
.get() as {
enable_proxmox_stats: number;
proxmox_stats_config: string | null;
};
// Pre-existing rows default to disabled, independent of enable_proxmox.
expect(row.enable_proxmox_stats).toBe(0);
expect(row.proxmox_stats_config).toBeNull();
});
it("creates the proxmox_node_history and proxmox_stats_preferences tables", async () => {
writePreUpgradeDatabase();
const db = await import("../../../../src/backend/database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
const tables = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.pluck()
.all() as string[];
expect(tables).toContain("proxmox_node_history");
expect(tables).toContain("proxmox_stats_preferences");
});
});
@@ -0,0 +1,151 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const historyCreate = vi.fn();
const historyPrune = vi.fn();
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
getCurrentSettingValue: () => null,
createCurrentProxmoxNodeHistoryRepository: () => ({
create: historyCreate,
pruneOlderThan: historyPrune,
}),
}));
const collectProxmoxStats = vi.fn();
vi.mock(
"../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js",
() => ({
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
}),
);
import {
ProxmoxPollingManager,
parseProxmoxStatsConfig,
} from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
import type { Client } from "ssh2";
interface TestHost {
id: number;
userId: string;
proxmoxStatsConfig?: string | null;
}
function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
return {
node: {
cpu: { percent: 10, cores: 4, load: [0.1, 0.2, 0.3] },
memory: { percent: 20, usedGiB: 2, totalGiB: 8 },
disk: { percent: 30, usedGiB: 30, totalGiB: 100 },
uptime: { seconds: 100, formatted: "0d 0h 1m" },
system: { hostname: "pve1", kernel: "6.8", pveVersion: "8.2" },
},
network: { interfaces: [{ name: "eth0", rxBytes: "10", txBytes: "20" }] },
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
storage: { pools: [] },
cluster: { clustered: false },
lastChecked: new Date().toISOString(),
...overrides,
};
}
beforeEach(() => {
vi.useFakeTimers();
historyCreate.mockReset();
historyPrune.mockReset();
collectProxmoxStats.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
describe("parseProxmoxStatsConfig", () => {
it("returns default poll interval when config is missing", () => {
expect(parseProxmoxStatsConfig(null)).toEqual({
nodeName: null,
pollInterval: 60,
});
});
it("parses a JSON string config", () => {
expect(
parseProxmoxStatsConfig('{"nodeName":"pve1","pollInterval":30}'),
).toEqual({ nodeName: "pve1", pollInterval: 30 });
});
it("falls back to defaults on malformed JSON", () => {
expect(parseProxmoxStatsConfig("{not json")).toEqual({
nodeName: null,
pollInterval: 60,
});
});
});
describe("ProxmoxPollingManager", () => {
function makeManager(host: TestHost) {
const fetchHostById = vi.fn(async () => host);
const withSshConnection = vi.fn(
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
fn({} as Client),
);
const manager = new ProxmoxPollingManager<TestHost>({
fetchHostById,
withSshConnection,
});
return { manager, fetchHostById, withSshConnection };
}
it("starts polling and caches a snapshot when the first viewer registers", async () => {
const host: TestHost = { id: 1, userId: "user-1" };
collectProxmoxStats.mockResolvedValue(snapshot());
const { manager, withSshConnection } = makeManager(host);
manager.registerViewer(1, "viewer-1", "user-1");
// registerViewer kicks off polling via a fire-and-forget promise chain.
await vi.waitFor(() => {
expect(withSshConnection).toHaveBeenCalled();
});
const cached = manager.getStats(1);
expect(cached?.data.node.cpu.percent).toBe(10);
manager.destroy();
});
it("stops polling once the last viewer unregisters", async () => {
const host: TestHost = { id: 2, userId: "user-1" };
collectProxmoxStats.mockResolvedValue(snapshot());
const { manager } = makeManager(host);
manager.registerViewer(2, "viewer-a", "user-1");
manager.registerViewer(2, "viewer-b", "user-1");
await vi.waitFor(() => expect(manager.getStats(2)).toBeDefined());
manager.unregisterViewer(2, "viewer-a");
// one viewer left - stats stay cached
expect(manager.getStats(2)).toBeDefined();
manager.unregisterViewer(2, "viewer-b");
// Cached snapshot is retained (not cleared) but the interval is stopped;
// registering a new viewer must restart polling.
manager.destroy();
});
it("updateHeartbeat returns false for an unknown session", () => {
const { manager } = makeManager({ id: 3, userId: "user-1" });
expect(manager.updateHeartbeat("nope")).toBe(false);
manager.destroy();
});
it("records an error snapshot when collection fails, without throwing", async () => {
const host: TestHost = { id: 4, userId: "user-1" };
collectProxmoxStats.mockRejectedValue(new Error("pvesh not found"));
const { manager } = makeManager(host);
manager.registerViewer(4, "viewer-1", "user-1");
await vi.waitFor(() => {
expect(manager.getError(4)?.error).toBe("pvesh not found");
});
expect(manager.getStats(4)).toBeUndefined();
manager.destroy();
});
});
@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Client } from "ssh2";
const execElevated = vi.fn();
vi.mock(
"../../../../src/backend/hosts/metrics-shared/exec-elevated.js",
() => ({
execElevated: (...args: unknown[]) => execElevated(...args),
}),
);
const { execPveshCommand } = await import("../../src/backend/routes.js");
const client = {} as Client;
describe("Proxmox discovery sudo execution", () => {
beforeEach(() => {
execElevated.mockReset();
});
it("forces pvesh through the stored sudo credentials", async () => {
execElevated.mockResolvedValue({
stdout: '[{"type":"qemu"}]',
stderr: "",
code: 0,
usedSudo: true,
});
await expect(
execPveshCommand(client, "pvesh get /cluster/resources", "secret", 12000),
).resolves.toBe('[{"type":"qemu"}]');
expect(execElevated).toHaveBeenCalledWith(
client,
"pvesh get /cluster/resources",
"secret",
{ forceSudo: true, timeoutMs: 12000 },
);
});
it("surfaces a failed elevated pvesh command", async () => {
execElevated.mockResolvedValue({
stdout: "",
stderr: "pvesh failed",
code: 255,
usedSudo: true,
});
await expect(
execPveshCommand(client, "pvesh get /cluster/resources", "secret"),
).rejects.toThrow("pvesh failed");
});
});
@@ -0,0 +1,69 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
import { collectProxmoxClusterHealth } from "../../../../../src/backend/hosts/metrics/proxmox/cluster-health-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxClusterHealth", () => {
it("reports clustered:false for a standalone node (no cluster entry)", async () => {
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
const res = await collectProxmoxClusterHealth(fakeClient);
expect(res).toEqual({ clustered: false });
});
it("parses a clustered response with quorum and node entries", async () => {
execCommand.mockResolvedValueOnce(
result(
JSON.stringify([
{ type: "cluster", name: "prod-cluster", quorate: 1, nodes: 3 },
{ type: "node", name: "pve1", online: 1, local: 1, ip: "10.0.0.1" },
{ type: "node", name: "pve2", online: 0, local: 0, ip: "10.0.0.2" },
]),
),
);
const res = await collectProxmoxClusterHealth(fakeClient);
expect(res.clustered).toBe(true);
if (res.clustered) {
expect(res.quorate).toBe(true);
expect(res.clusterName).toBe("prod-cluster");
expect(res.nodes).toHaveLength(2);
expect(res.nodes[0]).toEqual({
name: "pve1",
online: true,
local: true,
ip: "10.0.0.1",
});
expect(res.nodes[1].online).toBe(false);
}
});
it("returns clustered:false when pvesh fails", async () => {
execCommand.mockResolvedValueOnce(result("", 1));
const res = await collectProxmoxClusterHealth(fakeClient);
expect(res).toEqual({ clustered: false });
});
it("returns clustered:false on malformed JSON", async () => {
execCommand.mockResolvedValueOnce(result("not json", 0));
const res = await collectProxmoxClusterHealth(fakeClient);
expect(res).toEqual({ clustered: false });
});
});
@@ -0,0 +1,73 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
import { collectProxmoxStats } from "../../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxStats", () => {
it("throws a distinguishable error when pvesh is missing", async () => {
execCommand.mockResolvedValueOnce(result("missing"));
await expect(collectProxmoxStats(fakeClient, null)).rejects.toThrow(
/pvesh not found/i,
);
// Only the pvesh-presence check should have run - no node resolution or
// per-collector execs once that check fails.
expect(execCommand).toHaveBeenCalledTimes(1);
});
it("auto-detects the node name via hostname when none is configured", async () => {
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
execCommand.mockResolvedValueOnce(result("pve-auto\n")); // hostname
// Five collectors run concurrently after that; give each a benign failing
// response so the aggregator still resolves with null-filled sub-shapes.
execCommand.mockResolvedValue(result("", 1));
const snapshot = await collectProxmoxStats(fakeClient, null);
expect(snapshot.lastChecked).toBeTruthy();
expect(snapshot.node).toBeDefined();
expect(snapshot.guests).toBeDefined();
expect(snapshot.storage).toBeDefined();
expect(snapshot.cluster).toEqual({ clustered: false });
});
it("uses the configured node name when it is safe, skipping hostname detection", async () => {
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
execCommand.mockResolvedValue(result("", 1)); // all collector calls fail benignly
await collectProxmoxStats(fakeClient, "my-node");
// hostname auto-detection command should never have been issued.
const calls = execCommand.mock.calls.map((c) => c[1] as string);
expect(calls).not.toContain("hostname");
});
it("rejects an unsafe configured node name", async () => {
execCommand.mockResolvedValueOnce(result("ok")); // pvesh check
await expect(collectProxmoxStats(fakeClient, "bad;node")).rejects.toThrow(
/valid Proxmox node name/i,
);
});
});
@@ -0,0 +1,147 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
import { collectProxmoxGuestsSummary } from "../../../../../src/backend/hosts/metrics/proxmox/guests-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxGuestsSummary", () => {
it("filters to the target node, excludes templates, and computes percentages", async () => {
execCommand.mockResolvedValueOnce(
result(
JSON.stringify([
{
type: "qemu",
node: "pve1",
vmid: 100,
name: "vm-a",
status: "running",
cpu: 0.25,
mem: 2 * 1024 ** 3,
maxmem: 4 * 1024 ** 3,
disk: 10 * 1024 ** 3,
maxdisk: 40 * 1024 ** 3,
uptime: 3600,
},
{
type: "lxc",
node: "pve1",
vmid: 101,
name: "ct-b",
status: "stopped",
cpu: 0,
mem: 0,
maxmem: 512 * 1024 ** 2,
disk: 0,
maxdisk: 8 * 1024 ** 3,
uptime: 0,
},
{
// different node - excluded
type: "qemu",
node: "pve2",
vmid: 200,
name: "elsewhere",
status: "running",
},
{
// template - excluded
type: "qemu",
node: "pve1",
vmid: 999,
name: "template",
status: "stopped",
template: 1,
},
{
// non-guest resource type - excluded
type: "storage",
node: "pve1",
},
]),
),
);
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
expect(res.guests).toHaveLength(2);
expect(res.counts).toEqual({ running: 1, stopped: 1, total: 2 });
const vmA = res.guests.find((g) => g.vmid === 100)!;
expect(vmA.cpuPercent).toBe(25);
expect(vmA.memPercent).toBe(50);
expect(vmA.diskPercent).toBe(25);
});
it("reports null disk fields (not a false 0%) when maxdisk is 0", async () => {
execCommand.mockResolvedValueOnce(
result(
JSON.stringify([
{
type: "qemu",
node: "pve1",
vmid: 100,
name: "vm-no-agent",
status: "running",
cpu: 0.1,
mem: 1024,
maxmem: 2048,
disk: 0,
maxdisk: 0,
uptime: 10,
},
]),
),
);
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
expect(res.guests[0].diskPercent).toBeNull();
expect(res.guests[0].diskUsedGiB).toBeNull();
expect(res.guests[0].diskTotalGiB).toBeNull();
});
it("returns an empty guest list when there are no matching resources", async () => {
execCommand.mockResolvedValueOnce(result(JSON.stringify([]), 0));
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
expect(res.guests).toEqual([]);
expect(res.counts).toEqual({ running: 0, stopped: 0, total: 0 });
});
it("returns an empty result when pvesh is missing (non-zero exit)", async () => {
execCommand.mockResolvedValueOnce(result("", 127));
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
expect(res.guests).toEqual([]);
});
it("returns an empty result on malformed JSON", async () => {
execCommand.mockResolvedValueOnce(result("{not json", 0));
const res = await collectProxmoxGuestsSummary(fakeClient, "pve1");
expect(res.guests).toEqual([]);
});
it("rejects an unsafe node name before running any command", async () => {
const res = await collectProxmoxGuestsSummary(fakeClient, "../etc");
expect(res.guests).toEqual([]);
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
import { collectProxmoxNodeNetwork } from "../../../../../src/backend/hosts/metrics/proxmox/node-network-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxNodeNetwork", () => {
it("rejects an unsafe node name before running any command", async () => {
const res = await collectProxmoxNodeNetwork(fakeClient, "bad;name");
expect(res.interfaces).toEqual([]);
expect(execCommand).not.toHaveBeenCalled();
});
it("falls back to /proc/net/dev when pvesh netstat fails", async () => {
// First call: pvesh netstat -> failure.
execCommand.mockResolvedValueOnce(result("", 1));
// Fallback calls: ip addr, ip link, /proc/net/dev.
execCommand.mockResolvedValueOnce(result("eth0 10.0.0.5/24\n"));
execCommand.mockResolvedValueOnce(result("eth0 UP\n"));
execCommand.mockResolvedValueOnce(
result(
"Inter-| Receive\n" +
" face |bytes packets\n" +
"eth0: 123456 10 0 0 0 0 0 0 654321 20 0 0 0 0 0 0\n",
),
);
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
expect(res.interfaces).toHaveLength(1);
expect(res.interfaces[0]).toMatchObject({
name: "eth0",
ip: "10.0.0.5",
state: "UP",
rxBytes: "123456",
txBytes: "654321",
});
});
it("falls back to /proc/net/dev when pvesh returns unparseable data", async () => {
execCommand.mockResolvedValueOnce(result("not json", 0));
execCommand.mockResolvedValueOnce(result(""));
execCommand.mockResolvedValueOnce(result(""));
execCommand.mockResolvedValueOnce(result("Inter-| Receive\n face |\n"));
const res = await collectProxmoxNodeNetwork(fakeClient, "pve1");
expect(res.interfaces).toEqual([]);
});
});
@@ -0,0 +1,83 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
import { collectProxmoxNodeStatus } from "../../../../../src/backend/hosts/metrics/proxmox/node-status-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxNodeStatus", () => {
it("parses a healthy pvesh /nodes/{node}/status response", async () => {
execCommand.mockResolvedValueOnce(
result(
JSON.stringify({
cpu: 0.15,
cpuinfo: { cores: 8 },
loadavg: ["0.5", "0.6", "0.7"],
memory: { used: 4 * 1024 ** 3, total: 16 * 1024 ** 3 },
rootfs: { used: 20 * 1024 ** 3, total: 100 * 1024 ** 3 },
uptime: 90061,
hostname: "pve1",
kversion: "Linux 6.8.0",
pveversion: "pve-manager/8.2.0",
}),
),
);
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
expect(res.cpu.percent).toBe(15);
expect(res.cpu.cores).toBe(8);
expect(res.cpu.load).toEqual([0.5, 0.6, 0.7]);
expect(res.memory.percent).toBe(25);
expect(res.memory.usedGiB).toBeCloseTo(4, 1);
expect(res.memory.totalGiB).toBeCloseTo(16, 1);
expect(res.disk.percent).toBe(20);
expect(res.uptime.seconds).toBe(90061);
expect(res.uptime.formatted).toBe("1d 1h 1m");
expect(res.system.hostname).toBe("pve1");
expect(res.system.kernel).toBe("Linux 6.8.0");
expect(res.system.pveVersion).toBe("pve-manager/8.2.0");
});
it("returns a fully null-filled shape when pvesh exits non-zero", async () => {
execCommand.mockResolvedValueOnce(result("", 1));
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
expect(res.cpu.percent).toBeNull();
expect(res.memory.percent).toBeNull();
expect(res.disk.percent).toBeNull();
expect(res.uptime.seconds).toBeNull();
expect(res.system.hostname).toBeNull();
});
it("returns null-filled shape on malformed JSON", async () => {
execCommand.mockResolvedValueOnce(result("not json", 0));
const res = await collectProxmoxNodeStatus(fakeClient, "pve1");
expect(res.cpu.percent).toBeNull();
});
it("rejects an unsafe node name before running any command", async () => {
const res = await collectProxmoxNodeStatus(fakeClient, "pve1; rm -rf /");
expect(res.cpu.percent).toBeNull();
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,81 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
import { collectProxmoxStorage } from "../../../../../src/backend/hosts/metrics/proxmox/storage-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
function result(stdout: string, code: number | null = 0) {
return { stdout, stderr: "", code };
}
beforeEach(() => {
execCommand.mockReset();
});
describe("collectProxmoxStorage", () => {
it("parses storage pools with usage percentages", async () => {
execCommand.mockResolvedValueOnce(
result(
JSON.stringify([
{
storage: "local",
type: "dir",
active: 1,
enabled: 1,
used: 20 * 1024 ** 3,
total: 100 * 1024 ** 3,
avail: 80 * 1024 ** 3,
},
{
storage: "local-zfs",
type: "zfspool",
active: 0,
enabled: 1,
used: 0,
total: 0,
avail: 0,
},
]),
),
);
const res = await collectProxmoxStorage(fakeClient, "pve1");
expect(res.pools).toHaveLength(2);
expect(res.pools[0].name).toBe("local");
expect(res.pools[0].active).toBe(true);
expect(res.pools[0].percent).toBe(20);
expect(res.pools[1].active).toBe(false);
expect(res.pools[1].percent).toBeNull();
});
it("returns an empty pool list when pvesh fails", async () => {
execCommand.mockResolvedValueOnce(result("", 1));
const res = await collectProxmoxStorage(fakeClient, "pve1");
expect(res.pools).toEqual([]);
});
it("returns an empty pool list on malformed JSON", async () => {
execCommand.mockResolvedValueOnce(result("nope", 0));
const res = await collectProxmoxStorage(fakeClient, "pve1");
expect(res.pools).toEqual([]);
});
it("rejects an unsafe node name before running any command", async () => {
const res = await collectProxmoxStorage(fakeClient, "$(whoami)");
expect(res.pools).toEqual([]);
expect(execCommand).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { ProxmoxNodeHistoryRepository } from "../../../../../src/backend/database/repositories/proxmox-node-history-repository.js";
describe("ProxmoxNodeHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<ProxmoxNodeHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'pve1', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'pve2', '10.0.0.2', 22, 'root', 'password');
INSERT INTO proxmox_node_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
(1, '2026-01-01 00:00:00', 10, 20, 30, 100, 200),
(1, '2026-01-02 00:00:00', 11, 21, 31, 101, 201),
(1, '2999-01-01 00:00:00', 12, 22, 32, 102, 202),
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new ProxmoxNodeHistoryRepository(context, onWrite);
}
it("creates and lists node history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
await repo.create({
hostId: 1,
cpuPercent: 12,
memPercent: 22,
diskPercent: 32,
netRxBytes: 102,
netTxBytes: 202,
});
const rows = await repo.listRange(
1,
"2026-01-01 00:00:00",
"2026-01-02 23:59:59",
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
});
it("prunes old history for a host only", async () => {
const repo = await createRepository();
await repo.pruneOlderThan(1, 1);
const rows = await repo.listRange(
1,
"2000-01-01 00:00:00",
"2999-12-31 23:59:59",
);
expect(rows.map((row) => row.ts)).toEqual(["2999-01-01 00:00:00"]);
expect(
await repo.listRange(2, "2026-01-01 00:00:00", "2026-01-03 00:00:00"),
).toHaveLength(1);
});
});
@@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { resolveProxmoxImportAuth } from "../../src/frontend/proxmox-import-auth";
describe("resolveProxmoxImportAuth", () => {
it("inherits an available credential even with the default 'password' authType", () => {
// Regression guard: a Proxmox host authenticating via a Termix credential
// used to yield authType 'none' for imported guests because the default
// authType is 'password', not 'credential'.
expect(resolveProxmoxImportAuth("password", 42)).toEqual({
authType: "credential",
credentialId: 42,
overrideCredentialUsername: false,
});
});
it("uses the credential when authType is explicitly 'credential'", () => {
expect(resolveProxmoxImportAuth("credential", 7)).toEqual({
authType: "credential",
credentialId: 7,
overrideCredentialUsername: false,
});
});
it("uses the credential when no authType is given", () => {
expect(resolveProxmoxImportAuth(undefined, 5)).toEqual({
authType: "credential",
credentialId: 5,
overrideCredentialUsername: false,
});
});
it("lets an explicit secretless authType win over an available credential", () => {
expect(resolveProxmoxImportAuth("none", 42)).toEqual({ authType: "none" });
expect(resolveProxmoxImportAuth("tailscale", 42)).toEqual({
authType: "tailscale",
});
expect(resolveProxmoxImportAuth("agent", 42)).toEqual({
authType: "agent",
});
});
it("falls back to 'none' when no credential is available", () => {
expect(resolveProxmoxImportAuth("password", null)).toEqual({
authType: "none",
});
expect(resolveProxmoxImportAuth("key", undefined)).toEqual({
authType: "none",
});
expect(resolveProxmoxImportAuth(undefined, undefined)).toEqual({
authType: "none",
});
});
it("passes through a non secret-backed special type without a credential", () => {
expect(resolveProxmoxImportAuth("opkssh", null)).toEqual({
authType: "opkssh",
});
});
});
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);