mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15: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
@@ -22,6 +22,9 @@ const h = vi.hoisted(() => ({
|
||||
sessions: [] as number[],
|
||||
released: [] as number[],
|
||||
importRows: [] as Array<{ hostId: number; row: Record<string, unknown> }>,
|
||||
statusReports: [] as Array<Record<string, unknown>>,
|
||||
statusPorts: [] as string[],
|
||||
statusPortsRemoved: [] as string[],
|
||||
roles: [] as Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -111,6 +114,22 @@ vi.mock("../../hosts/host-session-status.js", () => ({
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/status/host-status-service.js", () => ({
|
||||
hostStatusService: {
|
||||
get: (hostId: number) =>
|
||||
hostId === 4 ? { status: "reachable", lastChecked: "t" } : null,
|
||||
check: async (hostId: number) => ({
|
||||
status: "offline",
|
||||
lastChecked: String(hostId),
|
||||
}),
|
||||
reportLogin: (hostId: number, outcome: Record<string, unknown>) =>
|
||||
h.statusReports.push({ hostId, ...outcome }),
|
||||
registerPort: (connectionType: string) => {
|
||||
h.statusPorts.push(connectionType);
|
||||
return () => h.statusPortsRemoved.push(connectionType);
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../database/routes/host-plugin-settings.js", () => ({
|
||||
applyPluginHostImportSettings: async (
|
||||
hostId: number,
|
||||
@@ -419,3 +438,69 @@ describe("ctx.hosts session tracking and activity", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.hosts.status", () => {
|
||||
it("reads and checks core's status with hosts:read", async () => {
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(hosts.status.get(4)).toEqual({
|
||||
status: "reachable",
|
||||
lastChecked: "t",
|
||||
});
|
||||
expect(hosts.status.get(5)).toBeNull();
|
||||
await expect(hosts.status.check(5)).resolves.toEqual({
|
||||
status: "offline",
|
||||
lastChecked: "5",
|
||||
});
|
||||
});
|
||||
|
||||
it("refuses every status member without hosts:read", async () => {
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest([]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
expect(() => hosts.status.get(4)).toThrow(PluginCapabilityError);
|
||||
expect(() => hosts.status.reportLogin(4, { ok: true })).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
expect(() => hosts.status.registerPort("rdp", () => 3389)).toThrow(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
await expect(hosts.status.check(4)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("passes login reports on to core", () => {
|
||||
h.statusReports = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.reportLogin(4, { ok: false, hostKeyChanged: true });
|
||||
expect(h.statusReports).toEqual([
|
||||
{ hostId: 4, ok: false, hostKeyChanged: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes a registered port when the plugin is disposed", async () => {
|
||||
h.statusPorts = [];
|
||||
h.statusPortsRemoved = [];
|
||||
h.granted = new Set(["hosts:read"]);
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
const bag = new DisposableBag("fixture");
|
||||
const hosts = createPluginHosts({
|
||||
manifest: manifest(["hosts:read"]),
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
hosts.status.registerPort("rdp", () => 3389);
|
||||
expect(h.statusPorts).toEqual(["rdp"]);
|
||||
await bag.disposeAll();
|
||||
expect(h.statusPortsRemoved).toEqual(["rdp"]);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,9 @@ const h = vi.hoisted(() => ({
|
||||
connects: [] as Array<{ target: unknown; options: Record<string, unknown> }>,
|
||||
factors: [] as Array<{ userId: string; pluginId: string; factorId: string }>,
|
||||
actor: undefined as string | undefined,
|
||||
cleared: [] as string[],
|
||||
clearedAll: 0,
|
||||
pooled: [] as string[],
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
@@ -27,7 +30,33 @@ vi.mock("../../plugins/permissions.js", async () => {
|
||||
};
|
||||
});
|
||||
vi.mock("../../plugins/actor.js", () => ({ getActor: () => h.actor }));
|
||||
vi.mock("../../hosts/ssh-connection-pool.js", () => ({
|
||||
withConnection: async (
|
||||
key: string,
|
||||
factory: () => Promise<unknown>,
|
||||
fn: (client: unknown) => Promise<unknown>,
|
||||
) => {
|
||||
h.pooled.push(key);
|
||||
return fn(await factory());
|
||||
},
|
||||
connectionPool: {
|
||||
clearKeyConnections: (key: string) => h.cleared.push(key),
|
||||
clearAllConnections: () => {
|
||||
h.clearedAll += 1;
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.mock("../../hosts/connect/connect-host.js", () => ({
|
||||
resolveConnectHost: async (target: unknown) =>
|
||||
typeof target === "number"
|
||||
? {
|
||||
id: target,
|
||||
userId: "user-1",
|
||||
ip: `10.0.0.${target}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
}
|
||||
: target,
|
||||
connectHost: async (target: unknown, options: Record<string, unknown>) => {
|
||||
h.connects.push({ target, options });
|
||||
const client = Object.assign(new EventEmitter(), { end: vi.fn() });
|
||||
@@ -71,6 +100,9 @@ beforeEach(() => {
|
||||
h.connects = [];
|
||||
h.factors = [];
|
||||
h.actor = "user-1";
|
||||
h.cleared = [];
|
||||
h.clearedAll = 0;
|
||||
h.pooled = [];
|
||||
});
|
||||
|
||||
describe("ctx.ssh", () => {
|
||||
@@ -170,6 +202,62 @@ describe("ctx.ssh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.ssh pooled connections", () => {
|
||||
const both = ["ssh:connect", "credentials:use"];
|
||||
|
||||
function plugin(id: string) {
|
||||
const bag = new DisposableBag(id);
|
||||
const ssh = createPluginSsh({
|
||||
manifest: { ...(manifest(both) as object), id } as never,
|
||||
bag,
|
||||
audit: vi.fn(async () => {}),
|
||||
});
|
||||
return { bag, ssh };
|
||||
}
|
||||
|
||||
it("disabling one plugin leaves ctx.ssh and the pool working for others", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
const other = plugin("fleets");
|
||||
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => "a");
|
||||
await other.ssh.withConnection(8, { pool: "fleet" }, async () => "b");
|
||||
await metrics.bag.disposeAll();
|
||||
|
||||
expect(h.clearedAll).toBe(0);
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
await expect(
|
||||
other.ssh.withConnection(8, { pool: "fleet" }, async () => "still"),
|
||||
).resolves.toBe("still");
|
||||
});
|
||||
|
||||
it("dropPooled clears only this plugin's keys for that host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
await metrics.ssh.withConnection(8, { pool: "stats" }, async () => 1);
|
||||
|
||||
metrics.ssh.dropPooled("stats", 7);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.7:22:root"]);
|
||||
|
||||
// Dropped keys are forgotten, so deactivate only clears what is left.
|
||||
h.cleared = [];
|
||||
await metrics.bag.disposeAll();
|
||||
expect(h.cleared).toEqual(["stats:user-1:10.0.0.8:22:root"]);
|
||||
});
|
||||
|
||||
it("dropPooled ignores another pool and an unknown host", async () => {
|
||||
h.granted = new Set(both);
|
||||
const metrics = plugin("host-metrics");
|
||||
await metrics.ssh.withConnection(7, { pool: "stats" }, async () => 1);
|
||||
metrics.ssh.dropPooled("other", 7);
|
||||
metrics.ssh.dropPooled("stats", 99);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
expect(h.cleared).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ctx.auth", () => {
|
||||
it("needs auth:provide and a contributes.auth entry to register anything", () => {
|
||||
const auth = createPluginAuth({
|
||||
|
||||
@@ -96,9 +96,8 @@ describe("plugin event bus", () => {
|
||||
it("exposes the internal topic names the server publishes", () => {
|
||||
expect(TOPICS.hostSessionStatus).toBe("host.session.status");
|
||||
expect(TOPICS.internalEvent).toBe("internal.event");
|
||||
expect(TOPICS.hostMetrics).toBe("host.metrics");
|
||||
expect(TOPICS.hostStatus).toBe("host.status");
|
||||
expect(TOPICS.hostHealthCheck).toBe("host.health_check");
|
||||
expect(TOPICS.hostKeyUpdated).toBe("host.key.updated");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,30 +134,6 @@ describe("hosts publishers route through the bus", () => {
|
||||
expect(listener).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("notifyAutomationStatus and notifyAutomationMetrics emit their topics", async () => {
|
||||
const { notifyAutomationStatus, notifyAutomationMetrics } =
|
||||
await import("../../../../plugins/host-metrics/src/backend/automation-bridge.js");
|
||||
|
||||
const status = vi.fn();
|
||||
const metrics = vi.fn();
|
||||
pluginEvents.on(TOPICS.hostStatus, status);
|
||||
pluginEvents.on(TOPICS.hostMetrics, metrics);
|
||||
|
||||
notifyAutomationStatus(42, "user-1", true);
|
||||
notifyAutomationMetrics(42, "user-1", { cpu: 10 } as never);
|
||||
|
||||
expect(status).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
online: true,
|
||||
});
|
||||
expect(metrics).toHaveBeenCalledWith({
|
||||
hostId: 42,
|
||||
ownerUserId: "user-1",
|
||||
metrics: { cpu: 10 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hostSessionStatus publishes host.session.status on the refcount edges", async () => {
|
||||
const { HostSessionStatus } =
|
||||
await import("../../hosts/host-session-status.js");
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../utils/logger.js", () => {
|
||||
const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() };
|
||||
return { pluginLogger: log };
|
||||
});
|
||||
|
||||
const { createPluginSchedule } = await import("../../plugins/schedule.js");
|
||||
const { DisposableBag } = await import("../../plugins/disposables.js");
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ctx.schedule", () => {
|
||||
it("repeats a job and stops it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const job = vi.fn();
|
||||
|
||||
const stop = schedule.every(1000, job);
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
|
||||
stop();
|
||||
await vi.advanceTimersByTimeAsync(3000);
|
||||
expect(job).toHaveBeenCalledTimes(3);
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
|
||||
it("runs straight away when asked", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
const job = vi.fn();
|
||||
schedule.every(1000, job, { runNow: true });
|
||||
expect(job).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("never overlaps a slow run with the next tick", async () => {
|
||||
const schedule = createPluginSchedule(
|
||||
new DisposableBag("fixture"),
|
||||
vi.fn(),
|
||||
);
|
||||
let running = 0;
|
||||
let peak = 0;
|
||||
schedule.every(100, async () => {
|
||||
running++;
|
||||
peak = Math.max(peak, running);
|
||||
await new Promise((resolve) => setTimeout(resolve, 350));
|
||||
running--;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
expect(peak).toBe(1);
|
||||
});
|
||||
|
||||
it("logs a failing job instead of throwing", async () => {
|
||||
const log = vi.fn();
|
||||
const schedule = createPluginSchedule(new DisposableBag("fixture"), log);
|
||||
schedule.every(100, () => {
|
||||
throw new Error("boom");
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(log).toHaveBeenCalledWith("Scheduled job failed", expect.any(Error));
|
||||
});
|
||||
|
||||
it("clears every timer when the plugin is disposed", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const repeating = vi.fn();
|
||||
const once = vi.fn();
|
||||
schedule.every(100, repeating, { jitterMs: 50 });
|
||||
schedule.after(500, once);
|
||||
|
||||
await bag.disposeAll();
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(repeating).not.toHaveBeenCalled();
|
||||
expect(once).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a one-off job once and can cancel it", async () => {
|
||||
const bag = new DisposableBag("fixture");
|
||||
const schedule = createPluginSchedule(bag, vi.fn());
|
||||
const once = vi.fn();
|
||||
schedule.after(100, once);
|
||||
const cancelled = vi.fn();
|
||||
const cancel = schedule.after(100, cancelled);
|
||||
cancel();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500);
|
||||
expect(once).toHaveBeenCalledOnce();
|
||||
expect(cancelled).not.toHaveBeenCalled();
|
||||
expect(bag.size).toBe(0);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user