mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: drop proxmox host columns, use plugin settings instead
This commit is contained in:
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;
|
||||
|
||||
+31
-27
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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?.();
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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[],
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user