mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 23:25:24 +00:00
fix: host imports running a defaults pass and metrics restart per host (#1384)
This commit is contained in:
1 parent
5e31642379
commit
11a22d30eb
6 files changed
+76
-10
No files matched your search
@@ -22,6 +22,8 @@ export interface MetricsViewersV1 {
|
|||||||
unregister: (hostId: number, viewerSessionId: string) => void;
|
unregister: (hostId: number, viewerSessionId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RETIME_DEBOUNCE_MS = 1000;
|
||||||
|
|
||||||
export async function activate(ctx: PluginContext) {
|
export async function activate(ctx: PluginContext) {
|
||||||
const log = createLogger(ctx.log);
|
const log = createLogger(ctx.log);
|
||||||
const repository = await createHostMetricsRepository(ctx.db);
|
const repository = await createHostMetricsRepository(ctx.db);
|
||||||
@@ -73,7 +75,19 @@ export async function activate(ctx: PluginContext) {
|
|||||||
const { hostId } = payload as { hostId?: number };
|
const { hostId } = payload as { hostId?: number };
|
||||||
if (hostId) poller.hostKeyAccepted(hostId);
|
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<typeof setTimeout> | 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<MetricsViewersV1>("host-metrics.viewers", {
|
ctx.services.provide<MetricsViewersV1>("host-metrics.viewers", {
|
||||||
register: async (hostId) => {
|
register: async (hostId) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import express from "express";
|
import express from "express";
|
||||||
import {
|
import {
|
||||||
createMockCtx,
|
createMockCtx,
|
||||||
@@ -9,6 +9,7 @@ import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
|||||||
import { activate } from "../../src/backend/index.js";
|
import { activate } from "../../src/backend/index.js";
|
||||||
import type { MetricsViewersV1 } from "../../src/backend/index.js";
|
import type { MetricsViewersV1 } from "../../src/backend/index.js";
|
||||||
import { hostImportNormalizer } from "../../src/backend/host-import.js";
|
import { hostImportNormalizer } from "../../src/backend/host-import.js";
|
||||||
|
import { MetricsPoller } from "../../src/backend/poller.js";
|
||||||
import { manifest, pluginDir, startServer, type TestServer } from "./server";
|
import { manifest, pluginDir, startServer, type TestServer } from "./server";
|
||||||
|
|
||||||
let db: TestDb | null = null;
|
let db: TestDb | null = null;
|
||||||
@@ -117,4 +118,23 @@ describe("hostImportNormalizer", () => {
|
|||||||
it("writes nothing for a row without metrics settings", () => {
|
it("writes nothing for a row without metrics settings", () => {
|
||||||
expect(hostImportNormalizer({ name: "x" })).toBeNull();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { getErrorMessage } from "../utils/error-message.js";
|
import { getErrorMessage } from "../utils/error-message.js";
|
||||||
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../hosts/ssh-options.js";
|
||||||
import {
|
import {
|
||||||
applyDefaultsAfterHostWrite,
|
applyDefaultsAfterHostWrites,
|
||||||
applyHostDefaultsToWrite,
|
applyHostDefaultsToWrite,
|
||||||
} from "../hosts/defaults/index.js";
|
} from "../hosts/defaults/index.js";
|
||||||
import { recompute } from "../hosts/defaults/recompute.js";
|
import { recompute } from "../hosts/defaults/recompute.js";
|
||||||
@@ -1048,7 +1048,7 @@ app.post(
|
|||||||
"/database/import",
|
"/database/import",
|
||||||
authenticateJWT,
|
authenticateJWT,
|
||||||
upload.single("file"),
|
upload.single("file"),
|
||||||
async (req, res) => {
|
DatabaseSaveTrigger.batched(async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (!req.file) {
|
if (!req.file) {
|
||||||
return res.status(400).json({ error: "No file uploaded" });
|
return res.status(400).json({ error: "No file uploaded" });
|
||||||
@@ -1150,6 +1150,7 @@ app.post(
|
|||||||
const importedHosts = importDb
|
const importedHosts = importDb
|
||||||
.prepare("SELECT * FROM ssh_data")
|
.prepare("SELECT * FROM ssh_data")
|
||||||
.all();
|
.all();
|
||||||
|
const importedHostIds: number[] = [];
|
||||||
for (const host of importedHosts) {
|
for (const host of importedHosts) {
|
||||||
try {
|
try {
|
||||||
const hostRepository = createCurrentHostRepository();
|
const hostRepository = createCurrentHostRepository();
|
||||||
@@ -1224,7 +1225,7 @@ app.post(
|
|||||||
Number(created.id),
|
Number(created.id),
|
||||||
importedHostPluginSettings(importDb, host),
|
importedHostPluginSettings(importDb, host),
|
||||||
);
|
);
|
||||||
await applyDefaultsAfterHostWrite(Number(created.id));
|
importedHostIds.push(Number(created.id));
|
||||||
await importHostProtocolLogins(
|
await importHostProtocolLogins(
|
||||||
importDb,
|
importDb,
|
||||||
host,
|
host,
|
||||||
@@ -1238,6 +1239,7 @@ app.post(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
await applyDefaultsAfterHostWrites(importedHostIds);
|
||||||
} catch {
|
} catch {
|
||||||
apiLogger.info("ssh_data table not found in import file, skipping");
|
apiLogger.info("ssh_data table not found in import file, skipping");
|
||||||
}
|
}
|
||||||
@@ -1402,7 +1404,7 @@ app.post(
|
|||||||
details: getErrorMessage(error),
|
details: getErrorMessage(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
},
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
} from "./host-import-order.js";
|
} from "./host-import-order.js";
|
||||||
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
import { sshOptionsForWrite } from "../../hosts/ssh-options.js";
|
||||||
import {
|
import {
|
||||||
applyDefaultsAfterHostWrite,
|
applyDefaultsAfterHostWrites,
|
||||||
applyHostDefaultsToWrite,
|
applyHostDefaultsToWrite,
|
||||||
} from "../../hosts/defaults/index.js";
|
} from "../../hosts/defaults/index.js";
|
||||||
import {
|
import {
|
||||||
@@ -576,6 +576,7 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const knownAuthTypes = listKnownAuthTypes();
|
const knownAuthTypes = listKnownAuthTypes();
|
||||||
|
const writtenHostIds: number[] = [];
|
||||||
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
for (const { host: hostData, index: i, exportId } of orderedHosts) {
|
||||||
try {
|
try {
|
||||||
const effectiveConnectionType = hostData.connectionType || "ssh";
|
const effectiveConnectionType = hostData.connectionType || "ssh";
|
||||||
@@ -811,13 +812,15 @@ export function registerHostBulkRoutes(
|
|||||||
savedHostId,
|
savedHostId,
|
||||||
hostData as Record<string, unknown>,
|
hostData as Record<string, unknown>,
|
||||||
);
|
);
|
||||||
await applyDefaultsAfterHostWrite(savedHostId);
|
writtenHostIds.push(savedHostId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.failed++;
|
results.failed++;
|
||||||
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
results.errors.push(`Host ${i + 1}: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||||
success: results.success,
|
success: results.success,
|
||||||
@@ -935,6 +938,7 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const writtenHostIds: number[] = [];
|
||||||
for (let i = 0; i < hostsToImport.length; i++) {
|
for (let i = 0; i < hostsToImport.length; i++) {
|
||||||
const hostData = normalizeImportedHost(
|
const hostData = normalizeImportedHost(
|
||||||
hostsToImport[i] as Record<string, unknown>,
|
hostsToImport[i] as Record<string, unknown>,
|
||||||
@@ -1002,7 +1006,7 @@ export function registerHostBulkRoutes(
|
|||||||
existing.id,
|
existing.id,
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
await applyDefaultsAfterHostWrite(existing.id);
|
writtenHostIds.push(existing.id);
|
||||||
results.updated++;
|
results.updated++;
|
||||||
} else {
|
} else {
|
||||||
sshDataObj.createdAt = new Date().toISOString();
|
sshDataObj.createdAt = new Date().toISOString();
|
||||||
@@ -1010,7 +1014,7 @@ export function registerHostBulkRoutes(
|
|||||||
userId,
|
userId,
|
||||||
sshDataObj,
|
sshDataObj,
|
||||||
);
|
);
|
||||||
await applyDefaultsAfterHostWrite(saved.id);
|
writtenHostIds.push(saved.id);
|
||||||
results.success++;
|
results.success++;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -1021,6 +1025,8 @@ export function registerHostBulkRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await applyDefaultsAfterHostWrites(writtenHostIds);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
message: `Import completed: ${results.success} created, ${results.updated} updated, ${results.failed} failed`,
|
||||||
success: results.success,
|
success: results.success,
|
||||||
|
|||||||
@@ -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<void> {
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
update: vi.fn(),
|
update: vi.fn(),
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
applyPluginSettings: vi.fn(),
|
applyPluginSettings: vi.fn(),
|
||||||
|
applyDefaults: vi.fn(async () => {}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
||||||
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
applyPluginHostImportSettings: mocks.applyPluginSettings,
|
||||||
@@ -14,6 +15,7 @@ vi.mock("../../../database/routes/host-plugin-settings.js", () => ({
|
|||||||
vi.mock("../../../hosts/defaults/index.js", () => ({
|
vi.mock("../../../hosts/defaults/index.js", () => ({
|
||||||
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
applyHostDefaultsToWrite: async () => ({ core: [] }),
|
||||||
applyDefaultsAfterHostWrite: async () => {},
|
applyDefaultsAfterHostWrite: async () => {},
|
||||||
|
applyDefaultsAfterHostWrites: mocks.applyDefaults,
|
||||||
}));
|
}));
|
||||||
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
vi.mock("../../../hosts/defaults/overrides.js", () => ({
|
||||||
changeHostOverrides: async () => {},
|
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("exportId");
|
||||||
expect(mocks.create.mock.calls[1][1]).not.toHaveProperty("id");
|
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]);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user