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
@@ -112,7 +112,14 @@ export interface PluginDatabase {
|
||||
* lives in memory and is saved to its encrypted file only when asked, so a
|
||||
* write without it can be lost on restart. A no-op on Postgres and MySQL.
|
||||
*/
|
||||
persist: () => Promise<void>;
|
||||
persist: (options?: {
|
||||
/**
|
||||
* Mark the database dirty and let the debounced save pick it up, rather
|
||||
* than saving now. For frequent, low-value writes such as samples, where
|
||||
* losing a couple of seconds on a crash is fine.
|
||||
*/
|
||||
lazy?: boolean;
|
||||
}) => Promise<void>;
|
||||
/**
|
||||
* The engine the server runs on. Table objects encode the same on all
|
||||
* three, but MySQL has no RETURNING, so portable code reads a written row
|
||||
@@ -614,6 +621,48 @@ export interface PluginHosts {
|
||||
type: string,
|
||||
hostName: string,
|
||||
) => Promise<void>;
|
||||
/**
|
||||
* Core's reachability status, the one behind the host list's status dot.
|
||||
* Needs hosts:read.
|
||||
*/
|
||||
status: PluginHostStatusApi;
|
||||
}
|
||||
|
||||
/** online: a login worked. reachable: the port answers. offline: it does not. */
|
||||
export type PluginHostStatus = "online" | "reachable" | "offline";
|
||||
|
||||
export interface PluginHostStatusEntry {
|
||||
status: PluginHostStatus;
|
||||
lastChecked: string;
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
/**
|
||||
* Core checks every host with status checks on by opening a TCP connection to
|
||||
* it. A plugin that logs in to hosts tells core how that went, which is what
|
||||
* turns "reachable" into "online".
|
||||
*/
|
||||
export interface PluginHostStatusApi {
|
||||
/** The last known status, or null when core has not checked the host yet. */
|
||||
get: (hostId: number) => PluginHostStatusEntry | null;
|
||||
/** Checks the host now unless core checked it recently. */
|
||||
check: (hostId: number) => Promise<PluginHostStatusEntry | null>;
|
||||
/** Reports a login attempt. Not audited: pollers call it every sample. */
|
||||
reportLogin: (
|
||||
hostId: number,
|
||||
outcome: { ok: boolean; hostKeyChanged?: boolean },
|
||||
) => void;
|
||||
/**
|
||||
* Tells core which port proves a host of this connection type is up, for
|
||||
* protocols whose port lives in the plugin's own host settings. Core falls
|
||||
* back to the host's port. Removed on deactivate.
|
||||
*/
|
||||
registerPort: (
|
||||
connectionType: string,
|
||||
resolve: (
|
||||
hostId: number,
|
||||
) => number | undefined | Promise<number | undefined>,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -782,6 +831,12 @@ export interface PluginSsh {
|
||||
/** The pool key withConnection would use, for clearing it. */
|
||||
poolKey: (pool: string, host: PluginSshHost) => string;
|
||||
|
||||
/**
|
||||
* Closes this plugin's pooled connections to a host, for when its details
|
||||
* changed. Only keys this plugin created are touched.
|
||||
*/
|
||||
dropPooled: (pool: string, hostId: number) => void;
|
||||
|
||||
/**
|
||||
* The host as the acting user may connect to it: RBAC, owner-key
|
||||
* decryption, shared-host overrides and external secret references
|
||||
@@ -1216,6 +1271,28 @@ export interface PluginCapabilities {
|
||||
require: (capability: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timers owned by the plugin. Every timer is cleared on deactivate, a run that
|
||||
* throws is logged instead of crashing the server, and a repeating job never
|
||||
* overlaps itself: a tick that arrives while the last run is still going is
|
||||
* skipped. No capability needed.
|
||||
*/
|
||||
export interface PluginSchedule {
|
||||
/** Runs `fn` every `intervalMs`. Returns a function that stops it. */
|
||||
every: (
|
||||
intervalMs: number,
|
||||
fn: () => void | Promise<void>,
|
||||
options?: {
|
||||
/** Delays the first run by up to this much, so many timers spread out. */
|
||||
jitterMs?: number;
|
||||
/** Also run once straight away. */
|
||||
runNow?: boolean;
|
||||
},
|
||||
) => () => void;
|
||||
/** Runs `fn` once after `delayMs`. Returns a function that cancels it. */
|
||||
after: (delayMs: number, fn: () => void | Promise<void>) => () => void;
|
||||
}
|
||||
|
||||
export interface PluginContext {
|
||||
readonly pluginId: string;
|
||||
readonly manifest: PluginManifest;
|
||||
@@ -1247,6 +1324,8 @@ export interface PluginContext {
|
||||
readonly credentials: PluginCredentials;
|
||||
/** Audit lines under the plugin's own action names. */
|
||||
readonly audit: PluginAudit;
|
||||
/** Timers that stop on deactivate. */
|
||||
readonly schedule: PluginSchedule;
|
||||
|
||||
/**
|
||||
* Runs `fn` with `userId` as the acting user, for background work that has
|
||||
|
||||
@@ -695,6 +695,16 @@ export interface PluginHostBridge {
|
||||
id: string,
|
||||
) => ComponentType<Record<string, unknown>> | undefined;
|
||||
hostProtocols: (host: PluginHostRecord) => string[];
|
||||
useHostStatus: (hostId: number | undefined) => HostStatusInfo | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's status for a host, the one behind the host list's dot. online: a
|
||||
* login worked. reachable: the port answers. unknown: not checked yet.
|
||||
*/
|
||||
export interface HostStatusInfo {
|
||||
status: "online" | "reachable" | "offline" | "unknown";
|
||||
reason?: "host_key_changed";
|
||||
}
|
||||
|
||||
let host: PluginHostBridge | null = null;
|
||||
@@ -765,6 +775,17 @@ export function useHosts(): { hosts: PluginHostRecord[]; loaded: boolean } {
|
||||
return requireHost().useHosts();
|
||||
}
|
||||
|
||||
/**
|
||||
* Core's status for one host, kept current by the shell's own polling. Null
|
||||
* outside the app shell (a standalone window) and for hosts with status
|
||||
* checks off.
|
||||
*/
|
||||
export function useHostStatus(
|
||||
hostId: number | undefined,
|
||||
): HostStatusInfo | null {
|
||||
return requireHost().useHostStatus(hostId);
|
||||
}
|
||||
|
||||
export function useCurrentUser(): CurrentUser | null {
|
||||
return requireHost().useCurrentUser();
|
||||
}
|
||||
|
||||
@@ -38,8 +38,17 @@ import type { PluginDatabase } from "./backend.js";
|
||||
import type {
|
||||
PluginNativeRdpRequest,
|
||||
PluginProtocolTarget,
|
||||
PluginHostStatusEntry,
|
||||
} from "./backend.js";
|
||||
|
||||
/** A timer registered through ctx.schedule, run by hand with runScheduled. */
|
||||
export interface FakeScheduledJob {
|
||||
kind: "every" | "after";
|
||||
ms: number;
|
||||
fn: () => void | Promise<void>;
|
||||
stopped: boolean;
|
||||
}
|
||||
|
||||
export interface FakeContextOptions {
|
||||
pluginId?: string;
|
||||
manifest?: Partial<PluginManifest>;
|
||||
@@ -80,6 +89,8 @@ export interface FakeContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.desktop.available() answers. Defaults to false. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status.get and check answer, by host id. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
}
|
||||
|
||||
export interface FakeAuthRegistrations {
|
||||
@@ -150,6 +161,22 @@ export interface FakePluginContext {
|
||||
interaction: string;
|
||||
request: Record<string, unknown>;
|
||||
}>;
|
||||
/** Every ctx.hosts.status.reportLogin call, in order. */
|
||||
statusReports: Array<{
|
||||
hostId: number;
|
||||
ok: boolean;
|
||||
hostKeyChanged?: boolean;
|
||||
}>;
|
||||
/** ctx.hosts.status.registerPort resolvers, by connection type. */
|
||||
statusPorts: Map<string, (hostId: number) => unknown>;
|
||||
/** Backing store behind ctx.hosts.status.get and check. */
|
||||
hostStatuses: Map<number, PluginHostStatusEntry>;
|
||||
/** Every ctx.ssh.dropPooled call, in order. */
|
||||
droppedPools: Array<{ pool: string; hostId: number }>;
|
||||
/** Every ctx.schedule timer, stopped ones included. */
|
||||
scheduled: FakeScheduledJob[];
|
||||
/** Runs every live ctx.schedule job once; "after" jobs are then done. */
|
||||
runScheduled: () => Promise<void>;
|
||||
/** Changes the acting user, as core's request middleware would. */
|
||||
setActor: (userId: string | undefined) => void;
|
||||
/** Provided or seeded services, by name or "<service>#<provider>". */
|
||||
@@ -227,6 +254,29 @@ export function createFakeContext(
|
||||
const activities: FakePluginContext["activities"] = [];
|
||||
const trackedSessions: number[] = [];
|
||||
const interactions: FakePluginContext["interactions"] = [];
|
||||
const statusReports: FakePluginContext["statusReports"] = [];
|
||||
const statusPorts: FakePluginContext["statusPorts"] = new Map();
|
||||
const hostStatuses = new Map<number, PluginHostStatusEntry>(
|
||||
Object.entries(options.hostStatuses ?? {}).map(([id, entry]) => [
|
||||
Number(id),
|
||||
entry,
|
||||
]),
|
||||
);
|
||||
const droppedPools: FakePluginContext["droppedPools"] = [];
|
||||
const scheduled: FakeScheduledJob[] = [];
|
||||
const schedule = (
|
||||
kind: FakeScheduledJob["kind"],
|
||||
ms: number,
|
||||
fn: FakeScheduledJob["fn"],
|
||||
) => {
|
||||
const job: FakeScheduledJob = { kind, ms, fn, stopped: false };
|
||||
scheduled.push(job);
|
||||
const stop = () => {
|
||||
job.stopped = true;
|
||||
};
|
||||
disposals.push(stop);
|
||||
return stop;
|
||||
};
|
||||
const hostsById = new Map<number, PluginHostSummary>(
|
||||
(options.hosts ?? []).map((h) => [h.id, h]),
|
||||
);
|
||||
@@ -561,6 +611,28 @@ export function createFakeContext(
|
||||
recordActivity: async (hostId, type, hostName) => {
|
||||
activities.push({ hostId, type, hostName });
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
check: async (hostId) => hostStatuses.get(hostId) ?? null,
|
||||
reportLogin: (hostId, outcome) => {
|
||||
statusReports.push({ hostId, ...outcome });
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
statusPorts.set(connectionType, resolve);
|
||||
return () => {
|
||||
statusPorts.delete(connectionType);
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
schedule: {
|
||||
every: (intervalMs, fn, scheduleOptions) => {
|
||||
const stop = schedule("every", intervalMs, fn);
|
||||
if (scheduleOptions?.runNow) void fn();
|
||||
return stop;
|
||||
},
|
||||
after: (delayMs, fn) => schedule("after", delayMs, fn),
|
||||
},
|
||||
|
||||
audit: {
|
||||
@@ -591,6 +663,9 @@ export function createFakeContext(
|
||||
host: { id: 0, ip: "", port: 22, username: "" } as never,
|
||||
dispose: () => {},
|
||||
}),
|
||||
dropPooled: (pool, hostId) => {
|
||||
droppedPools.push({ pool, hostId });
|
||||
},
|
||||
poolKey: (pool, host) =>
|
||||
`${pool}:${host.userId}:${host.ip}:${host.port}:${host.username}`,
|
||||
resolveHost: async (hostId, resolveOptions) =>
|
||||
@@ -701,6 +776,17 @@ export function createFakeContext(
|
||||
activities,
|
||||
trackedSessions,
|
||||
interactions,
|
||||
statusReports,
|
||||
statusPorts,
|
||||
hostStatuses,
|
||||
droppedPools,
|
||||
scheduled,
|
||||
runScheduled: async () => {
|
||||
for (const job of scheduled.filter((candidate) => !candidate.stopped)) {
|
||||
if (job.kind === "after") job.stopped = true;
|
||||
await job.fn();
|
||||
}
|
||||
},
|
||||
setActor: (userId) => {
|
||||
actor = userId;
|
||||
},
|
||||
@@ -739,6 +825,8 @@ export interface MockContextOptions {
|
||||
protocolTargets?: Record<string, PluginProtocolTarget>;
|
||||
/** What ctx.desktop.available() answers. */
|
||||
desktopAvailable?: boolean;
|
||||
/** What ctx.hosts.status answers. See FakeContextOptions. */
|
||||
hostStatuses?: Record<number, PluginHostStatusEntry>;
|
||||
}
|
||||
|
||||
export interface MockPluginContext extends FakePluginContext {
|
||||
@@ -777,6 +865,7 @@ export function createMockCtx(
|
||||
services: options.services,
|
||||
protocolTargets: options.protocolTargets,
|
||||
desktopAvailable: options.desktopAvailable,
|
||||
hostStatuses: options.hostStatuses,
|
||||
manifest: {
|
||||
capabilities: options.capabilities ?? [],
|
||||
...options.manifest,
|
||||
@@ -815,9 +904,9 @@ export function createMockCtx(
|
||||
require("db:own");
|
||||
return guardedDb.refs();
|
||||
},
|
||||
persist: async () => {
|
||||
persist: async (persistOptions) => {
|
||||
require("db:own");
|
||||
return guardedDb.persist();
|
||||
return guardedDb.persist(persistOptions);
|
||||
},
|
||||
dialect: guardedDb.dialect,
|
||||
},
|
||||
@@ -937,6 +1026,24 @@ export function createMockCtx(
|
||||
require("hosts:read");
|
||||
return ctx.hosts.recordActivity(hostId, type, hostName);
|
||||
},
|
||||
status: {
|
||||
get: (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.get(hostId);
|
||||
},
|
||||
check: async (hostId) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.check(hostId);
|
||||
},
|
||||
reportLogin: (hostId, outcome) => {
|
||||
require("hosts:read");
|
||||
ctx.hosts.status.reportLogin(hostId, outcome);
|
||||
},
|
||||
registerPort: (connectionType, resolve) => {
|
||||
require("hosts:read");
|
||||
return ctx.hosts.status.registerPort(connectionType, resolve);
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
ssh: {
|
||||
|
||||
@@ -81,6 +81,7 @@ const offline = () =>
|
||||
useSettings: () => ({ values: {}, loaded: true, save: async () => {} }),
|
||||
useHost: () => null,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useHostStatus: () => null,
|
||||
useCurrentUser: () => null,
|
||||
useTheme: () => ({ theme: "dark" }),
|
||||
toast: { success: noop, error: noop, info: noop, warning: noop },
|
||||
|
||||
Reference in new issue
Block a user