mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 22:25:24 +00:00
feat: finish the host-metrics plugin, move host status into core (B16)
This commit is contained in:
1 parent
14eb558b85
commit
0a3f05d99d
308 files changed
+53073
-8985
No files matched your search
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* Host Metrics' settings leaving core: the two admin keys, the new-host
|
||||
* default inside host_defaults, and the metrics half of every host's
|
||||
* stats_config. Nothing may be lost, and a second run must change nothing.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
interface Row {
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
coreSettings: new Map<string, string>(),
|
||||
pluginRows: [] as Array<{
|
||||
scope: string;
|
||||
scopeId: string | null;
|
||||
key: string;
|
||||
value: string | null;
|
||||
}>,
|
||||
pluginInstalled: true,
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
const find = (scope: string, scopeId: string | null, key: string) =>
|
||||
state.pluginRows.find(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId && row.key === key,
|
||||
);
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentPluginRepository: () => ({
|
||||
findById: async (id: string) =>
|
||||
state.pluginInstalled && id === "host-metrics" ? { id } : null,
|
||||
}),
|
||||
createCurrentPluginSettingsRepository: () => ({
|
||||
get: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
) => find(scope, scopeId, key) ?? null,
|
||||
getAll: async (_pluginId: string, scope: string, scopeId: string | null) =>
|
||||
state.pluginRows.filter(
|
||||
(row) => row.scope === scope && row.scopeId === scopeId,
|
||||
),
|
||||
set: async (
|
||||
_pluginId: string,
|
||||
scope: string,
|
||||
scopeId: string | null,
|
||||
key: string,
|
||||
value: string | null,
|
||||
) => {
|
||||
const existing = find(scope, scopeId, key);
|
||||
if (existing) existing.value = value;
|
||||
else state.pluginRows.push({ scope, scopeId, key, value });
|
||||
},
|
||||
}),
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.coreSettings.get(key) ?? null,
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const { runHostMetricsSettingsMigration, hostSettingsFromStatsConfig } =
|
||||
await import("../../utils/crypto-migration/host-metrics-settings-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
const value = (scope: string, scopeId: string | null, key: string) => {
|
||||
const row = find(scope, scopeId, key) as Row | undefined;
|
||||
return row?.value == null ? undefined : JSON.parse(row.value);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
state.coreSettings.clear();
|
||||
state.pluginRows = [];
|
||||
state.pluginInstalled = true;
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(
|
||||
"CREATE TABLE ssh_data (id INTEGER PRIMARY KEY, stats_config TEXT)",
|
||||
);
|
||||
state.db = drizzle(sqlite);
|
||||
});
|
||||
|
||||
describe("hostSettingsFromStatsConfig", () => {
|
||||
it("keeps only what differs from the defaults", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify({
|
||||
statusCheckEnabled: false,
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
useGlobalMetricsInterval: false,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }, { bad: 1 }],
|
||||
}),
|
||||
),
|
||||
).toEqual({
|
||||
metricsEnabled: false,
|
||||
metricsInterval: 45,
|
||||
enabledWidgets: ["cpu", "disk"],
|
||||
excludedMounts: ["/snap"],
|
||||
monitoredMounts: [{ path: "/data", label: "Data" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the host's interval while it followed the global one", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(JSON.stringify({ metricsInterval: 45 })),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a value that was stringified twice", () => {
|
||||
expect(
|
||||
hostSettingsFromStatsConfig(
|
||||
JSON.stringify(JSON.stringify({ metricsEnabled: false })),
|
||||
),
|
||||
).toEqual({ metricsEnabled: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostMetricsSettingsMigration", () => {
|
||||
it("moves the admin keys, the new-host default and every host", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
state.coreSettings.set("metrics_history_retention_days", "30");
|
||||
state.coreSettings.set(
|
||||
"host_defaults",
|
||||
JSON.stringify({ metricsEnabled: false, statusCheckEnabled: true }),
|
||||
);
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
insert.run(2, JSON.stringify({ enabledWidgets: ["cpu"] }));
|
||||
insert.run(3, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(4, null);
|
||||
|
||||
const result = await runHostMetricsSettingsMigration();
|
||||
|
||||
expect(result.moved.sort()).toEqual([
|
||||
"global_metrics_interval",
|
||||
"host_defaults.metricsEnabled",
|
||||
"metrics_history_retention_days",
|
||||
]);
|
||||
expect(value("admin", null, "metricsInterval")).toBe(20);
|
||||
expect(value("admin", null, "historyRetentionDays")).toBe(30);
|
||||
expect(value("admin", null, "enabledForNewHosts")).toBe(false);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(false);
|
||||
expect(value("host", "2", "enabledWidgets")).toEqual(["cpu"]);
|
||||
// Only the status half, which is core's: nothing for the plugin.
|
||||
expect(state.pluginRows.filter((row) => row.scopeId === "3")).toEqual([]);
|
||||
expect(result.hostsMoved).toBe(2);
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ metricsEnabled: false }));
|
||||
await runHostMetricsSettingsMigration();
|
||||
|
||||
// Changed through the plugin after the upgrade.
|
||||
const interval = find("admin", null, "metricsInterval")!;
|
||||
interval.value = "90";
|
||||
const enabled = find("host", "1", "metricsEnabled")!;
|
||||
enabled.value = "true";
|
||||
|
||||
const second = await runHostMetricsSettingsMigration();
|
||||
expect(second).toEqual({ moved: [], hostsMoved: 0, hostsSkipped: 1 });
|
||||
expect(value("admin", null, "metricsInterval")).toBe(90);
|
||||
expect(value("host", "1", "metricsEnabled")).toBe(true);
|
||||
});
|
||||
|
||||
it("waits until the plugin row exists", async () => {
|
||||
state.pluginInstalled = false;
|
||||
state.coreSettings.set("global_metrics_interval", "20");
|
||||
expect(await runHostMetricsSettingsMigration()).toEqual({
|
||||
moved: [],
|
||||
hostsMoved: 0,
|
||||
hostsSkipped: 0,
|
||||
});
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
|
||||
it("skips values out of range", async () => {
|
||||
state.coreSettings.set("global_metrics_interval", "1");
|
||||
state.coreSettings.set("metrics_history_retention_days", "900");
|
||||
await runHostMetricsSettingsMigration();
|
||||
expect(state.pluginRows).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* The status half of ssh_data.stats_config moving into the core
|
||||
* status_check_enabled and status_check_interval columns. Run against a real
|
||||
* SQLite database through the same raw SQL helpers the boot path uses.
|
||||
*/
|
||||
|
||||
import Database from "better-sqlite3";
|
||||
import { drizzle } from "drizzle-orm/better-sqlite3";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
settings: new Map<string, string>(),
|
||||
db: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock("../../database/db/index.js", () => ({ getDb: () => state.db }));
|
||||
vi.mock("../../database/repositories/factory.js", () => ({
|
||||
createCurrentSettingsRepository: () => ({
|
||||
get: async (key: string) => state.settings.get(key) ?? null,
|
||||
set: async (key: string, value: string) => {
|
||||
state.settings.set(key, value);
|
||||
},
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../utils/logger.js", () => ({
|
||||
databaseLogger: { info: vi.fn(), warn: vi.fn() },
|
||||
}));
|
||||
|
||||
const {
|
||||
runHostStatusConfigMigration,
|
||||
statusConfigFromStatsConfig,
|
||||
HOST_STATUS_CONFIG_MIGRATED,
|
||||
} =
|
||||
await import("../../utils/crypto-migration/host-status-config-migration.js");
|
||||
|
||||
let sqlite: Database.Database;
|
||||
|
||||
function createHosts(withStatsConfig = true) {
|
||||
sqlite = new Database(":memory:");
|
||||
sqlite.exec(`
|
||||
CREATE TABLE ssh_data (
|
||||
id INTEGER PRIMARY KEY,
|
||||
${withStatsConfig ? "stats_config TEXT," : ""}
|
||||
status_check_enabled INTEGER NOT NULL DEFAULT 1,
|
||||
status_check_interval INTEGER
|
||||
);
|
||||
`);
|
||||
state.db = drizzle(sqlite);
|
||||
}
|
||||
|
||||
const hosts = () =>
|
||||
sqlite
|
||||
.prepare(
|
||||
"SELECT id, status_check_enabled AS enabled, status_check_interval AS interval FROM ssh_data ORDER BY id",
|
||||
)
|
||||
.all();
|
||||
|
||||
beforeEach(() => {
|
||||
state.settings.clear();
|
||||
createHosts();
|
||||
});
|
||||
|
||||
describe("statusConfigFromStatsConfig", () => {
|
||||
it.each([
|
||||
[null, true, null],
|
||||
["{}", true, null],
|
||||
['{"statusCheckEnabled":false}', false, null],
|
||||
['{"disableTcpPing":true}', false, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":45}', true, 45],
|
||||
['{"useGlobalStatusInterval":true,"statusCheckInterval":45}', true, null],
|
||||
['{"useGlobalStatusInterval":false,"statusCheckInterval":2}', true, null],
|
||||
[
|
||||
JSON.stringify(JSON.stringify({ statusCheckEnabled: false })),
|
||||
false,
|
||||
null,
|
||||
],
|
||||
["not json", true, null],
|
||||
])("%s", (raw, enabled, interval) => {
|
||||
expect(statusConfigFromStatsConfig(raw)).toEqual({
|
||||
statusCheckEnabled: enabled,
|
||||
statusCheckInterval: interval,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("runHostStatusConfigMigration", () => {
|
||||
it("copies each host's status options into the new columns", async () => {
|
||||
const insert = sqlite.prepare(
|
||||
"INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)",
|
||||
);
|
||||
insert.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
insert.run(
|
||||
2,
|
||||
JSON.stringify({
|
||||
useGlobalStatusInterval: false,
|
||||
statusCheckInterval: 45,
|
||||
}),
|
||||
);
|
||||
insert.run(
|
||||
3,
|
||||
JSON.stringify({ disableTcpPing: true, metricsEnabled: false }),
|
||||
);
|
||||
insert.run(4, JSON.stringify({ metricsInterval: 10 }));
|
||||
insert.run(5, null);
|
||||
|
||||
const result = await runHostStatusConfigMigration();
|
||||
|
||||
expect(result).toEqual({ skipped: false, hostsMoved: 3 });
|
||||
expect(hosts()).toEqual([
|
||||
{ id: 1, enabled: 0, interval: null },
|
||||
{ id: 2, enabled: 1, interval: 45 },
|
||||
{ id: 3, enabled: 0, interval: null },
|
||||
{ id: 4, enabled: 1, interval: null },
|
||||
{ id: 5, enabled: 1, interval: null },
|
||||
]);
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
|
||||
it("changes nothing the second time", async () => {
|
||||
sqlite
|
||||
.prepare("INSERT INTO ssh_data (id, stats_config) VALUES (?, ?)")
|
||||
.run(1, JSON.stringify({ statusCheckEnabled: false }));
|
||||
await runHostStatusConfigMigration();
|
||||
|
||||
// The user turns checks back on after the upgrade.
|
||||
sqlite.exec("UPDATE ssh_data SET status_check_enabled = 1 WHERE id = 1");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: true,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(hosts()).toEqual([{ id: 1, enabled: 1, interval: null }]);
|
||||
});
|
||||
|
||||
it("finishes on a fresh install that never had stats_config", async () => {
|
||||
createHosts(false);
|
||||
sqlite.exec("INSERT INTO ssh_data (id) VALUES (1)");
|
||||
expect(await runHostStatusConfigMigration()).toEqual({
|
||||
skipped: false,
|
||||
hostsMoved: 0,
|
||||
});
|
||||
expect(state.settings.get(HOST_STATUS_CONFIG_MIGRATED)).toBe("true");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user