feat: drop proxmox host columns, use plugin settings instead

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

No files matched your search

@@ -1,117 +0,0 @@
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");
});
});
@@ -1,27 +1,20 @@
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,
vi.mock("../../src/backend/plugin-ctx.js", () => ({
pluginCtx: () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}),
}));
const collectProxmoxStats = vi.fn();
vi.mock(
"../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js",
() => ({
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
}),
);
vi.mock("../../src/backend/proxmox/collect-proxmox-stats.js", () => ({
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
}));
import {
ProxmoxPollingManager,
parseProxmoxStatsConfig,
} from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
} from "../../src/backend/proxmox-stats-polling.js";
import type { Client } from "ssh2";
interface TestHost {
@@ -50,8 +43,6 @@ function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
beforeEach(() => {
vi.useFakeTimers();
historyCreate.mockReset();
historyPrune.mockReset();
collectProxmoxStats.mockReset();
});
@@ -88,11 +79,17 @@ describe("ProxmoxPollingManager", () => {
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
fn({} as Client),
);
const historyRepository = {
create: vi.fn(async () => {}),
pruneOlderThan: vi.fn(async () => {}),
listRange: vi.fn(async () => []),
};
const manager = new ProxmoxPollingManager<TestHost>({
fetchHostById,
withSshConnection,
historyRepository,
});
return { manager, fetchHostById, withSshConnection };
return { manager, fetchHostById, withSshConnection, historyRepository };
}
it("starts polling and caches a snapshot when the first viewer registers", async () => {
@@ -1,14 +1,11 @@
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),
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { collectProxmoxClusterHealth } from "../../../../../src/backend/hosts/metrics/proxmox/cluster-health-collector.js";
import { collectProxmoxClusterHealth } from "../../../src/backend/proxmox/cluster-health-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
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));
},
}),
);
vi.mock("../../../src/backend/proxmox/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 { collectProxmoxStats } from "../../../src/backend/proxmox/collect-proxmox-stats.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
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));
},
}),
);
vi.mock("../../../src/backend/proxmox/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 { collectProxmoxGuestsSummary } from "../../../src/backend/proxmox/guests-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,14 +1,11 @@
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),
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { collectProxmoxNodeNetwork } from "../../../../../src/backend/hosts/metrics/proxmox/node-network-collector.js";
import { collectProxmoxNodeNetwork } from "../../../src/backend/proxmox/node-network-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
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));
},
}),
);
vi.mock("../../../src/backend/proxmox/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 { collectProxmoxNodeStatus } from "../../../src/backend/proxmox/node-status-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
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));
},
}),
);
vi.mock("../../../src/backend/proxmox/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 { collectProxmoxStorage } from "../../../src/backend/proxmox/storage-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,30 +1,34 @@
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";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { proxmoxNodeHistory } from "../../../src/backend/tables.js";
import {
createProxmoxNodeHistoryRepository,
type ProxmoxNodeHistoryRepository,
} from "../../../src/backend/proxmox-node-history-repository.js";
import { pluginDir } from "../helpers.js";
describe("ProxmoxNodeHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
let db: TestDb | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
afterEach(() => {
db?.close();
db = 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 (
async function createRepository(): Promise<ProxmoxNodeHistoryRepository> {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1'), ('user-2', 'user-2')",
);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
},
skipMigrations: false,
});
// Migrations already ran (adopting proxmox_node_history), so seed rows on
// the real table after the fact.
db.sqlite.exec(`
INSERT INTO p_proxmox_node_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
@@ -34,14 +38,14 @@ describe("ProxmoxNodeHistoryRepository", () => {
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new ProxmoxNodeHistoryRepository(context, onWrite);
return createProxmoxNodeHistoryRepository(
db.database,
await db.database.define(proxmoxNodeHistory),
);
}
it("creates and lists node history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const repo = await createRepository();
await repo.create({
hostId: 1,
@@ -59,7 +63,7 @@ describe("ProxmoxNodeHistoryRepository", () => {
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
expect(db!.persisted).toBe(1);
});
it("prunes old history for a host only", async () => {