mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: make each plugin an npm workspace built by a termix-plugin CLI
This commit is contained in:
1 parent
4758016f41
commit
5796186a1c
448 files changed
+3087
-2003
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -29,8 +29,6 @@
|
||||
"editorTab": "proxmox"
|
||||
}
|
||||
},
|
||||
"backend": "backend/index.mjs",
|
||||
"frontend": "frontend/index.mjs",
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
}
|
||||
@@ -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": "*"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
File renamed without changes.
File renamed without changes.
@@ -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,
|
||||
File renamed without changes.
@@ -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",
|
||||
File renamed without changes.
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
|
||||
|
||||
export default pluginVitestConfig(import.meta.url);
|
||||
Reference in new issue
Block a user