feat: finish the host-metrics plugin, move host status into core (B16)

This commit is contained in:
LukeGus committed 2026-09-24 11:21:23 -05:00
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");
});
});