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
@@ -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();
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
Reference in new issue
Block a user