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

+39 -1
View File
@@ -25,6 +25,8 @@ import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
import { assertCapability } from "./permissions.js";
import { hostSessionStatus } from "../hosts/host-session-status.js";
import { getActor } from "./actor.js";
import { hostStatusService } from "../hosts/status/host-status-service.js";
import type { DisposableBag } from "./disposables.js";
type AuditFn = (
action: string,
@@ -34,6 +36,8 @@ type AuditFn = (
interface Deps {
manifest: PluginManifest;
/** Where registerPort entries go, so deactivate removes them. */
bag?: DisposableBag;
audit: AuditFn;
}
@@ -98,11 +102,16 @@ function actingUser(): string {
return actor;
}
export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
export function createPluginHosts({ manifest, bag, audit }: Deps): PluginHosts {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const requireRead = () => assertCapability(pluginId, "hosts:read", declared);
const requireReadSync = () => {
if (!declared.includes("hosts:read")) {
throw new PluginCapabilityError(pluginId, "hosts:read");
}
};
const requireWrite = () =>
assertCapability(pluginId, "hosts:write", declared);
@@ -418,5 +427,34 @@ export function createPluginHosts({ manifest, audit }: Deps): PluginHosts {
await import("../services/recent-activity.js");
await recordRecentActivity(userId, { type, hostId, hostName });
},
// Synchronous and not audited, like trackSession: pollers call these on
// every sample.
status: {
get: (hostId) => {
requireReadSync();
return hostStatusService.get(hostId);
},
check: async (hostId) => {
await requireRead();
return hostStatusService.check(hostId);
},
reportLogin: (hostId, outcome) => {
requireReadSync();
hostStatusService.reportLogin(hostId, outcome);
},
registerPort: (connectionType, resolve) => {
requireReadSync();
const unregister = hostStatusService.registerPort(
connectionType,
resolve,
);
const drop = bag?.add(unregister, `status port for ${connectionType}`);
return () => {
drop?.();
unregister();
};
},
},
};
}
+20 -3
View File
@@ -90,7 +90,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
const pluginId = manifest.id;
const declared = manifest.capabilities;
const open = new Set<() => void>();
const poolKeys = new Set<string>();
// Pool key -> host id, so dropPooled can find a host's keys after its
// address changed.
const poolKeys = new Map<string, number>();
bag.add(async () => {
for (const dispose of [...open]) dispose();
@@ -98,7 +100,9 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
if (poolKeys.size > 0) {
const { connectionPool } =
await import("../hosts/ssh-connection-pool.js");
for (const key of poolKeys) connectionPool.clearKeyConnections(key);
for (const key of poolKeys.keys()) {
connectionPool.clearKeyConnections(key);
}
poolKeys.clear();
}
}, "SSH connections");
@@ -183,7 +187,7 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
actingUser(host),
);
const key = poolKey(options.pool, resolved as PluginSshHost);
poolKeys.add(key);
poolKeys.set(key, resolved.id);
const { withConnection } =
await import("../hosts/ssh-connection-pool.js");
return withConnection(
@@ -228,6 +232,19 @@ export function createPluginSsh({ manifest, bag, audit }: Deps): PluginSsh {
poolKey,
dropPooled: (pool, hostId) => {
const keys = [...poolKeys.entries()]
.filter(([key, id]) => id === hostId && key.startsWith(`${pool}:`))
.map(([key]) => key);
if (keys.length === 0) return;
for (const key of keys) poolKeys.delete(key);
void import("../hosts/ssh-connection-pool.js").then(
({ connectionPool }) => {
for (const key of keys) connectionPool.clearKeyConnections(key);
},
);
},
resolveHost: async (hostId, options) => {
await checkSsh(true);
const userId = actingUser(hostId);
+15 -2
View File
@@ -53,6 +53,7 @@ import {
} from "../database/db/dialect.js";
import { createPluginAuth, createPluginSsh } from "./ctx-ssh-auth.js";
import { createPluginHosts } from "./ctx-hosts.js";
import { createPluginSchedule } from "./schedule.js";
import { createPluginCredentials } from "./ctx-credentials.js";
import { isElectronIpcAvailable } from "../utils/electron-ipc-bridge.js";
@@ -425,11 +426,15 @@ export function createPluginContext(
refs: () => dbRefs() as never,
// Gated but not audited: it follows every write, and the write itself
// went through a client() call that already left an audit line.
persist: async () => {
persist: async (options) => {
await assertCapability(pluginId, "db:own", manifest.capabilities);
if (!needsExplicitPersist(resolveDatabaseDialect())) return;
const { DatabaseSaveTrigger } =
await import("../utils/database-save-trigger.js");
if (options?.lazy) {
DatabaseSaveTrigger.triggerSave(`plugin_${pluginId}_write`);
return;
}
await DatabaseSaveTrigger.forceSave(`plugin_${pluginId}_write`);
},
get dialect() {
@@ -715,7 +720,15 @@ export function createPluginContext(
},
},
hosts: createPluginHosts({ manifest, audit: auditCall }),
hosts: createPluginHosts({ manifest, bag: handle.bag, audit: auditCall }),
schedule: createPluginSchedule(handle.bag, (message, error) =>
pluginLogger.error(
message,
error instanceof Error ? error : new Error(String(error)),
logContext,
),
),
ssh: createPluginSsh({ manifest, bag: handle.bag, audit: auditCall }),
auth: createPluginAuth({ manifest, bag: handle.bag, audit: auditCall }),
+4 -7
View File
@@ -1,9 +1,8 @@
/**
* The process-wide event bus behind ctx.events.
*
* This formalises three ad-hoc publish paths that already existed:
* This formalises the ad-hoc publish paths that already existed:
* - hosts/automation-events.ts (generic named internal events)
* - hosts/metrics/automation-bridge.ts (metrics-shaped siblings)
* - hosts/host-session-status.ts (the "host.session.status" topic)
*
* Each of those lazily imported the automations engine and swallowed errors,
@@ -28,12 +27,10 @@ export const TOPICS = {
hostSessionStatus: "host.session.status",
/** A generic named internal event (host_deleted, user_login, ...). */
internalEvent: "internal.event",
/** A metrics snapshot for one host. */
hostMetrics: "host.metrics",
/** A host went online or offline as seen by the metrics poller. */
/** A host's status dot changed (hosts/status/host-status-service.ts). */
hostStatus: "host.status",
/** A health check result. */
hostHealthCheck: "host.health_check",
/** A host's key was accepted again after it changed. */
hostKeyUpdated: "host.key.updated",
/** A host's connection details changed and pollers should re-read them. */
hostUpdated: "host.updated",
/** A host was deleted and any poller holding it should drop it. */
+81
View File
@@ -0,0 +1,81 @@
/**
* ctx.schedule: timers a plugin owns. They live in the plugin's disposable
* bag, so deactivate clears every one of them.
*/
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
import type { DisposableBag } from "./disposables.js";
type Log = (message: string, error: unknown) => void;
export function createPluginSchedule(
bag: DisposableBag,
logError: Log,
): PluginSchedule {
const run = async (fn: () => void | Promise<void>) => {
try {
await fn();
} catch (error) {
logError("Scheduled job failed", error);
}
};
return {
every: (intervalMs, fn, options) => {
let running = false;
let stopped = false;
let interval: NodeJS.Timeout | undefined;
const tick = () => {
if (running || stopped) return;
running = true;
void run(fn).finally(() => {
running = false;
});
};
const jitter = Math.max(0, Math.floor(options?.jitterMs ?? 0));
const start = setTimeout(
() => {
if (stopped) return;
interval = setInterval(tick, Math.max(1, intervalMs));
if (jitter > 0) tick();
},
jitter > 0 ? Math.floor(Math.random() * jitter) : 0,
);
if (options?.runNow) tick();
let drop = () => {};
const stop = () => {
if (stopped) return;
stopped = true;
clearTimeout(start);
if (interval) clearInterval(interval);
drop();
};
drop = bag.add(stop, "scheduled job");
return stop;
},
after: (delayMs, fn) => {
let done = false;
let drop = () => {};
const timer = setTimeout(
() => {
done = true;
drop();
void run(fn);
},
Math.max(0, delayMs),
);
const cancel = () => {
if (done) return;
done = true;
clearTimeout(timer);
drop();
};
drop = bag.add(cancel, "scheduled job");
return cancel;
},
};
}
+1 -5
View File
@@ -38,11 +38,7 @@ export type { PluginSettingsScope };
* to it means deciding that every plugin holding settings:read-core may see
* that value, so it is a review decision rather than a convenience.
*/
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = [
"app_name",
"global_metrics_interval",
"global_status_check_interval",
];
export const CORE_SETTINGS_ALLOWLIST: readonly string[] = ["app_name"];
type ChangeListener = (value: unknown) => void;