From bf147096ebf94c3b443406c2e5f9db11c1ee68bb Mon Sep 17 00:00:00 2001 From: LukeGus Date: Wed, 23 Sep 2026 13:06:43 -0500 Subject: [PATCH] feat: drop proxmox host columns, use plugin settings instead --- .../proxmox-stats-columns-migration.test.ts | 117 ------ .../backend/proxmox-stats-polling.test.ts | 31 +- .../proxmox/cluster-health-collector.test.ts | 11 +- .../proxmox/collect-proxmox-stats.test.ts | 19 +- .../backend/proxmox/guests-collector.test.ts | 19 +- .../proxmox/node-network-collector.test.ts | 11 +- .../proxmox/node-status-collector.test.ts | 19 +- .../backend/proxmox/storage-collector.test.ts | 19 +- .../proxmox-node-history-repository.test.ts | 58 +-- src/backend/database/db/index.ts | 54 --- src/backend/database/db/schema.mysql.ts | 46 --- src/backend/database/db/schema.pg.ts | 46 --- src/backend/database/db/schema.ts | 46 --- src/backend/database/repositories/factory.ts | 10 - .../database/repositories/host-repository.ts | 15 - .../plugin-settings-repository.ts | 23 ++ .../proxmox-node-history-repository.ts | 68 ---- .../database/routes/host-bulk-routes.ts | 109 ++++-- .../database/routes/host-normalizers.ts | 10 - .../database/routes/host-plugin-settings.ts | 19 + src/backend/database/routes/host.ts | 122 +++++-- .../metrics/proxmox-stats-history-routes.ts | 135 ------- .../hosts/metrics/proxmox-stats-polling.ts | 332 ------------------ .../hosts/metrics/proxmox-stats-routes.ts | 258 -------------- .../proxmox/cluster-health-collector.ts | 67 ---- .../metrics/proxmox/collect-proxmox-stats.ts | 86 ----- .../hosts/metrics/proxmox/guests-collector.ts | 113 ------ .../metrics/proxmox/node-network-collector.ts | 145 -------- .../metrics/proxmox/node-status-collector.ts | 142 -------- .../metrics/proxmox/storage-collector.ts | 76 ---- src/backend/hosts/proxmox-shared.ts | 7 - src/backend/plugins/ctx-hosts.ts | 95 +++++ src/backend/starter.ts | 4 + .../database/routes/host-normalizers.test.ts | 18 - src/backend/tests/plugins/ctx-hosts.test.ts | 105 ++++++ .../utils/proxmox-settings-migration.test.ts | 171 +++++++++ .../proxmox-settings-migration.ts | 120 +++++++ src/types/index.ts | 7 +- src/ui/features/tunnel/TunnelApp.tsx | 4 +- src/ui/sidebar/HostEditorData.ts | 39 +- src/ui/sidebar/HostManagerData.ts | 16 +- src/ui/tests/sidebar/HostEditorData.test.ts | 20 +- .../tests/sidebar/HostExportDialog.test.tsx | 2 - 43 files changed, 835 insertions(+), 1999 deletions(-) delete mode 100644 plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts delete mode 100644 src/backend/database/repositories/proxmox-node-history-repository.ts delete mode 100644 src/backend/hosts/metrics/proxmox-stats-history-routes.ts delete mode 100644 src/backend/hosts/metrics/proxmox-stats-polling.ts delete mode 100644 src/backend/hosts/metrics/proxmox-stats-routes.ts delete mode 100644 src/backend/hosts/metrics/proxmox/cluster-health-collector.ts delete mode 100644 src/backend/hosts/metrics/proxmox/collect-proxmox-stats.ts delete mode 100644 src/backend/hosts/metrics/proxmox/guests-collector.ts delete mode 100644 src/backend/hosts/metrics/proxmox/node-network-collector.ts delete mode 100644 src/backend/hosts/metrics/proxmox/node-status-collector.ts delete mode 100644 src/backend/hosts/metrics/proxmox/storage-collector.ts delete mode 100644 src/backend/hosts/proxmox-shared.ts create mode 100644 src/backend/tests/utils/proxmox-settings-migration.test.ts create mode 100644 src/backend/utils/crypto-migration/proxmox-settings-migration.ts diff --git a/plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts b/plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts deleted file mode 100644 index 71358d6fd..000000000 --- a/plugins/proxmox/tests/backend/proxmox-stats-columns-migration.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts index 3a5a35e69..e703d3cdd 100644 --- a/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts +++ b/plugins/proxmox/tests/backend/proxmox-stats-polling.test.ts @@ -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> = {}) { beforeEach(() => { vi.useFakeTimers(); - historyCreate.mockReset(); - historyPrune.mockReset(); collectProxmoxStats.mockReset(); }); @@ -88,11 +79,17 @@ describe("ProxmoxPollingManager", () => { async (_host: TestHost, fn: (client: Client) => Promise) => fn({} as Client), ); + const historyRepository = { + create: vi.fn(async () => {}), + pruneOlderThan: vi.fn(async () => {}), + listRange: vi.fn(async () => []), + }; const manager = new ProxmoxPollingManager({ 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 () => { diff --git a/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts index 8a20f98af..666b0aee9 100644 --- a/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/cluster-health-collector.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts b/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts index d4a504e4b..80dd48ff7 100644 --- a/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/collect-proxmox-stats.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts index 4bd5a7863..f2cb5bab3 100644 --- a/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/guests-collector.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts index 0cb76ec2b..8399fa4a6 100644 --- a/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/node-network-collector.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts index e10fed76a..cd6a52638 100644 --- a/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/node-status-collector.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts b/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts index 99da77767..d9aa02d9f 100644 --- a/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts +++ b/plugins/proxmox/tests/backend/proxmox/storage-collector.test.ts @@ -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; diff --git a/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts b/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts index db298c592..e65949912 100644 --- a/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts +++ b/plugins/proxmox/tests/backend/repositories/proxmox-node-history-repository.test.ts @@ -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, - ): Promise { - 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 { + 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 () => { diff --git a/src/backend/database/db/index.ts b/src/backend/database/db/index.ts index 174003a72..bb4c0b9d7 100644 --- a/src/backend/database/db/index.ts +++ b/src/backend/database/db/index.ts @@ -846,20 +846,6 @@ async function initializeCompleteDatabase(): Promise { CREATE UNIQUE INDEX IF NOT EXISTS idx_host_metrics_prefs_user_host ON host_metrics_preferences (user_id, host_id); - CREATE TABLE IF NOT EXISTS proxmox_stats_preferences ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id TEXT NOT NULL, - host_id INTEGER NOT NULL, - layout TEXT NOT NULL, - created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, - FOREIGN KEY (host_id) REFERENCES ssh_data (id) ON DELETE CASCADE - ); - - CREATE UNIQUE INDEX IF NOT EXISTS idx_proxmox_stats_prefs_user_host - ON proxmox_stats_preferences (user_id, host_id); - CREATE TABLE IF NOT EXISTS host_sidebar_preferences ( user_id TEXT PRIMARY KEY, data TEXT NOT NULL, @@ -1342,18 +1328,6 @@ const migrateSchema = () => { "INTEGER NOT NULL DEFAULT 0", ); addColumnIfNotExists("ssh_data", "web_ui_config", "TEXT"); - addColumnIfNotExists( - "ssh_data", - "enable_proxmox", - "INTEGER NOT NULL DEFAULT 0", - ); - addColumnIfNotExists("ssh_data", "proxmox_config", "TEXT"); - addColumnIfNotExists( - "ssh_data", - "enable_proxmox_stats", - "INTEGER NOT NULL DEFAULT 0", - ); - addColumnIfNotExists("ssh_data", "proxmox_stats_config", "TEXT"); addColumnIfNotExists( "ssh_data", "enable_tmux_monitor", @@ -2381,34 +2355,6 @@ const migrateSchema = () => { } // --- metrics-history end --- - // --- proxmox-node-history begin --- - try { - sqlite.prepare("SELECT id FROM proxmox_node_history LIMIT 1").get(); - } catch { - try { - sqlite.exec(` - CREATE TABLE IF NOT EXISTS proxmox_node_history ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - host_id INTEGER NOT NULL REFERENCES ssh_data(id) ON DELETE CASCADE, - ts TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, - cpu_percent REAL, - mem_percent REAL, - disk_percent REAL, - net_rx_bytes INTEGER, - net_tx_bytes INTEGER - ); - CREATE INDEX IF NOT EXISTS idx_proxmox_node_history_host_ts - ON proxmox_node_history (host_id, ts DESC); - `); - } catch (createError) { - databaseLogger.warn("Failed to create proxmox_node_history table", { - operation: "schema_migration", - error: createError, - }); - } - } - // --- proxmox-node-history end --- - // --- alerts begin --- // alert_rules, alert_rule_channels and alert_firings were only ever created // here, never declared in schema.ts, so they never existed on Postgres or diff --git a/src/backend/database/db/schema.mysql.ts b/src/backend/database/db/schema.mysql.ts index f3a7bfab2..a8744bd20 100644 --- a/src/backend/database/db/schema.mysql.ts +++ b/src/backend/database/db/schema.mysql.ts @@ -298,14 +298,6 @@ export const hosts = mysqlTable( statsConfig: text("stats_config"), dockerConfig: text("docker_config"), webUiConfig: text("web_ui_config"), - enableProxmox: boolean("enable_proxmox") - .notNull() - .default(false), - proxmoxConfig: text("proxmox_config"), - enableProxmoxStats: boolean("enable_proxmox_stats") - .notNull() - .default(false), - proxmoxStatsConfig: text("proxmox_stats_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), @@ -1161,27 +1153,6 @@ export const hostMetricsPreferences = mysqlTable( ], ); -export const proxmoxStatsPreferences = mysqlTable( - "proxmox_stats_preferences", - { - id: int("id").autoincrement().primaryKey(), - userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }), - hostId: int("host_id").notNull().references(() => hosts.id, { onDelete: "cascade" }), - // JSON-encoded ProxmoxStatsLayout. Layout has no secrets, so it is stored as - // plain JSON (no field-level encryption), same convention as hostMetricsPreferences.layout. - layout: text("layout").notNull(), - createdAt: varchar("created_at", { length: 255 }) - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - updatedAt: varchar("updated_at", { length: 255 }) - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - }, - (table) => [ - uniqueIndex("idx_proxmox_stats_prefs_user_host").on(table.userId, table.hostId), - ], -); - export const hostSidebarPreferences = mysqlTable("host_sidebar_preferences", { userId: varchar("user_id", { length: 255 }) .primaryKey() @@ -1387,23 +1358,6 @@ export const hostMetricsHistory = mysqlTable("host_metrics_history", { }); // --- metrics-history end --- -// --- proxmox-node-history begin --- -export const proxmoxNodeHistory = mysqlTable("proxmox_node_history", { - id: int("id").autoincrement().primaryKey(), - hostId: int("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - ts: text("ts") - .notNull() - .default(sql`(CURRENT_TIMESTAMP)`), - cpuPercent: double("cpu_percent"), - memPercent: double("mem_percent"), - diskPercent: double("disk_percent"), - netRxBytes: int("net_rx_bytes"), - netTxBytes: int("net_tx_bytes"), -}); -// --- proxmox-node-history end --- - // --- alerts begin --- export const notificationChannels = mysqlTable("notification_channels", { id: int("id").autoincrement().primaryKey(), diff --git a/src/backend/database/db/schema.pg.ts b/src/backend/database/db/schema.pg.ts index cd5e3aedd..5ecc3f943 100644 --- a/src/backend/database/db/schema.pg.ts +++ b/src/backend/database/db/schema.pg.ts @@ -299,14 +299,6 @@ export const hosts = pgTable( statsConfig: text("stats_config"), dockerConfig: text("docker_config"), webUiConfig: text("web_ui_config"), - enableProxmox: boolean("enable_proxmox") - .notNull() - .default(false), - proxmoxConfig: text("proxmox_config"), - enableProxmoxStats: boolean("enable_proxmox_stats") - .notNull() - .default(false), - proxmoxStatsConfig: text("proxmox_stats_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), @@ -1162,27 +1154,6 @@ export const hostMetricsPreferences = pgTable( ], ); -export const proxmoxStatsPreferences = pgTable( - "proxmox_stats_preferences", - { - id: serial("id").primaryKey(), - userId: varchar("user_id", { length: 255 }).notNull().references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id").notNull().references(() => hosts.id, { onDelete: "cascade" }), - // JSON-encoded ProxmoxStatsLayout. Layout has no secrets, so it is stored as - // plain JSON (no field-level encryption), same convention as hostMetricsPreferences.layout. - layout: text("layout").notNull(), - createdAt: varchar("created_at", { length: 255 }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - updatedAt: varchar("updated_at", { length: 255 }) - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - (table) => [ - uniqueIndex("idx_proxmox_stats_prefs_user_host").on(table.userId, table.hostId), - ], -); - export const hostSidebarPreferences = pgTable("host_sidebar_preferences", { userId: varchar("user_id", { length: 255 }) .primaryKey() @@ -1388,23 +1359,6 @@ export const hostMetricsHistory = pgTable("host_metrics_history", { }); // --- metrics-history end --- -// --- proxmox-node-history begin --- -export const proxmoxNodeHistory = pgTable("proxmox_node_history", { - id: serial("id").primaryKey(), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - ts: text("ts") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - cpuPercent: doublePrecision("cpu_percent"), - memPercent: doublePrecision("mem_percent"), - diskPercent: doublePrecision("disk_percent"), - netRxBytes: integer("net_rx_bytes"), - netTxBytes: integer("net_tx_bytes"), -}); -// --- proxmox-node-history end --- - // --- alerts begin --- export const notificationChannels = pgTable("notification_channels", { id: serial("id").primaryKey(), diff --git a/src/backend/database/db/schema.ts b/src/backend/database/db/schema.ts index 120d2f155..d47404263 100644 --- a/src/backend/database/db/schema.ts +++ b/src/backend/database/db/schema.ts @@ -291,14 +291,6 @@ export const hosts = sqliteTable( statsConfig: text("stats_config"), dockerConfig: text("docker_config"), webUiConfig: text("web_ui_config"), - enableProxmox: integer("enable_proxmox", { mode: "boolean" }) - .notNull() - .default(false), - proxmoxConfig: text("proxmox_config"), - enableProxmoxStats: integer("enable_proxmox_stats", { mode: "boolean" }) - .notNull() - .default(false), - proxmoxStatsConfig: text("proxmox_stats_config"), terminalConfig: text("terminal_config"), quickActions: text("quick_actions"), notes: text("notes"), @@ -1160,27 +1152,6 @@ export const hostMetricsPreferences = sqliteTable( ], ); -export const proxmoxStatsPreferences = sqliteTable( - "proxmox_stats_preferences", - { - id: integer("id").primaryKey({ autoIncrement: true }), - userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), - hostId: integer("host_id").notNull().references(() => hosts.id, { onDelete: "cascade" }), - // JSON-encoded ProxmoxStatsLayout. Layout has no secrets, so it is stored as - // plain JSON (no field-level encryption), same convention as hostMetricsPreferences.layout. - layout: text("layout").notNull(), - createdAt: text("created_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - updatedAt: text("updated_at") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - }, - (table) => [ - uniqueIndex("idx_proxmox_stats_prefs_user_host").on(table.userId, table.hostId), - ], -); - export const hostSidebarPreferences = sqliteTable("host_sidebar_preferences", { userId: text("user_id") .primaryKey() @@ -1386,23 +1357,6 @@ export const hostMetricsHistory = sqliteTable("host_metrics_history", { }); // --- metrics-history end --- -// --- proxmox-node-history begin --- -export const proxmoxNodeHistory = sqliteTable("proxmox_node_history", { - id: integer("id").primaryKey({ autoIncrement: true }), - hostId: integer("host_id") - .notNull() - .references(() => hosts.id, { onDelete: "cascade" }), - ts: text("ts") - .notNull() - .default(sql`CURRENT_TIMESTAMP`), - cpuPercent: real("cpu_percent"), - memPercent: real("mem_percent"), - diskPercent: real("disk_percent"), - netRxBytes: integer("net_rx_bytes"), - netTxBytes: integer("net_tx_bytes"), -}); -// --- proxmox-node-history end --- - // --- alerts begin --- export const notificationChannels = sqliteTable("notification_channels", { id: integer("id").primaryKey({ autoIncrement: true }), diff --git a/src/backend/database/repositories/factory.ts b/src/backend/database/repositories/factory.ts index ae9261574..b72e1ce31 100644 --- a/src/backend/database/repositories/factory.ts +++ b/src/backend/database/repositories/factory.ts @@ -21,7 +21,6 @@ import { HostFolderRepository } from "./host-folder-repository.js"; import { HostHealthRepository } from "./host-health-repository.js"; import { HostMetricsHistoryRepository } from "./host-metrics-history-repository.js"; import { HostMetricsPreferenceRepository } from "./host-metrics-preference-repository.js"; -import { ProxmoxNodeHistoryRepository } from "./proxmox-node-history-repository.js"; import { HostRepository } from "./host-repository.js"; import { HostResolutionRepository } from "./host-resolution-repository.js"; import { HostSidebarPreferenceRepository } from "./host-sidebar-preference-repository.js"; @@ -283,15 +282,6 @@ export function createCurrentHostMetricsPreferenceRepository(): HostMetricsPrefe ); } -export function createCurrentProxmoxNodeHistoryRepository(): ProxmoxNodeHistoryRepository { - return new ProxmoxNodeHistoryRepository( - createCurrentRepositoryContext(), - createCurrentRepositoryLazyWriteHook( - "proxmox_node_history_repository_write", - ), - ); -} - export function createCurrentHostRepository(): HostRepository { return new HostRepository( createCurrentRepositoryContext(), diff --git a/src/backend/database/repositories/host-repository.ts b/src/backend/database/repositories/host-repository.ts index 7dea2a390..ef53babb0 100644 --- a/src/backend/database/repositories/host-repository.ts +++ b/src/backend/database/repositories/host-repository.ts @@ -17,7 +17,6 @@ export interface HostBulkUpdateState { id: number; statsConfig: string | null; credentialId: number | null; - proxmoxConfig: string | null; } export class HostRepository { @@ -103,19 +102,6 @@ export class HostRepository { return DataCrypto.decryptRecord("ssh_data", row, userId, userDataKey); } - async listProxmoxEnabled(): Promise< - Pick[] - > { - return this.context.drizzle - .select({ - id: hosts.id, - userId: hosts.userId, - proxmoxConfig: hosts.proxmoxConfig, - }) - .from(hosts) - .where(eq(hosts.enableProxmox, true)); - } - async listByUserId(userId: string): Promise { return this.context.drizzle .select() @@ -248,7 +234,6 @@ export class HostRepository { id: hosts.id, statsConfig: hosts.statsConfig, credentialId: hosts.credentialId, - proxmoxConfig: hosts.proxmoxConfig, }) .from(hosts) .where(and(inArray(hosts.id, hostIds), eq(hosts.userId, userId))); diff --git a/src/backend/database/repositories/plugin-settings-repository.ts b/src/backend/database/repositories/plugin-settings-repository.ts index 45d534611..4a9fbe1a9 100644 --- a/src/backend/database/repositories/plugin-settings-repository.ts +++ b/src/backend/database/repositories/plugin-settings-repository.ts @@ -89,6 +89,29 @@ export class PluginSettingsRepository { ); } + /** + * Every row across all scope ids for one plugin/scope/key, regardless of + * owner. For a plugin's own background scan (auto-sync, polling) that needs + * to find every host with a setting enabled, across every user, before it + * knows which actor to run as. + */ + async listByKey( + pluginId: string, + scope: PluginSettingsScope, + key: string, + ): Promise { + return this.context.drizzle + .select() + .from(pluginSettings) + .where( + and( + eq(pluginSettings.pluginId, pluginId), + eq(pluginSettings.scope, scope), + eq(pluginSettings.key, key), + ), + ); + } + async set( pluginId: string, scope: PluginSettingsScope, diff --git a/src/backend/database/repositories/proxmox-node-history-repository.ts b/src/backend/database/repositories/proxmox-node-history-repository.ts deleted file mode 100644 index 681ccd00b..000000000 --- a/src/backend/database/repositories/proxmox-node-history-repository.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { and, asc, eq, gte, lt, lte } from "drizzle-orm"; -import { proxmoxNodeHistory } from "../db/schema.js"; -import type { DatabaseContext } from "./database-context.js"; -import { sqlTimestampDaysAgo } from "./sql-timestamp.js"; - -export type ProxmoxNodeHistoryRecord = typeof proxmoxNodeHistory.$inferSelect; - -export interface ProxmoxNodeHistoryCreateInput { - hostId: number; - cpuPercent?: number | null; - memPercent?: number | null; - diskPercent?: number | null; - netRxBytes?: number | null; - netTxBytes?: number | null; -} - -export class ProxmoxNodeHistoryRepository { - constructor( - private readonly context: DatabaseContext, - private readonly onWrite?: () => void | Promise, - ) {} - - async create(input: ProxmoxNodeHistoryCreateInput): Promise { - await this.context.drizzle.insert(proxmoxNodeHistory).values({ - hostId: input.hostId, - cpuPercent: input.cpuPercent, - memPercent: input.memPercent, - diskPercent: input.diskPercent, - netRxBytes: input.netRxBytes, - netTxBytes: input.netTxBytes, - }); - - await this.afterWrite(); - } - - async pruneOlderThan(hostId: number, retentionDays: number): Promise { - await this.context.drizzle - .delete(proxmoxNodeHistory) - .where( - and( - eq(proxmoxNodeHistory.hostId, hostId), - lt(proxmoxNodeHistory.ts, sqlTimestampDaysAgo(retentionDays)), - ), - ); - } - - async listRange( - hostId: number, - fromTs: string, - toTs: string, - ): Promise { - return this.context.drizzle - .select() - .from(proxmoxNodeHistory) - .where( - and( - eq(proxmoxNodeHistory.hostId, hostId), - gte(proxmoxNodeHistory.ts, fromTs), - lte(proxmoxNodeHistory.ts, toTs), - ), - ) - .orderBy(asc(proxmoxNodeHistory.ts)); - } - - private async afterWrite(): Promise { - await this.onWrite?.(); - } -} diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 0bf2f750a..7013006e9 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -10,6 +10,7 @@ import { createCurrentCredentialRepository, createCurrentHostRepository, createCurrentHostResolutionRepository, + createCurrentPluginSettingsRepository, } from "../repositories/factory.js"; import { validateParentHostId } from "./host-parent-validation.js"; import { serializeWebUiConfig } from "./host-web-endpoints.js"; @@ -295,10 +296,6 @@ export function registerHostBulkRoutes( simpleUpdates.enableTerminalToolbar = updates.enableTerminalToolbar; if (typeof updates.enableAiAssistant === "boolean") simpleUpdates.enableAiAssistant = updates.enableAiAssistant; - // Disabling Proxmox is a plain flag flip; enabling is handled per-host - // below so each host can default to its own stored credential. - if (updates.enableProxmox === false) - simpleUpdates.enableProxmox = false; if (Object.keys(simpleUpdates).length > 0) { await hostRepository.updateManyForUser( @@ -324,33 +321,58 @@ export function registerHostBulkRoutes( } } - // Enabling Proxmox needs per-host handling: each host defaults its - // Proxmox credential to the credential already stored on that host, so - // discovery works right away without picking one by hand. Existing - // proxmoxConfig values are preserved. - if (updates.enableProxmox === true) { + // Proxmox enable/disable now lives in the proxmox plugin's own + // host-scope settings, not a hosts column. Disabling is a plain flag + // flip; enabling also defaults the Proxmox credential to the one + // already stored on the host, so discovery works without picking one + // by hand. Existing proxmoxConfig values are preserved. + if (typeof updates.enableProxmox === "boolean") { + const pluginSettingsRepository = + createCurrentPluginSettingsRepository(); for (const host of ownedHosts) { try { - const existing = host.proxmoxConfig - ? JSON.parse(host.proxmoxConfig as string) - : {}; - const merged = { - defaultCredentialId: - existing.defaultCredentialId ?? host.credentialId ?? null, - windowsPatterns: existing.windowsPatterns ?? "win, windows", - dockerPatterns: existing.dockerPatterns ?? "docker", - preferredPrefixes: - existing.preferredPrefixes ?? "10., 192.168.", - autoSyncEnabled: existing.autoSyncEnabled ?? false, - syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, - markMissingGuests: existing.markMissingGuests ?? true, - }; - await hostRepository.updateForUser(userId, host.id, { - enableProxmox: true, - proxmoxConfig: JSON.stringify(merged), - }); + const scopeId = String(host.id); + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "enableProxmox", + JSON.stringify(updates.enableProxmox), + ); + + if (updates.enableProxmox) { + const existingRow = await pluginSettingsRepository.get( + "proxmox", + "host", + scopeId, + "proxmoxConfig", + ); + const existing = existingRow?.value + ? JSON.parse(existingRow.value) + : {}; + const merged = { + defaultCredentialId: + existing.defaultCredentialId ?? host.credentialId ?? null, + windowsPatterns: existing.windowsPatterns ?? "win, windows", + dockerPatterns: existing.dockerPatterns ?? "docker", + preferredPrefixes: + existing.preferredPrefixes ?? "10., 192.168.", + autoSyncEnabled: existing.autoSyncEnabled ?? false, + syncIntervalMinutes: existing.syncIntervalMinutes ?? 15, + markMissingGuests: existing.markMissingGuests ?? true, + }; + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "proxmoxConfig", + JSON.stringify(merged), + ); + } } catch { - errors.push(`Failed to enable Proxmox for host ${host.id}`); + errors.push( + `Failed to ${updates.enableProxmox ? "enable" : "disable"} Proxmox for host ${host.id}`, + ); } } } @@ -716,7 +738,6 @@ export function registerHostBulkRoutes( enableFileManager: hostData.enableFileManager !== false, enableDocker: hostData.enableDocker || false, enableWebUi: hostData.enableWebUi || false, - enableProxmox: hostData.enableProxmox || false, enableTmuxMonitor: hostData.enableTmuxMonitor || false, enableTerminalToolbar: hostData.enableTerminalToolbar !== false, enableAiAssistant: hostData.enableAiAssistant || false, @@ -744,9 +765,6 @@ export function registerHostBulkRoutes( webUiConfig: hostData.enableWebUi ? serializeWebUiConfig(hostData.webUiConfig) : null, - proxmoxConfig: hostData.proxmoxConfig - ? JSON.stringify(hostData.proxmoxConfig) - : null, terminalConfig: hostData.terminalConfig ? JSON.stringify(hostData.terminalConfig) : null, @@ -820,6 +838,7 @@ export function registerHostBulkRoutes( const lookupKey = `${hostData.ip}:${hostData.port}:${hostData.username}`; const existing = existingHostMap?.get(lookupKey); + let savedHostId: number; if (existing) { const saved = await hostRepository.updateEncryptedForUser( userId, @@ -828,6 +847,7 @@ export function registerHostBulkRoutes( ); if (!saved) throw new Error("Host no longer exists"); if (exportId !== undefined) importedIds.set(exportId, existing.id); + savedHostId = existing.id; results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); @@ -836,8 +856,31 @@ export function registerHostBulkRoutes( sshDataObj, ); if (exportId !== undefined) importedIds.set(exportId, saved.id); + savedHostId = saved.id; results.success++; } + + if (hostData.enableProxmox || hostData.proxmoxConfig) { + const pluginSettingsRepository = + createCurrentPluginSettingsRepository(); + const scopeId = String(savedHostId); + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "enableProxmox", + JSON.stringify(hostData.enableProxmox || false), + ); + if (hostData.proxmoxConfig) { + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "proxmoxConfig", + JSON.stringify(hostData.proxmoxConfig), + ); + } + } } catch (error) { results.failed++; results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); @@ -996,7 +1039,6 @@ export function registerHostBulkRoutes( enableFileManager: true, enableDocker: false, enableWebUi: false, - enableProxmox: false, enableTmuxMonitor: false, enableTerminalToolbar: true, enableAiAssistant: false, @@ -1015,7 +1057,6 @@ export function registerHostBulkRoutes( statsConfig: null, dockerConfig: null, webUiConfig: null, - proxmoxConfig: null, terminalConfig: null, forceKeyboardInteractive: "false", notes: null, diff --git a/src/backend/database/routes/host-normalizers.ts b/src/backend/database/routes/host-normalizers.ts index 3562b579e..c7f72ab01 100644 --- a/src/backend/database/routes/host-normalizers.ts +++ b/src/backend/database/routes/host-normalizers.ts @@ -366,8 +366,6 @@ const CONNECT_LEVEL_FIELDS = new Set([ "enableDocker", "enableWebUi", "webUiConfig", - "enableProxmox", - "enableProxmoxStats", "enableTmuxMonitor", "enableTerminalToolbar", "enableAiAssistant", @@ -470,8 +468,6 @@ export function transformHostResponse( enableFileManager: host.enableFileManager !== false, enableDocker: !!host.enableDocker, enableWebUi: !!host.enableWebUi, - enableProxmox: !!host.enableProxmox, - enableProxmoxStats: !!host.enableProxmoxStats, enableTmuxMonitor: !!host.enableTmuxMonitor, enableTerminalToolbar: host.enableTerminalToolbar !== false, enableAiAssistant: !!host.enableAiAssistant, @@ -526,12 +522,6 @@ export function transformHostResponse( // Guarded, unlike dockerConfig directly above: parseWebUiConfig never // throws, so a half-written config cannot take out the whole host listing. webUiConfig: parseWebUiConfig(host.webUiConfig), - proxmoxConfig: host.proxmoxConfig - ? JSON.parse(host.proxmoxConfig as string) - : undefined, - proxmoxStatsConfig: host.proxmoxStatsConfig - ? JSON.parse(host.proxmoxStatsConfig as string) - : undefined, forceKeyboardInteractive: host.forceKeyboardInteractive === "true", useWarpgate: !!host.useWarpgate, socks5ProxyChain: host.socks5ProxyChain diff --git a/src/backend/database/routes/host-plugin-settings.ts b/src/backend/database/routes/host-plugin-settings.ts index 8a6089016..a8ef6d6c9 100644 --- a/src/backend/database/routes/host-plugin-settings.ts +++ b/src/backend/database/routes/host-plugin-settings.ts @@ -128,3 +128,22 @@ export async function withHostPluginSettings( const values = settings.get(hostId); return values ? { ...host, pluginSettings: values } : host; } + +/** + * Writes a set of host-scope plugin settings in one call, for a core route + * that still accepts a plugin's fields inline on the host create/update body + * (the fields haven't grown their own editor UI flow yet). Values are + * JSON-stringified the same way ctx.settings.setHost stores them. + */ +export async function writeHostPluginSettings( + pluginId: string, + hostId: number, + values: Record, +): Promise { + const repository = createCurrentPluginSettingsRepository(); + const scopeId = String(hostId); + for (const [key, value] of Object.entries(values)) { + if (value === undefined) continue; + await repository.set(pluginId, "host", scopeId, key, JSON.stringify(value)); + } +} diff --git a/src/backend/database/routes/host.ts b/src/backend/database/routes/host.ts index 5a0e29aba..0a486349b 100644 --- a/src/backend/database/routes/host.ts +++ b/src/backend/database/routes/host.ts @@ -48,6 +48,7 @@ import { import { attachHostPluginSettings, loadHostPluginSettings, + writeHostPluginSettings, withHostPluginSettings, } from "./host-plugin-settings.js"; import { validateParentHostId } from "./host-parent-validation.js"; @@ -351,7 +352,6 @@ router.post( scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, enableWebUi: enableWebUi ? 1 : 0, - enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1, enableAiAssistant: enableAiAssistant ? 1 : 0, @@ -379,17 +379,6 @@ router.post( : webUiConfig, ) : null, - proxmoxConfig: proxmoxConfig - ? typeof proxmoxConfig === "string" - ? proxmoxConfig - : JSON.stringify(proxmoxConfig) - : null, - enableProxmoxStats: enableProxmoxStats ? 1 : 0, - proxmoxStatsConfig: proxmoxStatsConfig - ? typeof proxmoxStatsConfig === "string" - ? proxmoxStatsConfig - : JSON.stringify(proxmoxStatsConfig) - : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig @@ -522,6 +511,37 @@ router.post( } const createdHost = result; + + if ( + enableProxmox !== undefined || + proxmoxConfig !== undefined || + enableProxmoxStats !== undefined || + proxmoxStatsConfig !== undefined + ) { + try { + await writeHostPluginSettings("proxmox", createdHost.id as number, { + enableProxmox: !!enableProxmox, + proxmoxConfig: proxmoxConfig + ? typeof proxmoxConfig === "string" + ? safeParseJson(proxmoxConfig) + : proxmoxConfig + : null, + enableProxmoxStats: !!enableProxmoxStats, + proxmoxStatsConfig: proxmoxStatsConfig + ? typeof proxmoxStatsConfig === "string" + ? safeParseJson(proxmoxStatsConfig) + : proxmoxStatsConfig + : null, + }); + } catch (proxmoxSettingsError) { + sshLogger.warn("Failed to save Proxmox host settings", { + operation: "host_create_proxmox_settings", + hostId: createdHost.id, + error: getErrorMessage(proxmoxSettingsError), + }); + } + } + // Standing folder shares apply to the newcomer. try { await applyFolderAccessRules( @@ -784,8 +804,6 @@ router.post( enableFileManager: true, enableDocker: false, enableWebUi: false, - enableProxmox: false, - enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: true, enableAiAssistant: false, @@ -1065,7 +1083,6 @@ router.put( scpLegacy: scpLegacy ? 1 : 0, enableDocker: enableDocker ? 1 : 0, enableWebUi: enableWebUi ? 1 : 0, - enableProxmox: enableProxmox ? 1 : 0, enableTmuxMonitor: enableTmuxMonitor ? 1 : 0, enableTerminalToolbar: enableTerminalToolbar === false ? 0 : 1, enableAiAssistant: enableAiAssistant ? 1 : 0, @@ -1093,17 +1110,6 @@ router.put( : webUiConfig, ) : null, - proxmoxConfig: proxmoxConfig - ? typeof proxmoxConfig === "string" - ? proxmoxConfig - : JSON.stringify(proxmoxConfig) - : null, - enableProxmoxStats: enableProxmoxStats ? 1 : 0, - proxmoxStatsConfig: proxmoxStatsConfig - ? typeof proxmoxStatsConfig === "string" - ? proxmoxStatsConfig - : JSON.stringify(proxmoxStatsConfig) - : null, terminalConfig: terminalConfig ? typeof terminalConfig === "string" ? terminalConfig @@ -1413,6 +1419,36 @@ router.put( sshDataObj, ); + if ( + enableProxmox !== undefined || + proxmoxConfig !== undefined || + enableProxmoxStats !== undefined || + proxmoxStatsConfig !== undefined + ) { + try { + await writeHostPluginSettings("proxmox", Number(hostId), { + enableProxmox: !!enableProxmox, + proxmoxConfig: proxmoxConfig + ? typeof proxmoxConfig === "string" + ? safeParseJson(proxmoxConfig) + : proxmoxConfig + : null, + enableProxmoxStats: !!enableProxmoxStats, + proxmoxStatsConfig: proxmoxStatsConfig + ? typeof proxmoxStatsConfig === "string" + ? safeParseJson(proxmoxStatsConfig) + : proxmoxStatsConfig + : null, + }); + } catch (proxmoxSettingsError) { + sshLogger.warn("Failed to save Proxmox host settings", { + operation: "host_update_proxmox_settings", + hostId: parseInt(hostId), + error: getErrorMessage(proxmoxSettingsError), + }); + } + } + // A host that moved into a folder inherits that folder's standing shares. try { await applyFolderAccessRules( @@ -2067,6 +2103,16 @@ router.get( } const resolvedHost = (await resolveHostCredentials(host, userId)) || host; + const proxmoxSettings = ( + await loadHostPluginSettings([Number(hostId)]) + ).get(Number(hostId))?.proxmox as + | { + enableProxmox?: boolean; + proxmoxConfig?: unknown; + enableProxmoxStats?: boolean; + proxmoxStatsConfig?: unknown; + } + | undefined; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; @@ -2135,8 +2181,8 @@ router.get( scpLegacy: !!resolvedHost.scpLegacy, enableDocker: !!resolvedHost.enableDocker, enableWebUi: !!resolvedHost.enableWebUi, - enableProxmox: !!resolvedHost.enableProxmox, - enableProxmoxStats: !!resolvedHost.enableProxmoxStats, + enableProxmox: !!proxmoxSettings?.enableProxmox, + enableProxmoxStats: !!proxmoxSettings?.enableProxmoxStats, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false, enableAiAssistant: !!resolvedHost.enableAiAssistant, @@ -2163,12 +2209,8 @@ router.get( dockerConfig: resolvedHost.dockerConfig ? JSON.parse(resolvedHost.dockerConfig as string) : null, - proxmoxConfig: resolvedHost.proxmoxConfig - ? JSON.parse(resolvedHost.proxmoxConfig as string) - : null, - proxmoxStatsConfig: resolvedHost.proxmoxStatsConfig - ? JSON.parse(resolvedHost.proxmoxStatsConfig as string) - : null, + proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null, + proxmoxStatsConfig: proxmoxSettings?.proxmoxStatsConfig ?? null, terminalConfig: resolvedHost.terminalConfig ? JSON.parse(resolvedHost.terminalConfig as string) : null, @@ -2243,6 +2285,9 @@ router.get( try { const allHosts = await createCurrentHostResolutionRepository().findHostsByUserId(userId); + const proxmoxSettingsByHost = await loadHostPluginSettings( + allHosts.map((h) => h.id as number), + ); const exportedHosts = []; const usedCredentialIds = new Set(); @@ -2251,6 +2296,9 @@ router.get( const resolvedHost = shareMode ? host : (await resolveHostCredentials(host, userId)) || host; + const proxmoxSettings = proxmoxSettingsByHost.get(host.id as number) + ?.proxmox as + { enableProxmox?: boolean; proxmoxConfig?: unknown } | undefined; const exportedConnectionType = (resolvedHost.connectionType as string) || "ssh"; @@ -2300,7 +2348,7 @@ router.get( enableFileManager: resolvedHost.enableFileManager !== false, enableDocker: !!resolvedHost.enableDocker, enableWebUi: !!resolvedHost.enableWebUi, - enableProxmox: !!resolvedHost.enableProxmox, + enableProxmox: !!proxmoxSettings?.enableProxmox, enableTmuxMonitor: !!resolvedHost.enableTmuxMonitor, enableTerminalToolbar: resolvedHost.enableTerminalToolbar !== false, @@ -2330,9 +2378,7 @@ router.get( dockerConfig: resolvedHost.dockerConfig ? JSON.parse(resolvedHost.dockerConfig as string) : null, - proxmoxConfig: resolvedHost.proxmoxConfig - ? JSON.parse(resolvedHost.proxmoxConfig as string) - : null, + proxmoxConfig: proxmoxSettings?.proxmoxConfig ?? null, terminalConfig: resolvedHost.terminalConfig ? JSON.parse(resolvedHost.terminalConfig as string) : null, diff --git a/src/backend/hosts/metrics/proxmox-stats-history-routes.ts b/src/backend/hosts/metrics/proxmox-stats-history-routes.ts deleted file mode 100644 index 63a095a47..000000000 --- a/src/backend/hosts/metrics/proxmox-stats-history-routes.ts +++ /dev/null @@ -1,135 +0,0 @@ -import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { createCurrentProxmoxNodeHistoryRepository } from "../../database/repositories/factory.js"; -import { statsLogger } from "../../utils/logger.js"; -import type { HostAction } from "../../utils/permission-manager.js"; - -type ProxmoxStatsHistoryRoutesDeps = { - validateHostId: RequestHandler; - canAccessHost: ( - userId: string, - hostId: number, - level: HostAction, - ) => Promise; -}; - -const RANGE_OFFSETS: Record = { - "1h": 1 * 60 * 60 * 1000, - "6h": 6 * 60 * 60 * 1000, - "24h": 24 * 60 * 60 * 1000, - "7d": 7 * 24 * 60 * 60 * 1000, - "30d": 30 * 24 * 60 * 60 * 1000, -}; - -export function registerProxmoxStatsHistoryRoutes( - app: Express, - { validateHostId, canAccessHost }: ProxmoxStatsHistoryRoutesDeps, -): void { - /** - * @openapi - * /proxmox-stats/history/{hostId}: - * get: - * summary: Get historical Proxmox node stats for a host - * tags: - * - Proxmox Stats - * parameters: - * - in: path - * name: hostId - * required: true - * schema: - * type: integer - * - in: query - * name: range - * schema: - * type: string - * enum: [1h, 6h, 24h, 7d, 30d] - * - in: query - * name: from - * schema: - * type: string - * format: date-time - * - in: query - * name: to - * schema: - * type: string - * format: date-time - * responses: - * 200: - * description: Array of node history rows. - * 403: - * description: Access denied. - */ - app.get( - "/proxmox-stats/history/:hostId", - validateHostId, - async (req, res) => { - const hostId = Number(req.params.hostId); - const userId = (req as AuthenticatedRequest).userId; - - try { - const hasAccess = await canAccessHost(userId, hostId, "connect"); - if (!hasAccess) { - return res.status(403).json({ error: "Access denied" }); - } - - const { range, from, to } = req.query as Record< - string, - string | undefined - >; - - let fromTs: string; - let toTs: string = new Date().toISOString(); - - if (range) { - const offsetMs = RANGE_OFFSETS[range]; - if (!offsetMs) { - return res - .status(400) - .json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" }); - } - fromTs = new Date(Date.now() - offsetMs).toISOString(); - } else if (from && to) { - const fromDate = new Date(from); - const toDate = new Date(to); - if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) { - return res - .status(400) - .json({ error: "Invalid from/to date format" }); - } - fromTs = fromDate.toISOString(); - toTs = toDate.toISOString(); - } else { - fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); - } - - const toSqlite = (iso: string) => - iso.replace("T", " ").replace(/\.\d{3}Z$/, ""); - const rows = ( - await createCurrentProxmoxNodeHistoryRepository().listRange( - hostId, - toSqlite(fromTs), - toSqlite(toTs), - ) - ).map((row) => ({ - ts: row.ts, - cpu_percent: row.cpuPercent, - mem_percent: row.memPercent, - disk_percent: row.diskPercent, - net_rx_bytes: row.netRxBytes, - net_tx_bytes: row.netTxBytes, - })); - - res.json({ rows, fromTs, toTs }); - } catch (error) { - statsLogger.error("Failed to fetch proxmox stats history", { - operation: "proxmox_stats_history_fetch_error", - hostId, - error: error instanceof Error ? error.message : String(error), - }); - res - .status(500) - .json({ error: "Failed to fetch proxmox stats history" }); - } - }, - ); -} diff --git a/src/backend/hosts/metrics/proxmox-stats-polling.ts b/src/backend/hosts/metrics/proxmox-stats-polling.ts deleted file mode 100644 index b4a74cba7..000000000 --- a/src/backend/hosts/metrics/proxmox-stats-polling.ts +++ /dev/null @@ -1,332 +0,0 @@ -import { statsLogger } from "../../utils/logger.js"; -import { - createCurrentProxmoxNodeHistoryRepository, - getCurrentSettingValue, -} from "../../database/repositories/factory.js"; -import { ConcurrentLimiter } from "../metrics-shared/state.js"; -import { - collectProxmoxStats, - type ProxmoxStatsSnapshot, -} from "./proxmox/collect-proxmox-stats.js"; -import type { Client } from "ssh2"; - -/** - * Proxmox Stats polling gets its own limiter, separate from Host Metrics' - * metricsPollLimiter in state.ts, so a burst of Proxmox polls can never starve - * regular Host Metrics collection (or vice versa). - */ -const proxmoxStatsPollLimiter = new ConcurrentLimiter(5); - -export interface ProxmoxStatsPollableHost { - id: number; - userId: string; - proxmoxStatsConfig?: string | ProxmoxStatsPollConfig | null; -} - -export interface ProxmoxStatsPollConfig { - nodeName?: string | null; - pollInterval?: number; - enabledCards?: string[]; -} - -const DEFAULT_POLL_INTERVAL_SECONDS = 60; - -export function parseProxmoxStatsConfig( - raw: string | ProxmoxStatsPollConfig | null | undefined, -): ProxmoxStatsPollConfig { - if (!raw) { - return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS }; - } - if (typeof raw === "object") { - return { - nodeName: raw.nodeName ?? null, - pollInterval: raw.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS, - enabledCards: raw.enabledCards, - }; - } - try { - const parsed = JSON.parse(raw) as ProxmoxStatsPollConfig; - return { - nodeName: parsed.nodeName ?? null, - pollInterval: parsed.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS, - enabledCards: parsed.enabledCards, - }; - } catch { - return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS }; - } -} - -interface HostPollingEntry { - host: THost; - timer?: NodeJS.Timeout; - viewerUserId?: string; -} - -interface ViewerDetail { - sessionId: string; - userId: string; - hostId: number; - lastHeartbeat: number; -} - -interface CachedSnapshot { - data: ProxmoxStatsSnapshot; - timestamp: number; -} - -interface ErrorSnapshot { - error: string; - timestamp: number; -} - -export class ProxmoxPollingManager< - THost extends ProxmoxStatsPollableHost = ProxmoxStatsPollableHost, -> { - private pollingConfigs = new Map>(); - private snapshotStore = new Map(); - private errorStore = new Map(); - private activeViewers = new Map>(); - private viewerDetails = new Map(); - private inFlight = new Set(); - private viewerCleanupInterval: NodeJS.Timeout; - - constructor( - private readonly deps: { - fetchHostById: ( - hostId: number, - userId: string, - ) => Promise; - withSshConnection: ( - host: THost, - fn: (client: Client) => Promise, - ) => Promise; - historyEnabled?: () => boolean; - }, - ) { - this.viewerCleanupInterval = setInterval(() => { - this.cleanupInactiveViewers(); - }, 60000); - } - - private intervalWithJitter(intervalMs: number, hostId: number): number { - const spread = Math.min(intervalMs * 0.2, 15_000); - const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread)); - return intervalMs + jitter; - } - - private getRetentionDays(): number { - try { - const value = getCurrentSettingValue("metrics_history_retention_days"); - const days = value ? parseInt(value, 10) : 7; - return isNaN(days) || days < 1 ? 7 : Math.min(days, 90); - } catch { - return 7; - } - } - - private async pollHostStats( - host: THost, - viewerUserId?: string, - ): Promise { - if (this.inFlight.has(host.id)) return; - this.inFlight.add(host.id); - - try { - await proxmoxStatsPollLimiter.run(async () => { - const userId = viewerUserId || host.userId; - const refreshed = - (await this.deps.fetchHostById(host.id, userId)) ?? host; - const config = parseProxmoxStatsConfig(refreshed.proxmoxStatsConfig); - - try { - const snapshot = await this.deps.withSshConnection( - refreshed, - (client) => collectProxmoxStats(client, config.nodeName), - ); - - this.snapshotStore.set(host.id, { - data: snapshot, - timestamp: Date.now(), - }); - this.errorStore.delete(host.id); - - if (this.deps.historyEnabled?.() ?? true) { - await this.insertHistory(host.id, snapshot); - } - } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - this.errorStore.set(host.id, { - error: message, - timestamp: Date.now(), - }); - statsLogger.warn("Proxmox stats poll failed", { - operation: "proxmox_stats_poll_failed", - hostId: host.id, - error: message, - }); - } - }); - } finally { - this.inFlight.delete(host.id); - } - } - - private async insertHistory( - hostId: number, - snapshot: ProxmoxStatsSnapshot, - ): Promise { - try { - const iface = snapshot.network?.interfaces?.[0]; - const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null; - const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null; - - const repository = createCurrentProxmoxNodeHistoryRepository(); - await repository.create({ - hostId, - cpuPercent: snapshot.node?.cpu?.percent ?? null, - memPercent: snapshot.node?.memory?.percent ?? null, - diskPercent: snapshot.node?.disk?.percent ?? null, - netRxBytes: rxRaw !== null && !isNaN(rxRaw) ? rxRaw : null, - netTxBytes: txRaw !== null && !isNaN(txRaw) ? txRaw : null, - }); - - await repository.pruneOlderThan(hostId, this.getRetentionDays()); - } catch (err) { - statsLogger.warn("Failed to write proxmox node history", { - operation: "insert_proxmox_node_history", - hostId, - error: err instanceof Error ? err.message : String(err), - }); - } - } - - private startPollingForHost(host: THost, viewerUserId?: string): void { - const existing = this.pollingConfigs.get(host.id); - if (existing?.timer) { - clearInterval(existing.timer); - } - - const config = parseProxmoxStatsConfig(host.proxmoxStatsConfig); - const intervalMs = this.intervalWithJitter( - (config.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000, - host.id, - ); - - void this.pollHostStats(host, viewerUserId); - - const timer = setInterval(() => { - const latest = this.pollingConfigs.get(host.id); - if (latest) { - void this.pollHostStats(latest.host, latest.viewerUserId); - } - }, intervalMs); - - this.pollingConfigs.set(host.id, { host, timer, viewerUserId }); - } - - private stopPollingForHost(hostId: number): void { - const config = this.pollingConfigs.get(hostId); - if (config?.timer) { - clearInterval(config.timer); - } - this.pollingConfigs.delete(hostId); - } - - getStats( - hostId: number, - ): { data: ProxmoxStatsSnapshot; timestamp: number } | undefined { - return this.snapshotStore.get(hostId); - } - - getError(hostId: number): ErrorSnapshot | undefined { - return this.errorStore.get(hostId); - } - - async ensurePolling(host: THost, viewerUserId?: string): Promise { - if (!this.pollingConfigs.has(host.id)) { - this.startPollingForHost(host, viewerUserId); - } - if (!this.snapshotStore.has(host.id) && !this.inFlight.has(host.id)) { - await this.pollHostStats(host, viewerUserId); - } - } - - registerViewer = ( - hostId: number, - sessionId: string, - userId: string, - ): void => { - if (!this.activeViewers.has(hostId)) { - this.activeViewers.set(hostId, new Set()); - } - this.activeViewers.get(hostId)!.add(sessionId); - - this.viewerDetails.set(sessionId, { - sessionId, - userId, - hostId, - lastHeartbeat: Date.now(), - }); - - if (this.activeViewers.get(hostId)!.size === 1) { - Promise.resolve() - .then(async () => { - const host = await this.deps.fetchHostById(hostId, userId); - if (host) { - this.startPollingForHost(host, userId); - } - }) - .catch((err) => { - statsLogger.warn( - "Proxmox stats startPollingForHost rejected (non-fatal)", - { - operation: "proxmox_stats_start_unhandled", - hostId, - userId, - error: err instanceof Error ? err.message : String(err), - }, - ); - }); - } - }; - - unregisterViewer = (hostId: number, sessionId: string): void => { - const viewers = this.activeViewers.get(hostId); - if (viewers) { - viewers.delete(sessionId); - if (viewers.size === 0) { - this.activeViewers.delete(hostId); - this.stopPollingForHost(hostId); - } - } - this.viewerDetails.delete(sessionId); - }; - - updateHeartbeat(sessionId: string): boolean { - const viewer = this.viewerDetails.get(sessionId); - if (viewer) { - viewer.lastHeartbeat = Date.now(); - return true; - } - return false; - } - - private cleanupInactiveViewers(): void { - const now = Date.now(); - const maxInactivity = 120000; - - for (const [sessionId, viewer] of this.viewerDetails.entries()) { - if (now - viewer.lastHeartbeat > maxInactivity) { - this.unregisterViewer(viewer.hostId, sessionId); - } - } - } - - destroy(): void { - clearInterval(this.viewerCleanupInterval); - for (const hostId of this.pollingConfigs.keys()) { - this.stopPollingForHost(hostId); - } - } -} diff --git a/src/backend/hosts/metrics/proxmox-stats-routes.ts b/src/backend/hosts/metrics/proxmox-stats-routes.ts deleted file mode 100644 index 725c0e0b3..000000000 --- a/src/backend/hosts/metrics/proxmox-stats-routes.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { getErrorMessage } from "../../utils/error-message.js"; -import type { Express, RequestHandler } from "express"; -import type { AuthenticatedRequest } from "../../../types/index.js"; -import { statsLogger } from "../../utils/logger.js"; -import { DataCrypto } from "../../utils/data-crypto.js"; -import type { HostAction } from "../../utils/permission-manager.js"; -import { - type ProxmoxPollingManager, - type ProxmoxStatsPollableHost, -} from "./proxmox-stats-polling.js"; - -const EMPTY_SNAPSHOT = { - node: { - cpu: { percent: null, cores: null, load: null }, - memory: { percent: null, usedGiB: null, totalGiB: null }, - disk: { percent: null, usedGiB: null, totalGiB: null }, - uptime: { seconds: null, formatted: null }, - system: { hostname: null, kernel: null, pveVersion: null }, - }, - network: { interfaces: [] }, - guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } }, - storage: { pools: [] }, - cluster: { clustered: false }, - lastChecked: new Date(0).toISOString(), -}; - -type ProxmoxStatsHost = ProxmoxStatsPollableHost & { - enableProxmoxStats?: boolean; -}; - -type ProxmoxStatsRoutesDeps = { - validateHostId: RequestHandler; - fetchHostById: ( - hostId: number, - userId: string, - ) => Promise; - canAccessHost: ( - userId: string, - hostId: number, - level: HostAction, - ) => Promise; - pollingManager: ProxmoxPollingManager; -}; - -export function registerProxmoxStatsRoutes( - app: Express, - { - validateHostId, - fetchHostById, - canAccessHost, - pollingManager, - }: ProxmoxStatsRoutesDeps, -): void { - /** - * @openapi - * /proxmox-stats/{id}: - * get: - * summary: Get cached Proxmox node stats for a host - * description: Returns the most recently polled Proxmox Stats snapshot for a host, or an empty skeleton if none has been collected yet. - * tags: - * - Proxmox Stats - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Proxmox stats snapshot. - * 401: - * description: Session expired - please log in again. - * 404: - * description: Stats not available yet. - */ - app.get("/proxmox-stats/:id", validateHostId, async (req, res) => { - const id = Number(req.params.id); - const userId = (req as AuthenticatedRequest).userId; - - if (DataCrypto.getUserDataKey(userId) === null) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - - const cached = pollingManager.getStats(id); - if (!cached) { - const errorState = pollingManager.getError(id); - return res.status(404).json({ - error: errorState?.error || "Stats not available", - ...EMPTY_SNAPSHOT, - lastChecked: new Date().toISOString(), - }); - } - - res.json({ - ...cached.data, - lastChecked: new Date(cached.timestamp).toISOString(), - }); - }); - - /** - * @openapi - * /proxmox-stats/start/{id}: - * post: - * summary: Start Proxmox stats collection - * description: Registers a viewer and starts (or reuses) polling for a host's Proxmox node stats. - * tags: - * - Proxmox Stats - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * responses: - * 200: - * description: Polling started, snapshot returned if already available. - * 401: - * description: Session expired - please log in again. - * 403: - * description: Proxmox Stats is not enabled for this host. - * 404: - * description: Host not found. - */ - app.post("/proxmox-stats/start/:id", validateHostId, async (req, res) => { - const id = Number(req.params.id); - const userId = (req as AuthenticatedRequest).userId; - - if (DataCrypto.getUserDataKey(userId) === null) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - - try { - if (!(await canAccessHost(userId, id, "connect"))) { - return res.status(403).json({ error: "No access to this host" }); - } - - const host = await fetchHostById(id, userId); - if (!host) { - return res.status(404).json({ error: "Host not found" }); - } - - if (!host.enableProxmoxStats) { - return res - .status(403) - .json({ error: "Proxmox Stats is not enabled for this host" }); - } - - const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; - pollingManager.registerViewer(id, viewerSessionId, userId); - await pollingManager.ensurePolling(host, userId); - - const cached = pollingManager.getStats(id); - if (cached) { - return res.json({ - success: true, - viewerSessionId, - ...cached.data, - lastChecked: new Date(cached.timestamp).toISOString(), - }); - } - - const errorState = pollingManager.getError(id); - if (errorState) { - return res.json({ - success: true, - viewerSessionId, - status: "error", - error: errorState.error, - }); - } - - return res.json({ - success: true, - viewerSessionId, - status: "collecting", - }); - } catch (error) { - statsLogger.error("Failed to start proxmox stats collection", { - operation: "proxmox_stats_start_error", - hostId: id, - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ - error: getErrorMessage( - error, - "Failed to start proxmox stats collection", - ), - }); - } - }); - - /** - * @openapi - * /proxmox-stats/stop/{id}: - * post: - * summary: Stop Proxmox stats collection - * description: Unregisters a viewer session for a host's Proxmox node stats polling. - * tags: - * - Proxmox Stats - * parameters: - * - in: path - * name: id - * required: true - * schema: - * type: integer - * requestBody: - * required: false - * content: - * application/json: - * schema: - * type: object - * properties: - * viewerSessionId: - * type: string - * responses: - * 200: - * description: Polling stopped successfully. - * 401: - * description: Session expired - please log in again. - */ - app.post("/proxmox-stats/stop/:id", validateHostId, async (req, res) => { - const id = Number(req.params.id); - const userId = (req as AuthenticatedRequest).userId; - const { viewerSessionId } = req.body as { viewerSessionId?: string }; - - if (DataCrypto.getUserDataKey(userId) === null) { - return res.status(401).json({ - error: "Session expired - please log in again", - code: "SESSION_EXPIRED", - }); - } - - try { - if (viewerSessionId && typeof viewerSessionId === "string") { - pollingManager.unregisterViewer(id, viewerSessionId); - } - res.json({ success: true }); - } catch (error) { - statsLogger.error("Failed to stop proxmox stats collection", { - operation: "proxmox_stats_stop_error", - hostId: id, - error: error instanceof Error ? error.message : String(error), - }); - res.status(500).json({ - error: getErrorMessage( - error, - "Failed to stop proxmox stats collection", - ), - }); - } - }); -} diff --git a/src/backend/hosts/metrics/proxmox/cluster-health-collector.ts b/src/backend/hosts/metrics/proxmox/cluster-health-collector.ts deleted file mode 100644 index 306121a56..000000000 --- a/src/backend/hosts/metrics/proxmox/cluster-health-collector.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand } from "../../metrics-shared/common-utils.js"; - -export interface ProxmoxClusterNodeEntry { - name: string; - online: boolean; - local: boolean; - ip: string | null; -} - -export type ProxmoxClusterHealthResult = - | { clustered: false } - | { - clustered: true; - quorate: boolean; - clusterName: string | null; - nodes: ProxmoxClusterNodeEntry[]; - }; - -const EMPTY_RESULT: ProxmoxClusterHealthResult = { clustered: false }; - -export async function collectProxmoxClusterHealth( - client: Client, -): Promise { - try { - const { stdout, code } = await execCommand( - client, - "pvesh get /cluster/status --output-format json", - 15000, - ); - if (code !== 0) { - return EMPTY_RESULT; - } - - const data = JSON.parse(stdout); - if (!Array.isArray(data)) { - return EMPTY_RESULT; - } - - const entries = data as Array>; - const clusterEntry = entries.find((e) => e.type === "cluster"); - if (!clusterEntry) { - return EMPTY_RESULT; - } - - const nodes: ProxmoxClusterNodeEntry[] = entries - .filter((e) => e.type === "node") - .map((e) => ({ - name: typeof e.name === "string" ? e.name : "", - online: e.online === 1 || e.online === true, - local: e.local === 1 || e.local === true, - ip: typeof e.ip === "string" && e.ip ? e.ip : null, - })); - - return { - clustered: true, - quorate: clusterEntry.quorate === 1 || clusterEntry.quorate === true, - clusterName: - typeof clusterEntry.name === "string" && clusterEntry.name - ? clusterEntry.name - : null, - nodes, - }; - } catch { - return EMPTY_RESULT; - } -} diff --git a/src/backend/hosts/metrics/proxmox/collect-proxmox-stats.ts b/src/backend/hosts/metrics/proxmox/collect-proxmox-stats.ts deleted file mode 100644 index 6e6237d1e..000000000 --- a/src/backend/hosts/metrics/proxmox/collect-proxmox-stats.ts +++ /dev/null @@ -1,86 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand } from "../../metrics-shared/common-utils.js"; -import { isSafeNodeName } from "../../proxmox-shared.js"; -import { - collectProxmoxNodeStatus, - type ProxmoxNodeStatusResult, -} from "./node-status-collector.js"; -import { - collectProxmoxNodeNetwork, - type ProxmoxNodeNetworkResult, -} from "./node-network-collector.js"; -import { - collectProxmoxGuestsSummary, - type ProxmoxGuestsSummaryResult, -} from "./guests-collector.js"; -import { - collectProxmoxStorage, - type ProxmoxStorageResult, -} from "./storage-collector.js"; -import { - collectProxmoxClusterHealth, - type ProxmoxClusterHealthResult, -} from "./cluster-health-collector.js"; - -export interface ProxmoxStatsSnapshot { - node: ProxmoxNodeStatusResult; - network: ProxmoxNodeNetworkResult; - guests: ProxmoxGuestsSummaryResult; - storage: ProxmoxStorageResult; - cluster: ProxmoxClusterHealthResult; - lastChecked: string; -} - -async function resolveNodeName( - client: Client, - configuredNodeName: string | null | undefined, -): Promise { - if (configuredNodeName) { - // An explicitly configured name is validated and used as-is - an unsafe - // value is rejected outright rather than silently falling back to - // auto-detection, which would mask a misconfigured (or malicious) override. - if (!isSafeNodeName(configuredNodeName)) { - throw new Error("Unable to determine a valid Proxmox node name"); - } - return configuredNodeName; - } - - const { stdout } = await execCommand(client, "hostname", 10000); - return stdout.trim(); -} - -export async function collectProxmoxStats( - client: Client, - configuredNodeName: string | null | undefined, -): Promise { - const pveshCheck = await execCommand( - client, - "command -v pvesh >/dev/null 2>&1 && echo ok || echo missing", - 10000, - ); - if (pveshCheck.stdout.trim() !== "ok") { - throw new Error("pvesh not found — is this a Proxmox node?"); - } - - const nodeName = await resolveNodeName(client, configuredNodeName); - if (!isSafeNodeName(nodeName)) { - throw new Error("Unable to determine a valid Proxmox node name"); - } - - const [node, network, guests, storage, cluster] = await Promise.all([ - collectProxmoxNodeStatus(client, nodeName), - collectProxmoxNodeNetwork(client, nodeName), - collectProxmoxGuestsSummary(client, nodeName), - collectProxmoxStorage(client, nodeName), - collectProxmoxClusterHealth(client), - ]); - - return { - node, - network, - guests, - storage, - cluster, - lastChecked: new Date().toISOString(), - }; -} diff --git a/src/backend/hosts/metrics/proxmox/guests-collector.ts b/src/backend/hosts/metrics/proxmox/guests-collector.ts deleted file mode 100644 index 11781dcff..000000000 --- a/src/backend/hosts/metrics/proxmox/guests-collector.ts +++ /dev/null @@ -1,113 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js"; -import { isSafeNodeName } from "../../proxmox-shared.js"; - -export interface ProxmoxGuestSummaryEntry { - vmid: number; - name: string; - type: "qemu" | "lxc"; - status: string; - cpuPercent: number | null; - memPercent: number | null; - memUsedGiB: number | null; - memTotalGiB: number | null; - diskPercent: number | null; - diskUsedGiB: number | null; - diskTotalGiB: number | null; - uptimeSeconds: number | null; -} - -export interface ProxmoxGuestsSummaryResult { - guests: ProxmoxGuestSummaryEntry[]; - counts: { running: number; stopped: number; total: number }; -} - -const EMPTY_RESULT: ProxmoxGuestsSummaryResult = { - guests: [], - counts: { running: 0, stopped: 0, total: 0 }, -}; - -function bytesToGiB(bytes: number): number { - return bytes / (1024 * 1024 * 1024); -} - -export async function collectProxmoxGuestsSummary( - client: Client, - nodeName: string, -): Promise { - if (!isSafeNodeName(nodeName)) { - return EMPTY_RESULT; - } - - try { - const { stdout, code } = await execCommand( - client, - "pvesh get /cluster/resources --output-format json", - 25000, - ); - if (code !== 0) { - return EMPTY_RESULT; - } - - const resources = JSON.parse(stdout); - if (!Array.isArray(resources)) { - return EMPTY_RESULT; - } - - const guests: ProxmoxGuestSummaryEntry[] = []; - for (const r of resources as Array>) { - const type = r.type; - if (type !== "qemu" && type !== "lxc") continue; - if (r.node !== nodeName) continue; - if (r.template === true || r.template === 1) continue; - - const cpuFraction = typeof r.cpu === "number" ? r.cpu : null; - const mem = typeof r.mem === "number" ? r.mem : null; - const maxmem = typeof r.maxmem === "number" ? r.maxmem : null; - const memPercent = - mem !== null && maxmem !== null && maxmem > 0 - ? Math.max(0, Math.min(100, (mem / maxmem) * 100)) - : null; - - const disk = typeof r.disk === "number" ? r.disk : null; - const maxdisk = typeof r.maxdisk === "number" ? r.maxdisk : null; - // QEMU guests without a running agent report maxdisk: 0 - a false 0% - // is worse than an honest "unknown", so treat that as no data at all. - const hasDiskData = maxdisk !== null && maxdisk > 0; - const diskPercent = - hasDiskData && disk !== null - ? Math.max(0, Math.min(100, (disk / maxdisk) * 100)) - : null; - - guests.push({ - vmid: typeof r.vmid === "number" ? r.vmid : Number(r.vmid), - name: - typeof r.name === "string" && r.name ? r.name : String(r.vmid ?? ""), - type, - status: typeof r.status === "string" ? r.status : "unknown", - cpuPercent: toFixedNum( - cpuFraction !== null ? cpuFraction * 100 : null, - 0, - ), - memPercent: toFixedNum(memPercent, 0), - memUsedGiB: mem !== null ? toFixedNum(bytesToGiB(mem), 2) : null, - memTotalGiB: maxmem !== null ? toFixedNum(bytesToGiB(maxmem), 2) : null, - diskPercent: toFixedNum(diskPercent, 0), - diskUsedGiB: - hasDiskData && disk !== null ? toFixedNum(bytesToGiB(disk), 2) : null, - diskTotalGiB: hasDiskData ? toFixedNum(bytesToGiB(maxdisk), 2) : null, - uptimeSeconds: typeof r.uptime === "number" ? r.uptime : null, - }); - } - - const running = guests.filter((g) => g.status === "running").length; - const stopped = guests.filter((g) => g.status !== "running").length; - - return { - guests, - counts: { running, stopped, total: guests.length }, - }; - } catch { - return EMPTY_RESULT; - } -} diff --git a/src/backend/hosts/metrics/proxmox/node-network-collector.ts b/src/backend/hosts/metrics/proxmox/node-network-collector.ts deleted file mode 100644 index a0746deee..000000000 --- a/src/backend/hosts/metrics/proxmox/node-network-collector.ts +++ /dev/null @@ -1,145 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand } from "../../metrics-shared/common-utils.js"; -import { isSafeNodeName } from "../../proxmox-shared.js"; - -export interface ProxmoxNodeNetworkInterface { - name: string; - ip: string | null; - state: string | null; - rxBytes: string | null; - txBytes: string | null; -} - -export interface ProxmoxNodeNetworkResult { - interfaces: ProxmoxNodeNetworkInterface[]; -} - -const EMPTY_RESULT: ProxmoxNodeNetworkResult = { interfaces: [] }; - -async function collectFromProcNetDev( - client: Client, -): Promise { - const interfaces: ProxmoxNodeNetworkInterface[] = []; - - try { - const [addrOut, stateOut, procNetOut] = await Promise.all([ - execCommand( - client, - "ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'", - ), - execCommand( - client, - "ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'", - ), - execCommand(client, "cat /proc/net/dev"), - ]); - - const ifMap = new Map< - string, - { ip: string | null; state: string | null } - >(); - for (const line of addrOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const name = parts[0]; - const ip = parts[1].split("/")[0]; - if (!ifMap.has(name)) ifMap.set(name, { ip, state: null }); - } - } - for (const line of stateOut.stdout - .split("\n") - .map((l) => l.trim()) - .filter(Boolean)) { - const parts = line.split(/\s+/); - if (parts.length >= 2) { - const existing = ifMap.get(parts[0]); - if (existing) existing.state = parts[1]; - } - } - - const rxTxMap = new Map(); - for (const line of procNetOut.stdout.split("\n").slice(2)) { - const parts = line.trim().split(/\s+/); - if (parts.length >= 10) { - const ifName = parts[0].replace(":", ""); - rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] }); - } - } - - for (const [name, data] of ifMap.entries()) { - const rxTx = rxTxMap.get(name); - interfaces.push({ - name, - ip: data.ip, - state: data.state, - rxBytes: rxTx?.rx ?? null, - txBytes: rxTx?.tx ?? null, - }); - } - } catch { - return EMPTY_RESULT; - } - - return { interfaces }; -} - -export async function collectProxmoxNodeNetwork( - client: Client, - nodeName: string, -): Promise { - if (!isSafeNodeName(nodeName)) { - return EMPTY_RESULT; - } - - try { - const { stdout, code } = await execCommand( - client, - `pvesh get /nodes/${nodeName}/netstat --output-format json`, - 15000, - ); - if (code !== 0) { - return collectFromProcNetDev(client); - } - - const data = JSON.parse(stdout); - if (!Array.isArray(data)) { - return collectFromProcNetDev(client); - } - - const interfaces: ProxmoxNodeNetworkInterface[] = data - .filter( - (entry): entry is Record => - !!entry && typeof entry === "object", - ) - .map((entry) => ({ - name: - typeof entry.dev === "string" ? entry.dev : String(entry.dev ?? ""), - ip: null, - state: null, - rxBytes: - typeof entry.in === "number" - ? String(entry.in) - : typeof entry.received === "number" - ? String(entry.received) - : null, - txBytes: - typeof entry.out === "number" - ? String(entry.out) - : typeof entry.transmitted === "number" - ? String(entry.transmitted) - : null, - })) - .filter((iface) => iface.name && iface.name !== "lo"); - - if (interfaces.length === 0) { - return collectFromProcNetDev(client); - } - - return { interfaces }; - } catch { - return collectFromProcNetDev(client); - } -} diff --git a/src/backend/hosts/metrics/proxmox/node-status-collector.ts b/src/backend/hosts/metrics/proxmox/node-status-collector.ts deleted file mode 100644 index d0b0ab80d..000000000 --- a/src/backend/hosts/metrics/proxmox/node-status-collector.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js"; -import { isSafeNodeName } from "../../proxmox-shared.js"; - -export interface ProxmoxNodeStatusResult { - cpu: { - percent: number | null; - cores: number | null; - load: [number, number, number] | null; - }; - memory: { - percent: number | null; - usedGiB: number | null; - totalGiB: number | null; - }; - disk: { - percent: number | null; - usedGiB: number | null; - totalGiB: number | null; - }; - uptime: { - seconds: number | null; - formatted: string | null; - }; - system: { - hostname: string | null; - kernel: string | null; - pveVersion: string | null; - }; -} - -const EMPTY_RESULT: ProxmoxNodeStatusResult = { - cpu: { percent: null, cores: null, load: null }, - memory: { percent: null, usedGiB: null, totalGiB: null }, - disk: { percent: null, usedGiB: null, totalGiB: null }, - uptime: { seconds: null, formatted: null }, - system: { hostname: null, kernel: null, pveVersion: null }, -}; - -function bytesToGiB(bytes: number): number { - return bytes / (1024 * 1024 * 1024); -} - -function formatUptime(seconds: number): string { - const days = Math.floor(seconds / 86400); - const hours = Math.floor((seconds % 86400) / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - return `${days}d ${hours}h ${minutes}m`; -} - -export async function collectProxmoxNodeStatus( - client: Client, - nodeName: string, -): Promise { - if (!isSafeNodeName(nodeName)) { - return EMPTY_RESULT; - } - - try { - const { stdout, code } = await execCommand( - client, - `pvesh get /nodes/${nodeName}/status --output-format json`, - 25000, - ); - if (code !== 0) { - return EMPTY_RESULT; - } - - const data = JSON.parse(stdout) as Record; - - const cpuFraction = typeof data.cpu === "number" ? data.cpu : null; - const cpuinfo = (data.cpuinfo as Record) || {}; - const cores = typeof cpuinfo.cores === "number" ? cpuinfo.cores : null; - const loadavgRaw = data.loadavg; - let load: [number, number, number] | null = null; - if (Array.isArray(loadavgRaw) && loadavgRaw.length >= 3) { - const parsed = loadavgRaw - .slice(0, 3) - .map((v) => Number(v)) - .map((v) => (Number.isFinite(v) ? v : 0)); - load = parsed as [number, number, number]; - } - - const memory = (data.memory as Record) || {}; - const memUsed = typeof memory.used === "number" ? memory.used : null; - const memTotal = typeof memory.total === "number" ? memory.total : null; - const memPercent = - memUsed !== null && memTotal !== null && memTotal > 0 - ? Math.max(0, Math.min(100, (memUsed / memTotal) * 100)) - : null; - - const rootfs = (data.rootfs as Record) || {}; - const diskUsed = typeof rootfs.used === "number" ? rootfs.used : null; - const diskTotal = typeof rootfs.total === "number" ? rootfs.total : null; - const diskPercent = - diskUsed !== null && diskTotal !== null && diskTotal > 0 - ? Math.max(0, Math.min(100, (diskUsed / diskTotal) * 100)) - : null; - - const uptimeSeconds = typeof data.uptime === "number" ? data.uptime : null; - - return { - cpu: { - percent: toFixedNum(cpuFraction !== null ? cpuFraction * 100 : null, 0), - cores, - load, - }, - memory: { - percent: toFixedNum(memPercent, 0), - usedGiB: memUsed !== null ? toFixedNum(bytesToGiB(memUsed), 2) : null, - totalGiB: - memTotal !== null ? toFixedNum(bytesToGiB(memTotal), 2) : null, - }, - disk: { - percent: toFixedNum(diskPercent, 0), - usedGiB: diskUsed !== null ? toFixedNum(bytesToGiB(diskUsed), 2) : null, - totalGiB: - diskTotal !== null ? toFixedNum(bytesToGiB(diskTotal), 2) : null, - }, - uptime: { - seconds: uptimeSeconds, - formatted: uptimeSeconds !== null ? formatUptime(uptimeSeconds) : null, - }, - system: { - hostname: - typeof data.hostname === "string" && data.hostname - ? data.hostname - : null, - kernel: - typeof data.kversion === "string" && data.kversion - ? data.kversion - : null, - pveVersion: - typeof data.pveversion === "string" && data.pveversion - ? data.pveversion - : null, - }, - }; - } catch { - return EMPTY_RESULT; - } -} diff --git a/src/backend/hosts/metrics/proxmox/storage-collector.ts b/src/backend/hosts/metrics/proxmox/storage-collector.ts deleted file mode 100644 index 0f4c136fa..000000000 --- a/src/backend/hosts/metrics/proxmox/storage-collector.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type { Client } from "ssh2"; -import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js"; -import { isSafeNodeName } from "../../proxmox-shared.js"; - -export interface ProxmoxStoragePoolEntry { - name: string; - type: string; - active: boolean; - enabled: boolean; - usedGiB: number | null; - totalGiB: number | null; - availGiB: number | null; - percent: number | null; -} - -export interface ProxmoxStorageResult { - pools: ProxmoxStoragePoolEntry[]; -} - -const EMPTY_RESULT: ProxmoxStorageResult = { pools: [] }; - -function bytesToGiB(bytes: number): number { - return bytes / (1024 * 1024 * 1024); -} - -export async function collectProxmoxStorage( - client: Client, - nodeName: string, -): Promise { - if (!isSafeNodeName(nodeName)) { - return EMPTY_RESULT; - } - - try { - const { stdout, code } = await execCommand( - client, - `pvesh get /nodes/${nodeName}/storage --output-format json`, - 25000, - ); - if (code !== 0) { - return EMPTY_RESULT; - } - - const data = JSON.parse(stdout); - if (!Array.isArray(data)) { - return EMPTY_RESULT; - } - - const pools: ProxmoxStoragePoolEntry[] = ( - data as Array> - ).map((entry) => { - const used = typeof entry.used === "number" ? entry.used : null; - const total = typeof entry.total === "number" ? entry.total : null; - const avail = typeof entry.avail === "number" ? entry.avail : null; - const percent = - used !== null && total !== null && total > 0 - ? Math.max(0, Math.min(100, (used / total) * 100)) - : null; - - return { - name: typeof entry.storage === "string" ? entry.storage : "", - type: typeof entry.type === "string" ? entry.type : "unknown", - active: entry.active === 1 || entry.active === true, - enabled: entry.enabled === 1 || entry.enabled === true, - usedGiB: used !== null ? toFixedNum(bytesToGiB(used), 2) : null, - totalGiB: total !== null ? toFixedNum(bytesToGiB(total), 2) : null, - availGiB: avail !== null ? toFixedNum(bytesToGiB(avail), 2) : null, - percent: toFixedNum(percent, 0), - }; - }); - - return { pools }; - } catch { - return EMPTY_RESULT; - } -} diff --git a/src/backend/hosts/proxmox-shared.ts b/src/backend/hosts/proxmox-shared.ts deleted file mode 100644 index 112c9f007..000000000 --- a/src/backend/hosts/proxmox-shared.ts +++ /dev/null @@ -1,7 +0,0 @@ -// Proxmox node names are restricted to [a-zA-Z0-9-] by PVE itself, -// but we validate defensively before using in a shell command. -const SAFE_NODE_RE = /^[a-zA-Z0-9._-]{1,64}$/; - -export function isSafeNodeName(name: string): boolean { - return SAFE_NODE_RE.test(name); -} diff --git a/src/backend/plugins/ctx-hosts.ts b/src/backend/plugins/ctx-hosts.ts index 41c1bed26..f28469f2a 100644 --- a/src/backend/plugins/ctx-hosts.ts +++ b/src/backend/plugins/ctx-hosts.ts @@ -10,6 +10,9 @@ import type { PluginHosts, PluginHostSummary, + PluginHostRecord, + PluginHostCreateInput, + PluginHostUpdateInput, PluginHostAccess, PluginHostShareLevel, PluginHostShareResult, @@ -56,6 +59,34 @@ function toSummary(host: { }; } +function toRecord(host: Record): PluginHostRecord { + return { + id: host.id as number, + userId: host.userId as string, + name: (host.name as string | null) ?? null, + ip: host.ip as string, + port: host.port as number, + username: host.username as string, + authType: host.authType as string, + credentialId: (host.credentialId as number | null) ?? null, + overrideCredentialUsername: + (host.overrideCredentialUsername as boolean | null) ?? null, + connectionType: (host.connectionType as string | null) ?? null, + tags: (host.tags as string | null) ?? null, + folder: (host.folder as string | null) ?? null, + jumpHosts: host.jumpHosts, + enableSsh: (host.enableSsh as boolean | null) ?? null, + enableRdp: (host.enableRdp as boolean | null) ?? null, + enableTerminal: (host.enableTerminal as boolean | null) ?? null, + enableFileManager: (host.enableFileManager as boolean | null) ?? null, + enableTunnel: (host.enableTunnel as boolean | null) ?? null, + enableDocker: (host.enableDocker as boolean | null) ?? null, + createdAt: (host.createdAt as string | null) ?? null, + updatedAt: (host.updatedAt as string | null) ?? null, + ...host, + }; +} + function actingUser(): string { const actor = getActor(); if (!actor) { @@ -154,6 +185,70 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts { }; }, + create: async (host: PluginHostCreateInput): Promise => { + try { + await requireWrite(); + } catch (error) { + await audit("hosts_create", host.name ?? host.ip, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + const userId = actingUser(); + const { createCurrentHostRepository } = + await import("../database/repositories/factory.js"); + const created = + await createCurrentHostRepository().createEncryptedForUser(userId, { + ...host, + userId, + }); + await audit("hosts_create", `host ${created.id}`, { success: true }); + return toRecord(created as unknown as Record); + }, + + update: async ( + hostId: number, + patch: PluginHostUpdateInput, + ): Promise => { + try { + await requireWrite(); + } catch (error) { + await audit("hosts_update", `host ${hostId}`, { + success: false, + errorMessage: error instanceof Error ? error.message : String(error), + }); + throw error; + } + const userId = actingUser(); + const { createCurrentHostRepository } = + await import("../database/repositories/factory.js"); + const updated = + await createCurrentHostRepository().updateEncryptedForUser( + userId, + hostId, + patch, + ); + await audit("hosts_update", `host ${hostId}`, { + success: updated !== null, + }); + return updated + ? toRecord(updated as unknown as Record) + : null; + }, + + listOwned: async (): Promise => { + await requireWrite(); + const userId = actingUser(); + const { createCurrentHostRepository } = + await import("../database/repositories/factory.js"); + const rows = + await createCurrentHostRepository().listDecryptedByUserId(userId); + return rows.map((row) => + toRecord(row as unknown as Record), + ); + }, + share: async ( hostId: number, targets: PluginShareTarget[], diff --git a/src/backend/starter.ts b/src/backend/starter.ts index 3349beab0..76da05452 100644 --- a/src/backend/starter.ts +++ b/src/backend/starter.ts @@ -346,6 +346,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise { const { runTailscaleSettingsMigration } = await import("./utils/crypto-migration/tailscale-settings-migration.js"); await runTailscaleSettingsMigration(); + + const { runProxmoxSettingsMigration } = + await import("./utils/crypto-migration/proxmox-settings-migration.js"); + await runProxmoxSettingsMigration(); } catch (error) { systemLogger.warn("Plugin runtime failed to initialize", { operation: "plugin_init", diff --git a/src/backend/tests/database/routes/host-normalizers.test.ts b/src/backend/tests/database/routes/host-normalizers.test.ts index d6846e820..f9ba1f89d 100644 --- a/src/backend/tests/database/routes/host-normalizers.test.ts +++ b/src/backend/tests/database/routes/host-normalizers.test.ts @@ -337,24 +337,6 @@ describe("transformHostResponse", () => { expect(result.vncPort).toBe(5900); expect(result.telnetPort).toBe(23); }); - - it("coerces enableProxmox and parses proxmoxConfig", () => { - const result = transformHostResponse({ - enableProxmox: 1, - proxmoxConfig: '{"defaultCredentialId":3,"windowsPatterns":"win"}', - }); - expect(result.enableProxmox).toBe(true); - expect(result.proxmoxConfig).toEqual({ - defaultCredentialId: 3, - windowsPatterns: "win", - }); - }); - - it("defaults enableProxmox to false when absent", () => { - const result = transformHostResponse({ port: 22 }); - expect(result.enableProxmox).toBe(false); - expect(result.proxmoxConfig).toBeUndefined(); - }); }); describe("sanitizeHostForRecipient", () => { diff --git a/src/backend/tests/plugins/ctx-hosts.test.ts b/src/backend/tests/plugins/ctx-hosts.test.ts index 7161e2b2e..0239648a6 100644 --- a/src/backend/tests/plugins/ctx-hosts.test.ts +++ b/src/backend/tests/plugins/ctx-hosts.test.ts @@ -14,6 +14,9 @@ const h = vi.hoisted(() => ({ ownerId: "user-1", hostById: null as Record | null, upserts: [] as Array>, + created: [] as Array>, + updates: [] as Array>, + updateResult: null as Record | null, users: [] as Array<{ id: string; username: string }>, roles: [] as Array<{ id: number; @@ -66,6 +69,26 @@ vi.mock("../../database/repositories/factory.js", () => ({ createCurrentUserRepository: () => ({ listAll: async () => h.users, }), + createCurrentHostRepository: () => ({ + createEncryptedForUser: async ( + userId: string, + host: Record, + ) => { + const created = { id: 99, ...host, userId }; + h.created.push(created); + return created; + }, + updateEncryptedForUser: async ( + userId: string, + hostId: number, + patch: Record, + ) => { + h.updates.push({ userId, hostId, patch }); + if (!h.updateResult) return null; + return { ...h.updateResult, ...patch, id: hostId, userId }; + }, + listDecryptedByUserId: async () => h.ownedHosts, + }), })); vi.mock("../../utils/shared-host-secrets-manager.js", () => ({ SharedHostSecretsManager: { @@ -98,6 +121,9 @@ beforeEach(() => { h.ownerId = "user-1"; h.hostById = null; h.upserts = []; + h.created = []; + h.updates = []; + h.updateResult = null; h.users = []; h.roles = []; }); @@ -241,4 +267,83 @@ describe("ctx.hosts", () => { { id: 2, name: "auditor", displayName: "Auditor" }, ]); }); + + it("refuses create/update/listOwned without hosts:write", async () => { + const audit = vi.fn(async () => {}); + const hosts = createPluginHosts({ manifest: manifest([]), audit }); + await expect( + hosts.create({ + name: "n", + ip: "10.0.0.1", + port: 22, + username: "root", + authType: "password", + }), + ).rejects.toBeInstanceOf(PluginCapabilityError); + await expect(hosts.update(1, { name: "x" })).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + await expect(hosts.listOwned()).rejects.toBeInstanceOf( + PluginCapabilityError, + ); + expect(audit).toHaveBeenCalledWith( + "hosts_create", + expect.any(String), + expect.objectContaining({ success: false }), + ); + }); + + it("creates a host once granted hosts:write", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + const created = await hosts.create({ + name: "pve-guest", + ip: "10.0.0.5", + port: 22, + username: "root", + authType: "password", + }); + expect(created).toMatchObject({ + id: 99, + name: "pve-guest", + userId: "user-1", + }); + expect(h.created).toHaveLength(1); + }); + + it("updates a host once granted hosts:write", async () => { + h.granted = new Set(["hosts:write"]); + h.updateResult = { id: 5, name: "old" }; + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + const updated = await hosts.update(5, { name: "new" }); + expect(updated).toMatchObject({ id: 5, name: "new" }); + expect(h.updates).toEqual([ + { userId: "user-1", hostId: 5, patch: { name: "new" } }, + ]); + }); + + it("returns null updating a host that does not exist for this user", async () => { + h.granted = new Set(["hosts:write"]); + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + expect(await hosts.update(404, { name: "x" })).toBeNull(); + }); + + it("lists owned hosts decrypted once granted hosts:write", async () => { + h.granted = new Set(["hosts:write"]); + h.ownedHosts = [{ id: 1, userId: "user-1", name: "own" }]; + const hosts = createPluginHosts({ + manifest: manifest(["hosts:write"]), + audit: vi.fn(async () => {}), + }); + expect(await hosts.listOwned()).toMatchObject([{ id: 1, name: "own" }]); + }); }); diff --git a/src/backend/tests/utils/proxmox-settings-migration.test.ts b/src/backend/tests/utils/proxmox-settings-migration.test.ts new file mode 100644 index 000000000..f34657f6a --- /dev/null +++ b/src/backend/tests/utils/proxmox-settings-migration.test.ts @@ -0,0 +1,171 @@ +/** + * The Proxmox host-columns migration. + * + * An upgrade must be lossless: a host that already had Proxmox discovery or + * stats configured must keep that configuration once the ssh_data columns + * are dropped. Running it twice must not duplicate or clobber what it moved. + */ + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +interface HostRow { + id: number; + enable_proxmox: boolean; + proxmox_config: string | null; + enable_proxmox_stats: boolean; + proxmox_stats_config: string | null; +} + +interface SettingsRow { + pluginId: string; + scope: string; + scopeId: string | null; + key: string; + value: string | null; +} + +const hostRows: HostRow[] = []; +const settingsRows: SettingsRow[] = []; + +const pluginSettingsRepository = { + get: async ( + pluginId: string, + scope: string, + scopeId: string | null, + key: string, + ) => + settingsRows.find( + (row) => + row.pluginId === pluginId && + row.scope === scope && + row.scopeId === scopeId && + row.key === key, + ) ?? null, + set: async ( + pluginId: string, + scope: string, + scopeId: string | null, + key: string, + value: string | null, + ) => { + const existing = settingsRows.find( + (row) => + row.pluginId === pluginId && + row.scope === scope && + row.scopeId === scopeId && + row.key === key, + ); + if (existing) { + existing.value = value; + return; + } + settingsRows.push({ pluginId, scope, scopeId, key, value }); + }, +}; + +vi.mock("../../database/repositories/factory.js", () => ({ + createCurrentPluginSettingsRepository: () => pluginSettingsRepository, +})); + +vi.mock("../../database/db/index.js", () => ({ + getDb: () => ({ + all: async () => + hostRows.filter( + (row) => + row.enable_proxmox || + row.proxmox_config !== null || + row.enable_proxmox_stats || + row.proxmox_stats_config !== null, + ), + }), +})); + +const { runProxmoxSettingsMigration } = + await import("../../utils/crypto-migration/proxmox-settings-migration.js"); + +function storedValue(hostId: number, key: string): unknown { + const row = settingsRows.find( + (entry) => entry.scopeId === String(hostId) && entry.key === key, + ); + return row?.value === null || row?.value === undefined + ? undefined + : JSON.parse(row.value); +} + +beforeEach(() => { + hostRows.length = 0; + settingsRows.length = 0; +}); + +describe("runProxmoxSettingsMigration", () => { + it("moves an enabled host's columns into plugin settings", async () => { + hostRows.push({ + id: 1, + enable_proxmox: true, + proxmox_config: JSON.stringify({ defaultAuthType: "password" }), + enable_proxmox_stats: true, + proxmox_stats_config: JSON.stringify({ nodeName: "pve1" }), + }); + + const result = await runProxmoxSettingsMigration(); + + expect(result.moved).toBe(1); + expect(storedValue(1, "enableProxmox")).toBe(true); + expect(storedValue(1, "proxmoxConfig")).toEqual({ + defaultAuthType: "password", + }); + expect(storedValue(1, "enableProxmoxStats")).toBe(true); + expect(storedValue(1, "proxmoxStatsConfig")).toEqual({ nodeName: "pve1" }); + }); + + it("does nothing on a fresh install with no proxmox hosts", async () => { + const result = await runProxmoxSettingsMigration(); + + expect(result.moved).toBe(0); + expect(settingsRows).toEqual([]); + }); + + it("is idempotent: a second run changes nothing", async () => { + hostRows.push({ + id: 2, + enable_proxmox: false, + proxmox_config: JSON.stringify({ autoSyncEnabled: true }), + enable_proxmox_stats: false, + proxmox_stats_config: null, + }); + + await runProxmoxSettingsMigration(); + const afterFirst = JSON.parse(JSON.stringify(settingsRows)); + + const second = await runProxmoxSettingsMigration(); + + expect(second.moved).toBe(0); + expect(second.skipped).toBe(1); + expect(settingsRows).toEqual(afterFirst); + }); + + it("moves each host independently", async () => { + hostRows.push( + { + id: 1, + enable_proxmox: true, + proxmox_config: null, + enable_proxmox_stats: false, + proxmox_stats_config: null, + }, + { + id: 2, + enable_proxmox: false, + proxmox_config: null, + enable_proxmox_stats: true, + proxmox_stats_config: JSON.stringify({ nodeName: "pve2" }), + }, + ); + + const result = await runProxmoxSettingsMigration(); + + expect(result.moved).toBe(2); + expect(storedValue(1, "enableProxmox")).toBe(true); + expect(storedValue(2, "proxmoxStatsConfig")).toEqual({ nodeName: "pve2" }); + }); +}); diff --git a/src/backend/utils/crypto-migration/proxmox-settings-migration.ts b/src/backend/utils/crypto-migration/proxmox-settings-migration.ts new file mode 100644 index 000000000..3c9d0f5f8 --- /dev/null +++ b/src/backend/utils/crypto-migration/proxmox-settings-migration.ts @@ -0,0 +1,120 @@ +/** + * Moves the four Proxmox host columns into the proxmox plugin's host-scope + * settings, before those columns are dropped from ssh_data. + * + * Idempotent: skips a host that already has a proxmox settings row, so it is + * safe to run on every boot. Lossless: reads straight off the still-present + * ssh_data columns, so it must run and finish before those columns are ever + * dropped from schema.ts. + */ + +import { sql } from "drizzle-orm"; +import { databaseLogger } from "../logger.js"; +import { getDb } from "../../database/db/index.js"; +import { createCurrentPluginSettingsRepository } from "../../database/repositories/factory.js"; + +export interface ProxmoxSettingsMigrationResult { + moved: number; + skipped: number; +} + +interface LegacyProxmoxRow { + id: number; + enable_proxmox: number | boolean | null; + proxmox_config: string | null; + enable_proxmox_stats: number | boolean | null; + proxmox_stats_config: string | null; +} + +export async function runProxmoxSettingsMigration(): Promise { + const result: ProxmoxSettingsMigrationResult = { moved: 0, skipped: 0 }; + + try { + const drizzleDb = getDb(); + // Raw SQL, not the typed schema: this runs once schema.ts has already + // dropped these columns, so the query is the only thing left that still + // knows they used to exist. + const rows = await drizzleDb.all(sql` + SELECT id, enable_proxmox, proxmox_config, enable_proxmox_stats, proxmox_stats_config + FROM ssh_data + WHERE enable_proxmox = true OR proxmox_config IS NOT NULL + OR enable_proxmox_stats = true OR proxmox_stats_config IS NOT NULL + `); + + if (rows.length === 0) return result; + + const pluginSettingsRepository = createCurrentPluginSettingsRepository(); + + for (const row of rows) { + const scopeId = String(row.id); + const existing = await pluginSettingsRepository.get( + "proxmox", + "host", + scopeId, + "enableProxmox", + ); + if (existing && existing.value !== null) { + result.skipped++; + continue; + } + + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "enableProxmox", + JSON.stringify(!!row.enable_proxmox), + ); + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "proxmoxConfig", + JSON.stringify( + row.proxmox_config ? safeParse(row.proxmox_config) : null, + ), + ); + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "enableProxmoxStats", + JSON.stringify(!!row.enable_proxmox_stats), + ); + await pluginSettingsRepository.set( + "proxmox", + "host", + scopeId, + "proxmoxStatsConfig", + JSON.stringify( + row.proxmox_stats_config ? safeParse(row.proxmox_stats_config) : null, + ), + ); + result.moved++; + } + + if (result.moved > 0) { + databaseLogger.info( + `Moved Proxmox settings for ${result.moved} host(s) into plugin settings`, + { operation: "proxmox_settings_migration", moved: result.moved }, + ); + } + } catch (error) { + // A failed migration must not stop the backend. Hosts simply keep + // whatever proxmox settings they already had (none, on a fresh run). + databaseLogger.warn("Proxmox settings migration failed", { + operation: "proxmox_settings_migration", + error: error instanceof Error ? error.message : String(error), + }); + } + + return result; +} + +function safeParse(raw: string): unknown { + try { + return JSON.parse(raw); + } catch { + return null; + } +} diff --git a/src/types/index.ts b/src/types/index.ts index ebddcc6b7..b6e35fc99 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -220,14 +220,10 @@ export type Host = { enableFileManager: boolean; scpLegacy?: boolean; enableDocker: boolean; - enableProxmox: boolean; enableTmuxMonitor: boolean; enableTerminalToolbar: boolean; enableAiAssistant: boolean; allowSessionSharing?: boolean; - proxmoxConfig?: ProxmoxConfig | null; - enableProxmoxStats: boolean; - proxmoxStatsConfig?: ProxmoxStatsConfig | null; showTerminalInSidebar: boolean; showFileManagerInSidebar: boolean; showTunnelInSidebar: boolean; @@ -319,6 +315,9 @@ export type Host = { permissionLevel?: "connect" | "view" | "edit" | "manage"; sharedExpiresAt?: string; ownerUsername?: string; + + /** Enabled plugins' host-scope settings, keyed by plugin id. Secrets redacted. */ + pluginSettings?: Record>; }; export interface JumpHostData { diff --git a/src/ui/features/tunnel/TunnelApp.tsx b/src/ui/features/tunnel/TunnelApp.tsx index de4f55abf..f6106c70e 100644 --- a/src/ui/features/tunnel/TunnelApp.tsx +++ b/src/ui/features/tunnel/TunnelApp.tsx @@ -27,8 +27,8 @@ function sshHostToMinimalHost(h: SSHHost): Host { authType: h.authType, enableTerminal: h.enableTerminal ?? false, enableCommandHistory: h.enableCommandHistory ?? false, - enableProxmox: h.enableProxmox ?? false, - enableProxmoxStats: h.enableProxmoxStats ?? false, + enableProxmox: !!h.pluginSettings?.proxmox?.enableProxmox, + enableProxmoxStats: !!h.pluginSettings?.proxmox?.enableProxmoxStats, enableTmuxMonitor: h.enableTmuxMonitor ?? false, enableTerminalToolbar: h.enableTerminalToolbar ?? true, enableAiAssistant: h.enableAiAssistant ?? false, diff --git a/src/ui/sidebar/HostEditorData.ts b/src/ui/sidebar/HostEditorData.ts index 0059a4556..8c26205be 100644 --- a/src/ui/sidebar/HostEditorData.ts +++ b/src/ui/sidebar/HostEditorData.ts @@ -127,6 +127,10 @@ export function createHostEditorForm( ...(remoteDefaults ?? {}), ...(host?.guacamoleConfig ?? {}), }; + const proxmoxSettings = (host?.pluginSettings?.proxmox ?? {}) as Record< + string, + unknown + >; const rawTheme = terminalConfig.theme ?? d?.theme; const normalizedTheme = !rawTheme || @@ -198,22 +202,25 @@ export function createHostEditorForm( enableTerminalToolbar: host?.enableTerminalToolbar ?? true, enableAiAssistant: host?.enableAiAssistant ?? false, allowSessionSharing: host?.allowSessionSharing ?? true, - enableProxmox: host?.enableProxmox ?? false, - proxmoxConfig: host?.proxmoxConfig ?? { - defaultCredentialId: null as number | null, - defaultAuthType: "password" as string, - windowsPatterns: "win, windows", - dockerPatterns: "docker", - preferredPrefixes: "10., 192.168.", - autoSyncEnabled: false, - syncIntervalMinutes: 15, - markMissingGuests: true, - }, - enableProxmoxStats: host?.enableProxmoxStats ?? false, - proxmoxStatsConfig: host?.proxmoxStatsConfig ?? { - pollInterval: 60, - nodeName: null as string | null, - }, + enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false, + proxmoxConfig: + ((proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? { + defaultCredentialId: null, + defaultAuthType: "password", + windowsPatterns: "win, windows", + dockerPatterns: "docker", + preferredPrefixes: "10., 192.168.", + autoSyncEnabled: false, + syncIntervalMinutes: 15, + markMissingGuests: true, + }) as Host["proxmoxConfig"], + enableProxmoxStats: + (proxmoxSettings.enableProxmoxStats as boolean) ?? false, + proxmoxStatsConfig: + ((proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ?? { + pollInterval: 60, + nodeName: null, + }) as Host["proxmoxStatsConfig"], enableTunnel: host?.enableTunnel ?? false, defaultPath: host?.defaultPath ?? "/", forceKeyboardInteractive: host?.forceKeyboardInteractive ?? false, diff --git a/src/ui/sidebar/HostManagerData.ts b/src/ui/sidebar/HostManagerData.ts index 7a45044a9..0e4613de6 100644 --- a/src/ui/sidebar/HostManagerData.ts +++ b/src/ui/sidebar/HostManagerData.ts @@ -46,6 +46,10 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { const isSshHost = h.connectionType === "ssh" || !h.connectionType; const parsedTerminalConfig = parseJson(h.terminalConfig) as (Host["terminalConfig"] & { sudoPassword?: string }) | undefined; + const proxmoxSettings = (h.pluginSettings?.proxmox ?? {}) as Record< + string, + unknown + >; return { id: String(h.id), name: h.name, @@ -95,13 +99,17 @@ export function sshHostToHost(h: SSHHostWithStatus): Host { dockerConfig: h.dockerConfig ?? null, enableWebUi: h.enableWebUi ?? false, webUiConfig: h.webUiConfig ?? { endpoints: [] }, - enableProxmox: h.enableProxmox ?? false, - enableProxmoxStats: h.enableProxmoxStats ?? false, + enableProxmox: (proxmoxSettings.enableProxmox as boolean) ?? false, + enableProxmoxStats: + (proxmoxSettings.enableProxmoxStats as boolean) ?? false, enableTmuxMonitor: h.enableTmuxMonitor ?? false, enableTerminalToolbar: h.enableTerminalToolbar ?? true, enableAiAssistant: h.enableAiAssistant ?? false, - proxmoxConfig: h.proxmoxConfig ?? null, - proxmoxStatsConfig: h.proxmoxStatsConfig ?? null, + proxmoxConfig: + (proxmoxSettings.proxmoxConfig as Host["proxmoxConfig"]) ?? null, + proxmoxStatsConfig: + (proxmoxSettings.proxmoxStatsConfig as Host["proxmoxStatsConfig"]) ?? + null, enableRdp: h.enableRdp != null ? h.enableRdp : h.connectionType === "rdp", enableVnc: h.enableVnc != null ? h.enableVnc : h.connectionType === "vnc", enableTelnet: diff --git a/src/ui/tests/sidebar/HostEditorData.test.ts b/src/ui/tests/sidebar/HostEditorData.test.ts index 281bf761f..b29809d29 100644 --- a/src/ui/tests/sidebar/HostEditorData.test.ts +++ b/src/ui/tests/sidebar/HostEditorData.test.ts @@ -375,10 +375,14 @@ describe("Proxmox / Proxmox Stats independent toggles", () => { it("seeds both configs independently from an existing host", () => { const host = { - enableProxmox: true, - proxmoxConfig: { windowsPatterns: "win", dockerPatterns: "docker" }, - enableProxmoxStats: true, - proxmoxStatsConfig: { pollInterval: 30, nodeName: "pve-custom" }, + pluginSettings: { + proxmox: { + enableProxmox: true, + proxmoxConfig: { windowsPatterns: "win", dockerPatterns: "docker" }, + enableProxmoxStats: true, + proxmoxStatsConfig: { pollInterval: 30, nodeName: "pve-custom" }, + }, + }, } as unknown as Host; const form = createHostEditorForm(host); @@ -446,8 +450,12 @@ describe("Proxmox / Proxmox Stats independent toggles", () => { }; const form = { ...createHostEditorForm({ - enableProxmox: false, - proxmoxConfig: { source }, + pluginSettings: { + proxmox: { + enableProxmox: false, + proxmoxConfig: { source }, + }, + }, } as unknown as Host), name: "Edited guest", }; diff --git a/src/ui/tests/sidebar/HostExportDialog.test.tsx b/src/ui/tests/sidebar/HostExportDialog.test.tsx index 9adbcdb33..c701029bb 100644 --- a/src/ui/tests/sidebar/HostExportDialog.test.tsx +++ b/src/ui/tests/sidebar/HostExportDialog.test.tsx @@ -51,8 +51,6 @@ function sshHost( enableTunnel: false, enableFileManager: false, enableDocker: false, - enableProxmox: false, - enableProxmoxStats: false, enableTmuxMonitor: false, enableTerminalToolbar: true, enableAiAssistant: false,