From 11a22d30eb93683a4d1ae3bcc07410f1ed56d2ea Mon Sep 17 00:00:00 2001 From: LukeGus Date: Sat, 3 Oct 2026 15:37:07 -0500 Subject: [PATCH] fix: host imports running a defaults pass and metrics restart per host (#1384) --- plugins/host-metrics/src/backend/index.ts | 16 +++++++++++++- .../tests/backend/activate.test.ts | 22 ++++++++++++++++++- src/backend/database/database.ts | 10 +++++---- .../database/routes/host-bulk-routes.ts | 14 ++++++++---- src/backend/hosts/defaults/index.ts | 19 ++++++++++++++++ .../host-import-terminal-settings.test.ts | 5 +++++ 6 files changed, 76 insertions(+), 10 deletions(-) diff --git a/plugins/host-metrics/src/backend/index.ts b/plugins/host-metrics/src/backend/index.ts index 968bc2b3e..463f46a3a 100644 --- a/plugins/host-metrics/src/backend/index.ts +++ b/plugins/host-metrics/src/backend/index.ts @@ -22,6 +22,8 @@ export interface MetricsViewersV1 { unregister: (hostId: number, viewerSessionId: string) => void; } +const RETIME_DEBOUNCE_MS = 1000; + export async function activate(ctx: PluginContext) { const log = createLogger(ctx.log); const repository = await createHostMetricsRepository(ctx.db); @@ -73,7 +75,19 @@ export async function activate(ctx: PluginContext) { const { hostId } = payload as { hostId?: number }; if (hostId) poller.hostKeyAccepted(hostId); }); - ctx.settings.onChange("metricsInterval", () => void poller.retimeAll()); + // An import writes this once per host, and each write restarted every + // polled host. One retime after the writes settle covers them all. + let retime: ReturnType | null = null; + ctx.settings.onChange("metricsInterval", () => { + if (retime) clearTimeout(retime); + retime = setTimeout(() => { + retime = null; + void poller.retimeAll(); + }, RETIME_DEBOUNCE_MS); + }); + ctx.disposables.add(() => { + if (retime) clearTimeout(retime); + }); ctx.services.provide("host-metrics.viewers", { register: async (hostId) => { diff --git a/plugins/host-metrics/tests/backend/activate.test.ts b/plugins/host-metrics/tests/backend/activate.test.ts index f2864491b..0623843a7 100644 --- a/plugins/host-metrics/tests/backend/activate.test.ts +++ b/plugins/host-metrics/tests/backend/activate.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import express from "express"; import { createMockCtx, @@ -9,6 +9,7 @@ import { PluginCapabilityError } from "@termix/plugin-sdk/backend"; import { activate } from "../../src/backend/index.js"; import type { MetricsViewersV1 } from "../../src/backend/index.js"; import { hostImportNormalizer } from "../../src/backend/host-import.js"; +import { MetricsPoller } from "../../src/backend/poller.js"; import { manifest, pluginDir, startServer, type TestServer } from "./server"; let db: TestDb | null = null; @@ -117,4 +118,23 @@ describe("hostImportNormalizer", () => { it("writes nothing for a row without metrics settings", () => { expect(hostImportNormalizer({ name: "x" })).toBeNull(); }); + + it("retimes once after a burst of interval writes", async () => { + server = await startServer(); + const retime = vi + .spyOn(MetricsPoller.prototype, "retimeAll") + .mockResolvedValue(); + vi.useFakeTimers(); + try { + for (let hostId = 1; hostId <= 20; hostId++) { + await server.mock.ctx.settings.setHost(hostId, "metricsInterval", 30); + } + expect(retime).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1500); + expect(retime).toHaveBeenCalledTimes(1); + } finally { + vi.useRealTimers(); + retime.mockRestore(); + } + }); }); diff --git a/src/backend/database/database.ts b/src/backend/database/database.ts index 8d7a8bd0a..f1f98312b 100644 --- a/src/backend/database/database.ts +++ b/src/backend/database/database.ts @@ -1,7 +1,7 @@ import { getErrorMessage } from "../utils/error-message.js"; import { sshOptionsForWrite } from "../hosts/ssh-options.js"; import { - applyDefaultsAfterHostWrite, + applyDefaultsAfterHostWrites, applyHostDefaultsToWrite, } from "../hosts/defaults/index.js"; import { recompute } from "../hosts/defaults/recompute.js"; @@ -1048,7 +1048,7 @@ app.post( "/database/import", authenticateJWT, upload.single("file"), - async (req, res) => { + DatabaseSaveTrigger.batched(async (req, res) => { try { if (!req.file) { return res.status(400).json({ error: "No file uploaded" }); @@ -1150,6 +1150,7 @@ app.post( const importedHosts = importDb .prepare("SELECT * FROM ssh_data") .all(); + const importedHostIds: number[] = []; for (const host of importedHosts) { try { const hostRepository = createCurrentHostRepository(); @@ -1224,7 +1225,7 @@ app.post( Number(created.id), importedHostPluginSettings(importDb, host), ); - await applyDefaultsAfterHostWrite(Number(created.id)); + importedHostIds.push(Number(created.id)); await importHostProtocolLogins( importDb, host, @@ -1238,6 +1239,7 @@ app.post( ); } } + await applyDefaultsAfterHostWrites(importedHostIds); } catch { apiLogger.info("ssh_data table not found in import file, skipping"); } @@ -1402,7 +1404,7 @@ app.post( details: getErrorMessage(error), }); } - }, + }), ); /** diff --git a/src/backend/database/routes/host-bulk-routes.ts b/src/backend/database/routes/host-bulk-routes.ts index 37c9d2506..33c203b23 100644 --- a/src/backend/database/routes/host-bulk-routes.ts +++ b/src/backend/database/routes/host-bulk-routes.ts @@ -4,7 +4,7 @@ import { } from "./host-import-order.js"; import { sshOptionsForWrite } from "../../hosts/ssh-options.js"; import { - applyDefaultsAfterHostWrite, + applyDefaultsAfterHostWrites, applyHostDefaultsToWrite, } from "../../hosts/defaults/index.js"; import { @@ -576,6 +576,7 @@ export function registerHostBulkRoutes( } const knownAuthTypes = listKnownAuthTypes(); + const writtenHostIds: number[] = []; for (const { host: hostData, index: i, exportId } of orderedHosts) { try { const effectiveConnectionType = hostData.connectionType || "ssh"; @@ -811,13 +812,15 @@ export function registerHostBulkRoutes( savedHostId, hostData as Record, ); - await applyDefaultsAfterHostWrite(savedHostId); + writtenHostIds.push(savedHostId); } catch (error) { results.failed++; results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`); } } + await applyDefaultsAfterHostWrites(writtenHostIds); + res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, @@ -935,6 +938,7 @@ export function registerHostBulkRoutes( } } + const writtenHostIds: number[] = []; for (let i = 0; i < hostsToImport.length; i++) { const hostData = normalizeImportedHost( hostsToImport[i] as Record, @@ -1002,7 +1006,7 @@ export function registerHostBulkRoutes( existing.id, sshDataObj, ); - await applyDefaultsAfterHostWrite(existing.id); + writtenHostIds.push(existing.id); results.updated++; } else { sshDataObj.createdAt = new Date().toISOString(); @@ -1010,7 +1014,7 @@ export function registerHostBulkRoutes( userId, sshDataObj, ); - await applyDefaultsAfterHostWrite(saved.id); + writtenHostIds.push(saved.id); results.success++; } } catch (error) { @@ -1021,6 +1025,8 @@ export function registerHostBulkRoutes( } } + await applyDefaultsAfterHostWrites(writtenHostIds); + res.json({ message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`, success: results.success, diff --git a/src/backend/hosts/defaults/index.ts b/src/backend/hosts/defaults/index.ts index 3b891b815..3d10a1078 100644 --- a/src/backend/hosts/defaults/index.ts +++ b/src/backend/hosts/defaults/index.ts @@ -25,3 +25,22 @@ export async function applyDefaultsAfterHostWrite( }); } } + +/** + * The same for many hosts written in one go (an import), in a single pass + * instead of one per host. + */ +export async function applyDefaultsAfterHostWrites( + hostIds: number[], +): Promise { + if (hostIds.length === 0) return; + try { + await recompute({ hostIds }); + } catch (error) { + databaseLogger.warn("Could not apply host defaults after a write", { + operation: "host_defaults_after_write", + hosts: hostIds.length, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts index 1b42268f7..1a2000d02 100644 --- a/src/backend/tests/database/routes/host-import-terminal-settings.test.ts +++ b/src/backend/tests/database/routes/host-import-terminal-settings.test.ts @@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({ update: vi.fn(), list: vi.fn(), applyPluginSettings: vi.fn(), + applyDefaults: vi.fn(async () => {}), })); vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ applyPluginHostImportSettings: mocks.applyPluginSettings, @@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({ vi.mock("../../../hosts/defaults/index.js", () => ({ applyHostDefaultsToWrite: async () => ({ core: [] }), applyDefaultsAfterHostWrite: async () => {}, + applyDefaultsAfterHostWrites: mocks.applyDefaults, })); vi.mock("../../../hosts/defaults/overrides.js", () => ({ changeHostOverrides: async () => {}, @@ -141,6 +143,9 @@ it.each([20, "20"])( ]); expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("exportId"); expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id"); + // One defaults pass for the whole import, not one per host. + expect(mocks.applyDefaults).toHaveBeenCalledTimes(1); + expect(mocks.applyDefaults).toHaveBeenCalledWith([501, 502]); }, );