feat: drop proxmox host columns, use plugin settings instead

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

No files matched your search

@@ -1,117 +0,0 @@
import fs from "fs";
import os from "os";
import path from "path";
import Database from "better-sqlite3";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
/**
* The Proxmox Stats feature adds `enable_proxmox_stats` and
* `proxmox_stats_config` to `ssh_data`, backfilled via `addColumnIfNotExists`
* next to the existing `enable_proxmox`/`proxmox_config` columns. Verify the
* migration adds both columns, with the right default, on a database that
* predates them.
*/
describe("proxmox stats columns migration", () => {
let dataDir: string;
beforeEach(() => {
dataDir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-proxmox-stats-"));
vi.resetModules();
process.env.DATA_DIR = dataDir;
process.env.DB_FILE_ENCRYPTION = "false";
process.env.ALLOW_EMPTY_DATA_DIR = "true";
});
afterEach(() => {
delete process.env.DATA_DIR;
delete process.env.DB_FILE_ENCRYPTION;
delete process.env.ALLOW_EMPTY_DATA_DIR;
fs.rmSync(dataDir, { recursive: true, force: true });
});
function writePreUpgradeDatabase(): void {
const seed = new Database(":memory:");
seed.exec(`
CREATE TABLE users (
id TEXT PRIMARY KEY,
username TEXT NOT NULL,
password_hash TEXT NOT NULL
);
CREATE TABLE ssh_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
name TEXT,
ip TEXT NOT NULL,
port INTEGER NOT NULL,
username TEXT NOT NULL,
auth_type TEXT NOT NULL DEFAULT 'password',
enable_proxmox INTEGER NOT NULL DEFAULT 0,
proxmox_config TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
INSERT INTO users (id, username, password_hash)
VALUES ('owner', 'alice', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type, enable_proxmox)
VALUES (1, 'owner', 'pve node', '10.0.0.9', 22, 'root', 'password', 1);
`);
fs.writeFileSync(path.join(dataDir, "db.sqlite"), seed.serialize());
seed.close();
}
it("adds enable_proxmox_stats (default 0) and proxmox_stats_config (nullable) columns", async () => {
writePreUpgradeDatabase();
const db = await import("../../../../src/backend/database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
const columns = sqlite
.prepare("PRAGMA table_info(ssh_data)")
.all() as Array<{
name: string;
notnull: number;
dflt_value: string | null;
}>;
const enableCol = columns.find((c) => c.name === "enable_proxmox_stats");
expect(enableCol).toBeDefined();
expect(enableCol?.notnull).toBe(1);
const configCol = columns.find((c) => c.name === "proxmox_stats_config");
expect(configCol).toBeDefined();
expect(configCol?.notnull).toBe(0);
const row = sqlite
.prepare(
"SELECT enable_proxmox_stats, proxmox_stats_config FROM ssh_data WHERE id = 1",
)
.get() as {
enable_proxmox_stats: number;
proxmox_stats_config: string | null;
};
// Pre-existing rows default to disabled, independent of enable_proxmox.
expect(row.enable_proxmox_stats).toBe(0);
expect(row.proxmox_stats_config).toBeNull();
});
it("creates the proxmox_node_history and proxmox_stats_preferences tables", async () => {
writePreUpgradeDatabase();
const db = await import("../../../../src/backend/database/db/index.js");
await db.initializeDatabase();
const sqlite = db.getSqlite();
const tables = sqlite
.prepare("SELECT name FROM sqlite_master WHERE type = 'table'")
.pluck()
.all() as string[];
expect(tables).toContain("proxmox_node_history");
expect(tables).toContain("proxmox_stats_preferences");
});
});
@@ -1,27 +1,20 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
const historyCreate = vi.fn();
const historyPrune = vi.fn();
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
getCurrentSettingValue: () => null,
createCurrentProxmoxNodeHistoryRepository: () => ({
create: historyCreate,
pruneOlderThan: historyPrune,
vi.mock("../../src/backend/plugin-ctx.js", () => ({
pluginCtx: () => ({
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}),
}));
const collectProxmoxStats = vi.fn();
vi.mock(
"../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js",
() => ({
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
}),
);
vi.mock("../../src/backend/proxmox/collect-proxmox-stats.js", () => ({
collectProxmoxStats: (...args: unknown[]) => collectProxmoxStats(...args),
}));
import {
ProxmoxPollingManager,
parseProxmoxStatsConfig,
} from "../../../../src/backend/hosts/metrics/proxmox-stats-polling.js";
} from "../../src/backend/proxmox-stats-polling.js";
import type { Client } from "ssh2";
interface TestHost {
@@ -50,8 +43,6 @@ function snapshot(overrides: Partial<Record<string, unknown>> = {}) {
beforeEach(() => {
vi.useFakeTimers();
historyCreate.mockReset();
historyPrune.mockReset();
collectProxmoxStats.mockReset();
});
@@ -88,11 +79,17 @@ describe("ProxmoxPollingManager", () => {
async (_host: TestHost, fn: (client: Client) => Promise<unknown>) =>
fn({} as Client),
);
const historyRepository = {
create: vi.fn(async () => {}),
pruneOlderThan: vi.fn(async () => {}),
listRange: vi.fn(async () => []),
};
const manager = new ProxmoxPollingManager<TestHost>({
fetchHostById,
withSshConnection,
historyRepository,
});
return { manager, fetchHostById, withSshConnection };
return { manager, fetchHostById, withSshConnection, historyRepository };
}
it("starts polling and caches a snapshot when the first viewer registers", async () => {
@@ -1,14 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { collectProxmoxClusterHealth } from "../../../../../src/backend/hosts/metrics/proxmox/cluster-health-collector.js";
import { collectProxmoxClusterHealth } from "../../../src/backend/proxmox/cluster-health-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}));
import { collectProxmoxStats } from "../../../../../src/backend/hosts/metrics/proxmox/collect-proxmox-stats.js";
import { collectProxmoxStats } from "../../../src/backend/proxmox/collect-proxmox-stats.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}));
import { collectProxmoxGuestsSummary } from "../../../../../src/backend/hosts/metrics/proxmox/guests-collector.js";
import { collectProxmoxGuestsSummary } from "../../../src/backend/proxmox/guests-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,14 +1,11 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
}));
import { collectProxmoxNodeNetwork } from "../../../../../src/backend/hosts/metrics/proxmox/node-network-collector.js";
import { collectProxmoxNodeNetwork } from "../../../src/backend/proxmox/node-network-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}));
import { collectProxmoxNodeStatus } from "../../../../../src/backend/hosts/metrics/proxmox/node-status-collector.js";
import { collectProxmoxNodeStatus } from "../../../src/backend/proxmox/node-status-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,18 +1,15 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
const execCommand = vi.fn();
vi.mock(
"../../../../../src/backend/hosts/metrics-shared/common-utils.js",
() => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}),
);
vi.mock("../../../src/backend/proxmox/common-utils.js", () => ({
execCommand: (...args: unknown[]) => execCommand(...args),
toFixedNum: (n: number | null | undefined, digits = 2) => {
if (typeof n !== "number" || !Number.isFinite(n)) return null;
return Number(n.toFixed(digits));
},
}));
import { collectProxmoxStorage } from "../../../../../src/backend/hosts/metrics/proxmox/storage-collector.js";
import { collectProxmoxStorage } from "../../../src/backend/proxmox/storage-collector.js";
import type { Client } from "ssh2";
const fakeClient = {} as Client;
@@ -1,30 +1,34 @@
import { afterEach, describe, expect, it } from "vitest";
import { TestSqliteDatabase } from "../../../../../src/backend/tests/database/repositories/test-support.js";
import { ProxmoxNodeHistoryRepository } from "../../../../../src/backend/database/repositories/proxmox-node-history-repository.js";
import { createTestDb, type TestDb } from "@termix/plugin-sdk/testing";
import { proxmoxNodeHistory } from "../../../src/backend/tables.js";
import {
createProxmoxNodeHistoryRepository,
type ProxmoxNodeHistoryRepository,
} from "../../../src/backend/proxmox-node-history-repository.js";
import { pluginDir } from "../helpers.js";
describe("ProxmoxNodeHistoryRepository", () => {
let adapter: TestSqliteDatabase | null = null;
let db: TestDb | null = null;
afterEach(async () => {
if (adapter) {
await adapter.close();
adapter = null;
}
afterEach(() => {
db?.close();
db = null;
});
async function createRepository(
onWrite?: () => void | Promise<void>,
): Promise<ProxmoxNodeHistoryRepository> {
adapter = new TestSqliteDatabase();
const context = await adapter.connect();
await adapter.exec(`
INSERT INTO users (id, username, password_hash) VALUES
('user-1', 'user-1', 'hash'),
('user-2', 'user-2', 'hash');
INSERT INTO ssh_data (id, user_id, name, ip, port, username, auth_type)
VALUES (1, 'user-1', 'pve1', '10.0.0.1', 22, 'root', 'password'), (2, 'user-2', 'pve2', '10.0.0.2', 22, 'root', 'password');
INSERT INTO proxmox_node_history (
async function createRepository(): Promise<ProxmoxNodeHistoryRepository> {
db = await createTestDb(pluginDir, {
before: (sqlite) => {
sqlite.exec(
"INSERT INTO users (id, username) VALUES ('user-1', 'user-1'), ('user-2', 'user-2')",
);
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1), (2)");
},
skipMigrations: false,
});
// Migrations already ran (adopting proxmox_node_history), so seed rows on
// the real table after the fact.
db.sqlite.exec(`
INSERT INTO p_proxmox_node_history (
host_id, ts, cpu_percent, mem_percent, disk_percent, net_rx_bytes, net_tx_bytes
)
VALUES
@@ -34,14 +38,14 @@ describe("ProxmoxNodeHistoryRepository", () => {
(2, '2026-01-02 00:00:00', 99, 99, 99, 999, 999);
`);
return new ProxmoxNodeHistoryRepository(context, onWrite);
return createProxmoxNodeHistoryRepository(
db.database,
await db.database.define(proxmoxNodeHistory),
);
}
it("creates and lists node history rows by range", async () => {
let writeCount = 0;
const repo = await createRepository(() => {
writeCount += 1;
});
const repo = await createRepository();
await repo.create({
hostId: 1,
@@ -59,7 +63,7 @@ describe("ProxmoxNodeHistoryRepository", () => {
);
expect(rows.map((row) => row.cpuPercent)).toEqual([10, 11]);
expect(writeCount).toBe(1);
expect(db!.persisted).toBe(1);
});
it("prunes old history for a host only", async () => {
-54
View File
@@ -846,20 +846,6 @@ async function initializeCompleteDatabase(): Promise<void> {
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
-46
View File
@@ -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(),
-46
View File
@@ -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(),
-46
View File
@@ -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 }),
@@ -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(),
@@ -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<HostRecord, "id" | "userId" | "proxmoxConfig">[]
> {
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<HostRecord[]> {
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)));
@@ -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<PluginSettingsRecord[]> {
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,
@@ -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<void>,
) {}
async create(input: ProxmoxNodeHistoryCreateInput): Promise<void> {
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<void> {
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<ProxmoxNodeHistoryRecord[]> {
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<void> {
await this.onWrite?.();
}
}
+75 -34
View File
@@ -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,
@@ -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
@@ -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<string, unknown>,
): Promise<void> {
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));
}
}
+84 -38
View File
@@ -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<number>();
@@ -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,
@@ -1,135 +0,0 @@
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { createCurrentProxmoxNodeHistoryRepository } from "../../database/repositories/factory.js";
import { statsLogger } from "../../utils/logger.js";
import type { HostAction } from "../../utils/permission-manager.js";
type ProxmoxStatsHistoryRoutesDeps = {
validateHostId: RequestHandler;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
};
const RANGE_OFFSETS: Record<string, number> = {
"1h": 1 * 60 * 60 * 1000,
"6h": 6 * 60 * 60 * 1000,
"24h": 24 * 60 * 60 * 1000,
"7d": 7 * 24 * 60 * 60 * 1000,
"30d": 30 * 24 * 60 * 60 * 1000,
};
export function registerProxmoxStatsHistoryRoutes(
app: Express,
{ validateHostId, canAccessHost }: ProxmoxStatsHistoryRoutesDeps,
): void {
/**
* @openapi
* /proxmox-stats/history/{hostId}:
* get:
* summary: Get historical Proxmox node stats for a host
* tags:
* - Proxmox Stats
* parameters:
* - in: path
* name: hostId
* required: true
* schema:
* type: integer
* - in: query
* name: range
* schema:
* type: string
* enum: [1h, 6h, 24h, 7d, 30d]
* - in: query
* name: from
* schema:
* type: string
* format: date-time
* - in: query
* name: to
* schema:
* type: string
* format: date-time
* responses:
* 200:
* description: Array of node history rows.
* 403:
* description: Access denied.
*/
app.get(
"/proxmox-stats/history/:hostId",
validateHostId,
async (req, res) => {
const hostId = Number(req.params.hostId);
const userId = (req as AuthenticatedRequest).userId;
try {
const hasAccess = await canAccessHost(userId, hostId, "connect");
if (!hasAccess) {
return res.status(403).json({ error: "Access denied" });
}
const { range, from, to } = req.query as Record<
string,
string | undefined
>;
let fromTs: string;
let toTs: string = new Date().toISOString();
if (range) {
const offsetMs = RANGE_OFFSETS[range];
if (!offsetMs) {
return res
.status(400)
.json({ error: "Invalid range. Use 1h, 6h, 24h, 7d, or 30d" });
}
fromTs = new Date(Date.now() - offsetMs).toISOString();
} else if (from && to) {
const fromDate = new Date(from);
const toDate = new Date(to);
if (isNaN(fromDate.getTime()) || isNaN(toDate.getTime())) {
return res
.status(400)
.json({ error: "Invalid from/to date format" });
}
fromTs = fromDate.toISOString();
toTs = toDate.toISOString();
} else {
fromTs = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
}
const toSqlite = (iso: string) =>
iso.replace("T", " ").replace(/\.\d{3}Z$/, "");
const rows = (
await createCurrentProxmoxNodeHistoryRepository().listRange(
hostId,
toSqlite(fromTs),
toSqlite(toTs),
)
).map((row) => ({
ts: row.ts,
cpu_percent: row.cpuPercent,
mem_percent: row.memPercent,
disk_percent: row.diskPercent,
net_rx_bytes: row.netRxBytes,
net_tx_bytes: row.netTxBytes,
}));
res.json({ rows, fromTs, toTs });
} catch (error) {
statsLogger.error("Failed to fetch proxmox stats history", {
operation: "proxmox_stats_history_fetch_error",
hostId,
error: error instanceof Error ? error.message : String(error),
});
res
.status(500)
.json({ error: "Failed to fetch proxmox stats history" });
}
},
);
}
@@ -1,332 +0,0 @@
import { statsLogger } from "../../utils/logger.js";
import {
createCurrentProxmoxNodeHistoryRepository,
getCurrentSettingValue,
} from "../../database/repositories/factory.js";
import { ConcurrentLimiter } from "../metrics-shared/state.js";
import {
collectProxmoxStats,
type ProxmoxStatsSnapshot,
} from "./proxmox/collect-proxmox-stats.js";
import type { Client } from "ssh2";
/**
* Proxmox Stats polling gets its own limiter, separate from Host Metrics'
* metricsPollLimiter in state.ts, so a burst of Proxmox polls can never starve
* regular Host Metrics collection (or vice versa).
*/
const proxmoxStatsPollLimiter = new ConcurrentLimiter(5);
export interface ProxmoxStatsPollableHost {
id: number;
userId: string;
proxmoxStatsConfig?: string | ProxmoxStatsPollConfig | null;
}
export interface ProxmoxStatsPollConfig {
nodeName?: string | null;
pollInterval?: number;
enabledCards?: string[];
}
const DEFAULT_POLL_INTERVAL_SECONDS = 60;
export function parseProxmoxStatsConfig(
raw: string | ProxmoxStatsPollConfig | null | undefined,
): ProxmoxStatsPollConfig {
if (!raw) {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
if (typeof raw === "object") {
return {
nodeName: raw.nodeName ?? null,
pollInterval: raw.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: raw.enabledCards,
};
}
try {
const parsed = JSON.parse(raw) as ProxmoxStatsPollConfig;
return {
nodeName: parsed.nodeName ?? null,
pollInterval: parsed.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS,
enabledCards: parsed.enabledCards,
};
} catch {
return { nodeName: null, pollInterval: DEFAULT_POLL_INTERVAL_SECONDS };
}
}
interface HostPollingEntry<THost extends ProxmoxStatsPollableHost> {
host: THost;
timer?: NodeJS.Timeout;
viewerUserId?: string;
}
interface ViewerDetail {
sessionId: string;
userId: string;
hostId: number;
lastHeartbeat: number;
}
interface CachedSnapshot {
data: ProxmoxStatsSnapshot;
timestamp: number;
}
interface ErrorSnapshot {
error: string;
timestamp: number;
}
export class ProxmoxPollingManager<
THost extends ProxmoxStatsPollableHost = ProxmoxStatsPollableHost,
> {
private pollingConfigs = new Map<number, HostPollingEntry<THost>>();
private snapshotStore = new Map<number, CachedSnapshot>();
private errorStore = new Map<number, ErrorSnapshot>();
private activeViewers = new Map<number, Set<string>>();
private viewerDetails = new Map<string, ViewerDetail>();
private inFlight = new Set<number>();
private viewerCleanupInterval: NodeJS.Timeout;
constructor(
private readonly deps: {
fetchHostById: (
hostId: number,
userId: string,
) => Promise<THost | undefined>;
withSshConnection: <T>(
host: THost,
fn: (client: Client) => Promise<T>,
) => Promise<T>;
historyEnabled?: () => boolean;
},
) {
this.viewerCleanupInterval = setInterval(() => {
this.cleanupInactiveViewers();
}, 60000);
}
private intervalWithJitter(intervalMs: number, hostId: number): number {
const spread = Math.min(intervalMs * 0.2, 15_000);
const jitter = (hostId * 1103515245) % Math.max(1, Math.floor(spread));
return intervalMs + jitter;
}
private getRetentionDays(): number {
try {
const value = getCurrentSettingValue("metrics_history_retention_days");
const days = value ? parseInt(value, 10) : 7;
return isNaN(days) || days < 1 ? 7 : Math.min(days, 90);
} catch {
return 7;
}
}
private async pollHostStats(
host: THost,
viewerUserId?: string,
): Promise<void> {
if (this.inFlight.has(host.id)) return;
this.inFlight.add(host.id);
try {
await proxmoxStatsPollLimiter.run(async () => {
const userId = viewerUserId || host.userId;
const refreshed =
(await this.deps.fetchHostById(host.id, userId)) ?? host;
const config = parseProxmoxStatsConfig(refreshed.proxmoxStatsConfig);
try {
const snapshot = await this.deps.withSshConnection(
refreshed,
(client) => collectProxmoxStats(client, config.nodeName),
);
this.snapshotStore.set(host.id, {
data: snapshot,
timestamp: Date.now(),
});
this.errorStore.delete(host.id);
if (this.deps.historyEnabled?.() ?? true) {
await this.insertHistory(host.id, snapshot);
}
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
this.errorStore.set(host.id, {
error: message,
timestamp: Date.now(),
});
statsLogger.warn("Proxmox stats poll failed", {
operation: "proxmox_stats_poll_failed",
hostId: host.id,
error: message,
});
}
});
} finally {
this.inFlight.delete(host.id);
}
}
private async insertHistory(
hostId: number,
snapshot: ProxmoxStatsSnapshot,
): Promise<void> {
try {
const iface = snapshot.network?.interfaces?.[0];
const rxRaw = iface?.rxBytes ? parseInt(iface.rxBytes, 10) : null;
const txRaw = iface?.txBytes ? parseInt(iface.txBytes, 10) : null;
const repository = createCurrentProxmoxNodeHistoryRepository();
await repository.create({
hostId,
cpuPercent: snapshot.node?.cpu?.percent ?? null,
memPercent: snapshot.node?.memory?.percent ?? null,
diskPercent: snapshot.node?.disk?.percent ?? null,
netRxBytes: rxRaw !== null && !isNaN(rxRaw) ? rxRaw : null,
netTxBytes: txRaw !== null && !isNaN(txRaw) ? txRaw : null,
});
await repository.pruneOlderThan(hostId, this.getRetentionDays());
} catch (err) {
statsLogger.warn("Failed to write proxmox node history", {
operation: "insert_proxmox_node_history",
hostId,
error: err instanceof Error ? err.message : String(err),
});
}
}
private startPollingForHost(host: THost, viewerUserId?: string): void {
const existing = this.pollingConfigs.get(host.id);
if (existing?.timer) {
clearInterval(existing.timer);
}
const config = parseProxmoxStatsConfig(host.proxmoxStatsConfig);
const intervalMs = this.intervalWithJitter(
(config.pollInterval ?? DEFAULT_POLL_INTERVAL_SECONDS) * 1000,
host.id,
);
void this.pollHostStats(host, viewerUserId);
const timer = setInterval(() => {
const latest = this.pollingConfigs.get(host.id);
if (latest) {
void this.pollHostStats(latest.host, latest.viewerUserId);
}
}, intervalMs);
this.pollingConfigs.set(host.id, { host, timer, viewerUserId });
}
private stopPollingForHost(hostId: number): void {
const config = this.pollingConfigs.get(hostId);
if (config?.timer) {
clearInterval(config.timer);
}
this.pollingConfigs.delete(hostId);
}
getStats(
hostId: number,
): { data: ProxmoxStatsSnapshot; timestamp: number } | undefined {
return this.snapshotStore.get(hostId);
}
getError(hostId: number): ErrorSnapshot | undefined {
return this.errorStore.get(hostId);
}
async ensurePolling(host: THost, viewerUserId?: string): Promise<void> {
if (!this.pollingConfigs.has(host.id)) {
this.startPollingForHost(host, viewerUserId);
}
if (!this.snapshotStore.has(host.id) && !this.inFlight.has(host.id)) {
await this.pollHostStats(host, viewerUserId);
}
}
registerViewer = (
hostId: number,
sessionId: string,
userId: string,
): void => {
if (!this.activeViewers.has(hostId)) {
this.activeViewers.set(hostId, new Set());
}
this.activeViewers.get(hostId)!.add(sessionId);
this.viewerDetails.set(sessionId, {
sessionId,
userId,
hostId,
lastHeartbeat: Date.now(),
});
if (this.activeViewers.get(hostId)!.size === 1) {
Promise.resolve()
.then(async () => {
const host = await this.deps.fetchHostById(hostId, userId);
if (host) {
this.startPollingForHost(host, userId);
}
})
.catch((err) => {
statsLogger.warn(
"Proxmox stats startPollingForHost rejected (non-fatal)",
{
operation: "proxmox_stats_start_unhandled",
hostId,
userId,
error: err instanceof Error ? err.message : String(err),
},
);
});
}
};
unregisterViewer = (hostId: number, sessionId: string): void => {
const viewers = this.activeViewers.get(hostId);
if (viewers) {
viewers.delete(sessionId);
if (viewers.size === 0) {
this.activeViewers.delete(hostId);
this.stopPollingForHost(hostId);
}
}
this.viewerDetails.delete(sessionId);
};
updateHeartbeat(sessionId: string): boolean {
const viewer = this.viewerDetails.get(sessionId);
if (viewer) {
viewer.lastHeartbeat = Date.now();
return true;
}
return false;
}
private cleanupInactiveViewers(): void {
const now = Date.now();
const maxInactivity = 120000;
for (const [sessionId, viewer] of this.viewerDetails.entries()) {
if (now - viewer.lastHeartbeat > maxInactivity) {
this.unregisterViewer(viewer.hostId, sessionId);
}
}
}
destroy(): void {
clearInterval(this.viewerCleanupInterval);
for (const hostId of this.pollingConfigs.keys()) {
this.stopPollingForHost(hostId);
}
}
}
@@ -1,258 +0,0 @@
import { getErrorMessage } from "../../utils/error-message.js";
import type { Express, RequestHandler } from "express";
import type { AuthenticatedRequest } from "../../../types/index.js";
import { statsLogger } from "../../utils/logger.js";
import { DataCrypto } from "../../utils/data-crypto.js";
import type { HostAction } from "../../utils/permission-manager.js";
import {
type ProxmoxPollingManager,
type ProxmoxStatsPollableHost,
} from "./proxmox-stats-polling.js";
const EMPTY_SNAPSHOT = {
node: {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
},
network: { interfaces: [] },
guests: { guests: [], counts: { running: 0, stopped: 0, total: 0 } },
storage: { pools: [] },
cluster: { clustered: false },
lastChecked: new Date(0).toISOString(),
};
type ProxmoxStatsHost = ProxmoxStatsPollableHost & {
enableProxmoxStats?: boolean;
};
type ProxmoxStatsRoutesDeps = {
validateHostId: RequestHandler;
fetchHostById: (
hostId: number,
userId: string,
) => Promise<ProxmoxStatsHost | undefined>;
canAccessHost: (
userId: string,
hostId: number,
level: HostAction,
) => Promise<boolean>;
pollingManager: ProxmoxPollingManager<ProxmoxStatsHost>;
};
export function registerProxmoxStatsRoutes(
app: Express,
{
validateHostId,
fetchHostById,
canAccessHost,
pollingManager,
}: ProxmoxStatsRoutesDeps,
): void {
/**
* @openapi
* /proxmox-stats/{id}:
* get:
* summary: Get cached Proxmox node stats for a host
* description: Returns the most recently polled Proxmox Stats snapshot for a host, or an empty skeleton if none has been collected yet.
* tags:
* - Proxmox Stats
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Proxmox stats snapshot.
* 401:
* description: Session expired - please log in again.
* 404:
* description: Stats not available yet.
*/
app.get("/proxmox-stats/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
const cached = pollingManager.getStats(id);
if (!cached) {
const errorState = pollingManager.getError(id);
return res.status(404).json({
error: errorState?.error || "Stats not available",
...EMPTY_SNAPSHOT,
lastChecked: new Date().toISOString(),
});
}
res.json({
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
});
/**
* @openapi
* /proxmox-stats/start/{id}:
* post:
* summary: Start Proxmox stats collection
* description: Registers a viewer and starts (or reuses) polling for a host's Proxmox node stats.
* tags:
* - Proxmox Stats
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Polling started, snapshot returned if already available.
* 401:
* description: Session expired - please log in again.
* 403:
* description: Proxmox Stats is not enabled for this host.
* 404:
* description: Host not found.
*/
app.post("/proxmox-stats/start/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
if (!(await canAccessHost(userId, id, "connect"))) {
return res.status(403).json({ error: "No access to this host" });
}
const host = await fetchHostById(id, userId);
if (!host) {
return res.status(404).json({ error: "Host not found" });
}
if (!host.enableProxmoxStats) {
return res
.status(403)
.json({ error: "Proxmox Stats is not enabled for this host" });
}
const viewerSessionId = `proxmox-viewer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
pollingManager.registerViewer(id, viewerSessionId, userId);
await pollingManager.ensurePolling(host, userId);
const cached = pollingManager.getStats(id);
if (cached) {
return res.json({
success: true,
viewerSessionId,
...cached.data,
lastChecked: new Date(cached.timestamp).toISOString(),
});
}
const errorState = pollingManager.getError(id);
if (errorState) {
return res.json({
success: true,
viewerSessionId,
status: "error",
error: errorState.error,
});
}
return res.json({
success: true,
viewerSessionId,
status: "collecting",
});
} catch (error) {
statsLogger.error("Failed to start proxmox stats collection", {
operation: "proxmox_stats_start_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: getErrorMessage(
error,
"Failed to start proxmox stats collection",
),
});
}
});
/**
* @openapi
* /proxmox-stats/stop/{id}:
* post:
* summary: Stop Proxmox stats collection
* description: Unregisters a viewer session for a host's Proxmox node stats polling.
* tags:
* - Proxmox Stats
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* requestBody:
* required: false
* content:
* application/json:
* schema:
* type: object
* properties:
* viewerSessionId:
* type: string
* responses:
* 200:
* description: Polling stopped successfully.
* 401:
* description: Session expired - please log in again.
*/
app.post("/proxmox-stats/stop/:id", validateHostId, async (req, res) => {
const id = Number(req.params.id);
const userId = (req as AuthenticatedRequest).userId;
const { viewerSessionId } = req.body as { viewerSessionId?: string };
if (DataCrypto.getUserDataKey(userId) === null) {
return res.status(401).json({
error: "Session expired - please log in again",
code: "SESSION_EXPIRED",
});
}
try {
if (viewerSessionId && typeof viewerSessionId === "string") {
pollingManager.unregisterViewer(id, viewerSessionId);
}
res.json({ success: true });
} catch (error) {
statsLogger.error("Failed to stop proxmox stats collection", {
operation: "proxmox_stats_stop_error",
hostId: id,
error: error instanceof Error ? error.message : String(error),
});
res.status(500).json({
error: getErrorMessage(
error,
"Failed to stop proxmox stats collection",
),
});
}
});
}
@@ -1,67 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/common-utils.js";
export interface ProxmoxClusterNodeEntry {
name: string;
online: boolean;
local: boolean;
ip: string | null;
}
export type ProxmoxClusterHealthResult =
| { clustered: false }
| {
clustered: true;
quorate: boolean;
clusterName: string | null;
nodes: ProxmoxClusterNodeEntry[];
};
const EMPTY_RESULT: ProxmoxClusterHealthResult = { clustered: false };
export async function collectProxmoxClusterHealth(
client: Client,
): Promise<ProxmoxClusterHealthResult> {
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/status --output-format json",
15000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const entries = data as Array<Record<string, unknown>>;
const clusterEntry = entries.find((e) => e.type === "cluster");
if (!clusterEntry) {
return EMPTY_RESULT;
}
const nodes: ProxmoxClusterNodeEntry[] = entries
.filter((e) => e.type === "node")
.map((e) => ({
name: typeof e.name === "string" ? e.name : "",
online: e.online === 1 || e.online === true,
local: e.local === 1 || e.local === true,
ip: typeof e.ip === "string" && e.ip ? e.ip : null,
}));
return {
clustered: true,
quorate: clusterEntry.quorate === 1 || clusterEntry.quorate === true,
clusterName:
typeof clusterEntry.name === "string" && clusterEntry.name
? clusterEntry.name
: null,
nodes,
};
} catch {
return EMPTY_RESULT;
}
}
@@ -1,86 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
import {
collectProxmoxNodeStatus,
type ProxmoxNodeStatusResult,
} from "./node-status-collector.js";
import {
collectProxmoxNodeNetwork,
type ProxmoxNodeNetworkResult,
} from "./node-network-collector.js";
import {
collectProxmoxGuestsSummary,
type ProxmoxGuestsSummaryResult,
} from "./guests-collector.js";
import {
collectProxmoxStorage,
type ProxmoxStorageResult,
} from "./storage-collector.js";
import {
collectProxmoxClusterHealth,
type ProxmoxClusterHealthResult,
} from "./cluster-health-collector.js";
export interface ProxmoxStatsSnapshot {
node: ProxmoxNodeStatusResult;
network: ProxmoxNodeNetworkResult;
guests: ProxmoxGuestsSummaryResult;
storage: ProxmoxStorageResult;
cluster: ProxmoxClusterHealthResult;
lastChecked: string;
}
async function resolveNodeName(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<string> {
if (configuredNodeName) {
// An explicitly configured name is validated and used as-is - an unsafe
// value is rejected outright rather than silently falling back to
// auto-detection, which would mask a misconfigured (or malicious) override.
if (!isSafeNodeName(configuredNodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
return configuredNodeName;
}
const { stdout } = await execCommand(client, "hostname", 10000);
return stdout.trim();
}
export async function collectProxmoxStats(
client: Client,
configuredNodeName: string | null | undefined,
): Promise<ProxmoxStatsSnapshot> {
const pveshCheck = await execCommand(
client,
"command -v pvesh >/dev/null 2>&1 && echo ok || echo missing",
10000,
);
if (pveshCheck.stdout.trim() !== "ok") {
throw new Error("pvesh not found — is this a Proxmox node?");
}
const nodeName = await resolveNodeName(client, configuredNodeName);
if (!isSafeNodeName(nodeName)) {
throw new Error("Unable to determine a valid Proxmox node name");
}
const [node, network, guests, storage, cluster] = await Promise.all([
collectProxmoxNodeStatus(client, nodeName),
collectProxmoxNodeNetwork(client, nodeName),
collectProxmoxGuestsSummary(client, nodeName),
collectProxmoxStorage(client, nodeName),
collectProxmoxClusterHealth(client),
]);
return {
node,
network,
guests,
storage,
cluster,
lastChecked: new Date().toISOString(),
};
}
@@ -1,113 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxGuestSummaryEntry {
vmid: number;
name: string;
type: "qemu" | "lxc";
status: string;
cpuPercent: number | null;
memPercent: number | null;
memUsedGiB: number | null;
memTotalGiB: number | null;
diskPercent: number | null;
diskUsedGiB: number | null;
diskTotalGiB: number | null;
uptimeSeconds: number | null;
}
export interface ProxmoxGuestsSummaryResult {
guests: ProxmoxGuestSummaryEntry[];
counts: { running: number; stopped: number; total: number };
}
const EMPTY_RESULT: ProxmoxGuestsSummaryResult = {
guests: [],
counts: { running: 0, stopped: 0, total: 0 },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxGuestsSummary(
client: Client,
nodeName: string,
): Promise<ProxmoxGuestsSummaryResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
"pvesh get /cluster/resources --output-format json",
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const resources = JSON.parse(stdout);
if (!Array.isArray(resources)) {
return EMPTY_RESULT;
}
const guests: ProxmoxGuestSummaryEntry[] = [];
for (const r of resources as Array<Record<string, unknown>>) {
const type = r.type;
if (type !== "qemu" && type !== "lxc") continue;
if (r.node !== nodeName) continue;
if (r.template === true || r.template === 1) continue;
const cpuFraction = typeof r.cpu === "number" ? r.cpu : null;
const mem = typeof r.mem === "number" ? r.mem : null;
const maxmem = typeof r.maxmem === "number" ? r.maxmem : null;
const memPercent =
mem !== null && maxmem !== null && maxmem > 0
? Math.max(0, Math.min(100, (mem / maxmem) * 100))
: null;
const disk = typeof r.disk === "number" ? r.disk : null;
const maxdisk = typeof r.maxdisk === "number" ? r.maxdisk : null;
// QEMU guests without a running agent report maxdisk: 0 - a false 0%
// is worse than an honest "unknown", so treat that as no data at all.
const hasDiskData = maxdisk !== null && maxdisk > 0;
const diskPercent =
hasDiskData && disk !== null
? Math.max(0, Math.min(100, (disk / maxdisk) * 100))
: null;
guests.push({
vmid: typeof r.vmid === "number" ? r.vmid : Number(r.vmid),
name:
typeof r.name === "string" && r.name ? r.name : String(r.vmid ?? ""),
type,
status: typeof r.status === "string" ? r.status : "unknown",
cpuPercent: toFixedNum(
cpuFraction !== null ? cpuFraction * 100 : null,
0,
),
memPercent: toFixedNum(memPercent, 0),
memUsedGiB: mem !== null ? toFixedNum(bytesToGiB(mem), 2) : null,
memTotalGiB: maxmem !== null ? toFixedNum(bytesToGiB(maxmem), 2) : null,
diskPercent: toFixedNum(diskPercent, 0),
diskUsedGiB:
hasDiskData && disk !== null ? toFixedNum(bytesToGiB(disk), 2) : null,
diskTotalGiB: hasDiskData ? toFixedNum(bytesToGiB(maxdisk), 2) : null,
uptimeSeconds: typeof r.uptime === "number" ? r.uptime : null,
});
}
const running = guests.filter((g) => g.status === "running").length;
const stopped = guests.filter((g) => g.status !== "running").length;
return {
guests,
counts: { running, stopped, total: guests.length },
};
} catch {
return EMPTY_RESULT;
}
}
@@ -1,145 +0,0 @@
import type { Client } from "ssh2";
import { execCommand } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxNodeNetworkInterface {
name: string;
ip: string | null;
state: string | null;
rxBytes: string | null;
txBytes: string | null;
}
export interface ProxmoxNodeNetworkResult {
interfaces: ProxmoxNodeNetworkInterface[];
}
const EMPTY_RESULT: ProxmoxNodeNetworkResult = { interfaces: [] };
async function collectFromProcNetDev(
client: Client,
): Promise<ProxmoxNodeNetworkResult> {
const interfaces: ProxmoxNodeNetworkInterface[] = [];
try {
const [addrOut, stateOut, procNetOut] = await Promise.all([
execCommand(
client,
"ip -o addr show | awk '{print $2,$4}' | grep -v '^lo'",
),
execCommand(
client,
"ip -o link show | awk '{gsub(/:/, \"\", $2); print $2,$9}'",
),
execCommand(client, "cat /proc/net/dev"),
]);
const ifMap = new Map<
string,
{ ip: string | null; state: string | null }
>();
for (const line of addrOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const name = parts[0];
const ip = parts[1].split("/")[0];
if (!ifMap.has(name)) ifMap.set(name, { ip, state: null });
}
}
for (const line of stateOut.stdout
.split("\n")
.map((l) => l.trim())
.filter(Boolean)) {
const parts = line.split(/\s+/);
if (parts.length >= 2) {
const existing = ifMap.get(parts[0]);
if (existing) existing.state = parts[1];
}
}
const rxTxMap = new Map<string, { rx: string; tx: string }>();
for (const line of procNetOut.stdout.split("\n").slice(2)) {
const parts = line.trim().split(/\s+/);
if (parts.length >= 10) {
const ifName = parts[0].replace(":", "");
rxTxMap.set(ifName, { rx: parts[1], tx: parts[9] });
}
}
for (const [name, data] of ifMap.entries()) {
const rxTx = rxTxMap.get(name);
interfaces.push({
name,
ip: data.ip,
state: data.state,
rxBytes: rxTx?.rx ?? null,
txBytes: rxTx?.tx ?? null,
});
}
} catch {
return EMPTY_RESULT;
}
return { interfaces };
}
export async function collectProxmoxNodeNetwork(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeNetworkResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/netstat --output-format json`,
15000,
);
if (code !== 0) {
return collectFromProcNetDev(client);
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return collectFromProcNetDev(client);
}
const interfaces: ProxmoxNodeNetworkInterface[] = data
.filter(
(entry): entry is Record<string, unknown> =>
!!entry && typeof entry === "object",
)
.map((entry) => ({
name:
typeof entry.dev === "string" ? entry.dev : String(entry.dev ?? ""),
ip: null,
state: null,
rxBytes:
typeof entry.in === "number"
? String(entry.in)
: typeof entry.received === "number"
? String(entry.received)
: null,
txBytes:
typeof entry.out === "number"
? String(entry.out)
: typeof entry.transmitted === "number"
? String(entry.transmitted)
: null,
}))
.filter((iface) => iface.name && iface.name !== "lo");
if (interfaces.length === 0) {
return collectFromProcNetDev(client);
}
return { interfaces };
} catch {
return collectFromProcNetDev(client);
}
}
@@ -1,142 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxNodeStatusResult {
cpu: {
percent: number | null;
cores: number | null;
load: [number, number, number] | null;
};
memory: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
disk: {
percent: number | null;
usedGiB: number | null;
totalGiB: number | null;
};
uptime: {
seconds: number | null;
formatted: string | null;
};
system: {
hostname: string | null;
kernel: string | null;
pveVersion: string | null;
};
}
const EMPTY_RESULT: ProxmoxNodeStatusResult = {
cpu: { percent: null, cores: null, load: null },
memory: { percent: null, usedGiB: null, totalGiB: null },
disk: { percent: null, usedGiB: null, totalGiB: null },
uptime: { seconds: null, formatted: null },
system: { hostname: null, kernel: null, pveVersion: null },
};
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
return `${days}d ${hours}h ${minutes}m`;
}
export async function collectProxmoxNodeStatus(
client: Client,
nodeName: string,
): Promise<ProxmoxNodeStatusResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/status --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout) as Record<string, unknown>;
const cpuFraction = typeof data.cpu === "number" ? data.cpu : null;
const cpuinfo = (data.cpuinfo as Record<string, unknown>) || {};
const cores = typeof cpuinfo.cores === "number" ? cpuinfo.cores : null;
const loadavgRaw = data.loadavg;
let load: [number, number, number] | null = null;
if (Array.isArray(loadavgRaw) && loadavgRaw.length >= 3) {
const parsed = loadavgRaw
.slice(0, 3)
.map((v) => Number(v))
.map((v) => (Number.isFinite(v) ? v : 0));
load = parsed as [number, number, number];
}
const memory = (data.memory as Record<string, unknown>) || {};
const memUsed = typeof memory.used === "number" ? memory.used : null;
const memTotal = typeof memory.total === "number" ? memory.total : null;
const memPercent =
memUsed !== null && memTotal !== null && memTotal > 0
? Math.max(0, Math.min(100, (memUsed / memTotal) * 100))
: null;
const rootfs = (data.rootfs as Record<string, unknown>) || {};
const diskUsed = typeof rootfs.used === "number" ? rootfs.used : null;
const diskTotal = typeof rootfs.total === "number" ? rootfs.total : null;
const diskPercent =
diskUsed !== null && diskTotal !== null && diskTotal > 0
? Math.max(0, Math.min(100, (diskUsed / diskTotal) * 100))
: null;
const uptimeSeconds = typeof data.uptime === "number" ? data.uptime : null;
return {
cpu: {
percent: toFixedNum(cpuFraction !== null ? cpuFraction * 100 : null, 0),
cores,
load,
},
memory: {
percent: toFixedNum(memPercent, 0),
usedGiB: memUsed !== null ? toFixedNum(bytesToGiB(memUsed), 2) : null,
totalGiB:
memTotal !== null ? toFixedNum(bytesToGiB(memTotal), 2) : null,
},
disk: {
percent: toFixedNum(diskPercent, 0),
usedGiB: diskUsed !== null ? toFixedNum(bytesToGiB(diskUsed), 2) : null,
totalGiB:
diskTotal !== null ? toFixedNum(bytesToGiB(diskTotal), 2) : null,
},
uptime: {
seconds: uptimeSeconds,
formatted: uptimeSeconds !== null ? formatUptime(uptimeSeconds) : null,
},
system: {
hostname:
typeof data.hostname === "string" && data.hostname
? data.hostname
: null,
kernel:
typeof data.kversion === "string" && data.kversion
? data.kversion
: null,
pveVersion:
typeof data.pveversion === "string" && data.pveversion
? data.pveversion
: null,
},
};
} catch {
return EMPTY_RESULT;
}
}
@@ -1,76 +0,0 @@
import type { Client } from "ssh2";
import { execCommand, toFixedNum } from "../../metrics-shared/common-utils.js";
import { isSafeNodeName } from "../../proxmox-shared.js";
export interface ProxmoxStoragePoolEntry {
name: string;
type: string;
active: boolean;
enabled: boolean;
usedGiB: number | null;
totalGiB: number | null;
availGiB: number | null;
percent: number | null;
}
export interface ProxmoxStorageResult {
pools: ProxmoxStoragePoolEntry[];
}
const EMPTY_RESULT: ProxmoxStorageResult = { pools: [] };
function bytesToGiB(bytes: number): number {
return bytes / (1024 * 1024 * 1024);
}
export async function collectProxmoxStorage(
client: Client,
nodeName: string,
): Promise<ProxmoxStorageResult> {
if (!isSafeNodeName(nodeName)) {
return EMPTY_RESULT;
}
try {
const { stdout, code } = await execCommand(
client,
`pvesh get /nodes/${nodeName}/storage --output-format json`,
25000,
);
if (code !== 0) {
return EMPTY_RESULT;
}
const data = JSON.parse(stdout);
if (!Array.isArray(data)) {
return EMPTY_RESULT;
}
const pools: ProxmoxStoragePoolEntry[] = (
data as Array<Record<string, unknown>>
).map((entry) => {
const used = typeof entry.used === "number" ? entry.used : null;
const total = typeof entry.total === "number" ? entry.total : null;
const avail = typeof entry.avail === "number" ? entry.avail : null;
const percent =
used !== null && total !== null && total > 0
? Math.max(0, Math.min(100, (used / total) * 100))
: null;
return {
name: typeof entry.storage === "string" ? entry.storage : "",
type: typeof entry.type === "string" ? entry.type : "unknown",
active: entry.active === 1 || entry.active === true,
enabled: entry.enabled === 1 || entry.enabled === true,
usedGiB: used !== null ? toFixedNum(bytesToGiB(used), 2) : null,
totalGiB: total !== null ? toFixedNum(bytesToGiB(total), 2) : null,
availGiB: avail !== null ? toFixedNum(bytesToGiB(avail), 2) : null,
percent: toFixedNum(percent, 0),
};
});
return { pools };
} catch {
return EMPTY_RESULT;
}
}
-7
View File
@@ -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);
}
+95
View File
@@ -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<string, unknown>): 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<PluginHostRecord> => {
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<string, unknown>);
},
update: async (
hostId: number,
patch: PluginHostUpdateInput,
): Promise<PluginHostRecord | null> => {
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<string, unknown>)
: null;
},
listOwned: async (): Promise<PluginHostRecord[]> => {
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<string, unknown>),
);
},
share: async (
hostId: number,
targets: PluginShareTarget[],
+4
View File
@@ -346,6 +346,10 @@ async function provisionLocalDesktopUserIfNeeded(): Promise<void> {
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",
@@ -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", () => {
+105
View File
@@ -14,6 +14,9 @@ const h = vi.hoisted(() => ({
ownerId: "user-1",
hostById: null as Record<string, unknown> | null,
upserts: [] as Array<Record<string, unknown>>,
created: [] as Array<Record<string, unknown>>,
updates: [] as Array<Record<string, unknown>>,
updateResult: null as Record<string, unknown> | 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<string, unknown>,
) => {
const created = { id: 99, ...host, userId };
h.created.push(created);
return created;
},
updateEncryptedForUser: async (
userId: string,
hostId: number,
patch: Record<string, unknown>,
) => {
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" }]);
});
});
@@ -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" });
});
});
@@ -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<ProxmoxSettingsMigrationResult> {
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<LegacyProxmoxRow>(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;
}
}
+3 -4
View File
@@ -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<string, Record<string, unknown>>;
};
export interface JumpHostData {
+2 -2
View File
@@ -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,
+23 -16
View File
@@ -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,
+12 -4
View File
@@ -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:
+14 -6
View File
@@ -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",
};
@@ -51,8 +51,6 @@ function sshHost(
enableTunnel: false,
enableFileManager: false,
enableDocker: false,
enableProxmox: false,
enableProxmoxStats: false,
enableTmuxMonitor: false,
enableTerminalToolbar: true,
enableAiAssistant: false,