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,182 @@
/**
* Moves Host Metrics' settings out of core and into the host-metrics
* plugin's own settings.
*
* Admin keys from `settings`: global_metrics_interval,
* metrics_history_retention_days and metricsEnabled inside host_defaults (the
* default for new hosts). Per host, the metrics half of
* ssh_data.stats_config: metricsEnabled, the host's own interval, the enabled
* widgets and the mount lists. The status half is core and moved by
* host-status-config-migration.ts.
*
* The old settings rows and the column stay in place, unused.
*
* Idempotent: an admin key already set, or a host that already has any
* host-metrics row, is skipped, so this is safe on every boot.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import {
createCurrentPluginRepository,
createCurrentPluginSettingsRepository,
createCurrentSettingsRepository,
} from "../../database/repositories/factory.js";
import { selectRows } from "./raw-rows.js";
const PLUGIN_ID = "host-metrics";
/** A JSON object stored as text, or {} when it is anything else. */
function parseStatsConfig(raw: string | null): Record<string, unknown> {
try {
let parsed: unknown = raw ? JSON.parse(raw) : null;
if (typeof parsed === "string") parsed = JSON.parse(parsed);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: {};
} catch {
return {};
}
}
function seconds(value: unknown, min: number, max: number): number | null {
const parsed = Number(value);
return Number.isInteger(parsed) && parsed >= min && parsed <= max
? parsed
: null;
}
/** The plugin's host settings for one stats_config value, defaults left out. */
export function hostSettingsFromStatsConfig(
raw: string | null,
): Record<string, unknown> {
const config = parseStatsConfig(raw);
const values: Record<string, unknown> = {};
if (config.metricsEnabled === false) values.metricsEnabled = false;
if (config.useGlobalMetricsInterval === false) {
const interval = seconds(config.metricsInterval, 5, 3600);
if (interval !== null) values.metricsInterval = interval;
}
if (Array.isArray(config.enabledWidgets)) {
values.enabledWidgets = config.enabledWidgets.filter(
(widget): widget is string => typeof widget === "string",
);
}
if (Array.isArray(config.excludedMounts) && config.excludedMounts.length) {
values.excludedMounts = config.excludedMounts.filter(
(mount): mount is string => typeof mount === "string",
);
}
if (Array.isArray(config.monitoredMounts) && config.monitoredMounts.length) {
values.monitoredMounts = config.monitoredMounts.filter(
(mount) =>
!!mount &&
typeof mount === "object" &&
typeof (mount as { path?: unknown }).path === "string",
);
}
return values;
}
export interface HostMetricsSettingsMigrationResult {
moved: string[];
hostsMoved: number;
hostsSkipped: number;
}
export async function runHostMetricsSettingsMigration(): Promise<HostMetricsSettingsMigrationResult> {
const result: HostMetricsSettingsMigrationResult = {
moved: [],
hostsMoved: 0,
hostsSkipped: 0,
};
// plugin_settings has a foreign key to plugins: nothing to migrate into
// until the plugin row exists.
try {
const plugin = await createCurrentPluginRepository().findById(PLUGIN_ID);
if (!plugin) return result;
} catch {
return result;
}
const pluginSettings = createCurrentPluginSettingsRepository();
const write = (
scope: "admin" | "host",
scopeId: string | null,
key: string,
value: unknown,
) =>
pluginSettings.set(PLUGIN_ID, scope, scopeId, key, JSON.stringify(value));
try {
const settings = createCurrentSettingsRepository();
const moves: [string, string, number, number][] = [
["global_metrics_interval", "metricsInterval", 5, 3600],
["metrics_history_retention_days", "historyRetentionDays", 1, 90],
];
for (const [legacyKey, field, min, max] of moves) {
const value = seconds(await settings.get(legacyKey), min, max);
if (value === null) continue;
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
field,
);
if (existing && existing.value !== null) continue;
await write("admin", null, field, value);
result.moved.push(legacyKey);
}
const defaults = parseStatsConfig(await settings.get("host_defaults"));
if (defaults.metricsEnabled === false) {
const existing = await pluginSettings.get(
PLUGIN_ID,
"admin",
null,
"enabledForNewHosts",
);
if (!existing || existing.value === null) {
await write("admin", null, "enabledForNewHosts", false);
result.moved.push("host_defaults.metricsEnabled");
}
}
} catch (error) {
databaseLogger.warn("Host Metrics admin settings migration failed", {
operation: "host_metrics_settings_migration",
error: error instanceof Error ? error.message : String(error),
});
}
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const values = hostSettingsFromStatsConfig(row.stats_config);
if (Object.keys(values).length === 0) continue;
const scopeId = String(row.id);
const existing = await pluginSettings.getAll(PLUGIN_ID, "host", scopeId);
if (existing.length > 0) {
result.hostsSkipped++;
continue;
}
for (const [key, value] of Object.entries(values)) {
await write("host", scopeId, key, value);
}
result.hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
if (result.moved.length > 0 || result.hostsMoved > 0) {
databaseLogger.info(
`Moved ${result.moved.length} Host Metrics setting(s) and ${result.hostsMoved} host(s) into plugin settings`,
{ operation: "host_metrics_settings_migration" },
);
}
return result;
}
@@ -0,0 +1,110 @@
/**
* Copies the status check options out of ssh_data.stats_config into the
* status_check_enabled and status_check_interval columns. Status checks are
* core; the rest of stats_config belongs to the host-metrics plugin and is
* moved by host-metrics-settings-migration.ts.
*
* The stats_config column stays in the database, unused: core's drizzle
* migrations run before this does, so dropping it there would lose the data.
*
* Runs once, marked by a settings row, because the new columns have defaults
* and there is no other way to tell a copied row from an untouched one.
*/
import { sql } from "drizzle-orm";
import { databaseLogger } from "../logger.js";
import { createCurrentSettingsRepository } from "../../database/repositories/factory.js";
import { runStatement, selectRows } from "./raw-rows.js";
export const HOST_STATUS_CONFIG_MIGRATED = "host_status_config_migrated";
export interface LegacyStatusConfig {
statusCheckEnabled: boolean;
statusCheckInterval: number | null;
}
/** The status part of an old stats_config value. */
export function statusConfigFromStatsConfig(
raw: string | null,
): LegacyStatusConfig {
let parsed: unknown = null;
try {
parsed = raw ? JSON.parse(raw) : null;
// Some rows were stringified twice.
if (typeof parsed === "string") parsed = JSON.parse(parsed);
} catch {
parsed = null;
}
const config =
parsed && typeof parsed === "object"
? (parsed as Record<string, unknown>)
: {};
const seconds = Number(config.statusCheckInterval);
return {
statusCheckEnabled:
config.statusCheckEnabled !== false && config.disableTcpPing !== true,
statusCheckInterval:
config.useGlobalStatusInterval === false &&
Number.isInteger(seconds) &&
seconds >= 5
? seconds
: null,
};
}
export interface HostStatusConfigMigrationResult {
skipped: boolean;
hostsMoved: number;
}
export async function runHostStatusConfigMigration(): Promise<HostStatusConfigMigrationResult> {
const settings = createCurrentSettingsRepository();
try {
if ((await settings.get(HOST_STATUS_CONFIG_MIGRATED)) === "true") {
return { skipped: true, hostsMoved: 0 };
}
} catch {
return { skipped: true, hostsMoved: 0 };
}
let hostsMoved = 0;
try {
// Raw SQL: schema.ts no longer declares stats_config.
const rows = await selectRows<{ id: number; stats_config: string | null }>(
sql`SELECT id, stats_config FROM ssh_data WHERE stats_config IS NOT NULL`,
);
for (const row of rows) {
const config = statusConfigFromStatsConfig(row.stats_config);
if (config.statusCheckEnabled && config.statusCheckInterval === null) {
continue;
}
// TRUE and FALSE as literals: SQLite cannot bind a JS boolean.
await runStatement(sql`
UPDATE ssh_data
SET status_check_enabled = ${sql.raw(config.statusCheckEnabled ? "TRUE" : "FALSE")},
status_check_interval = ${config.statusCheckInterval}
WHERE id = ${row.id}
`);
hostsMoved++;
}
} catch {
// A fresh install never had the column.
}
try {
await settings.set(HOST_STATUS_CONFIG_MIGRATED, "true");
} catch (error) {
databaseLogger.warn("Could not mark the host status migration done", {
operation: "host_status_config_migration",
error: error instanceof Error ? error.message : String(error),
});
}
if (hostsMoved > 0) {
databaseLogger.info(
`Moved status check options for ${hostsMoved} host(s) out of stats_config`,
{ operation: "host_status_config_migration" },
);
}
return { skipped: false, hostsMoved };
}