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

+104 -14
View File
@@ -466,8 +466,12 @@ Slots core owns: `terminal.toolbar`, `terminal.toolbarStatus`,
`terminal.sidePanel`, `terminal.overlay` (declared by ssh-terminal; **B9**
renamed `terminal.dock` to `terminal.sidePanel` and added the status slot,
where host-metrics puts its CPU, memory and disk bars), `onboarding.steps`, `onboarding.features`,
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`
and `shell.overlay`. **B12** added `shell.overlay`, a component slot the
`onboarding.workflow`, `hosts.importMenu`, `hosts.panel`, `proxmox.hostEditor`,
`shell.overlay`, `dashboard.hostMetrics` and `homepage.hostMetrics`. **B16**
added the last two, component slots in the dashboard's host status card
(`{ hostId, online }`, one per host row) and the homepage's host status widget
(`{ hostId, shownMetrics, online }`): core draws the status and a plugin draws
the numbers, so core no longer calls the metrics API. **B12** added `shell.overlay`, a component slot the
shell renders once at its root for always-mounted plugin UI: session-sharing
keeps its share dialog and its room-invite watcher there.
`session.remoteDisplay` moved to session-sharing, which declares it and draws
@@ -482,6 +486,9 @@ or without tailscale enabled. Cross-plugin frontend calls go through actions:
plugin actions the same way when a core view shows a plugin's data:
`files.openHost`, and **B7**'s `tunnels.statuses` and `tunnels.open` behind the
dashboard's active tunnel counter, which reads as zero while tunnels is off.
**B16** added `host-metrics.disk` (the file manager's disk usage bar) and
`snippets.get` / `snippets.execute` (host-metrics's quick actions); each
answers undefined while its plugin is off.
**B9** moved the terminal itself into ssh-terminal, which adds
`terminal.open(host, { path?, joinSharedSessionId?, joinShareId?, label? })`
@@ -571,6 +578,11 @@ connection helpers (`isElectron`, `resolveConnectionOrigin`, `pluginWsUrl`,
(`logActivity`, `getHostPassword`, `patchOpenTab`, `getUserPreferences`,
`parseCustomKeybindings`, `setHostAutoTmux`, `getCookie`). The APIs are a
stopgap in the right place: D1 turns them into typed bridge members.
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
Appearance density and chart options), and the homepage widget pieces
`WidgetTitle` and `runVisibleInterval`, for host-metrics's cards and its
metrics chart widget. The frontend SDK gained `useHostStatus(hostId)`: core's
status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote
host lookup. `useConnectionDefaults` now carries terminal defaults only; the
@@ -696,6 +708,44 @@ the `shareable`, `canShare`, `openShareModal` and `getShareTarget` members of
`TabOptions`/`TabHandle` are gone. Core's active connections list gets the
sessions shared with a user from the `sessions.sharedWithMe` action.
#### Host status is core
The status dot in the host list, the dashboard and the homepage widget is
core, in `src/backend/hosts/status/`. **B16** moved it out of host-metrics's
poller. Core opens a TCP connection to every host with status checks on
(`status_check_enabled`, a core host column), on the host's own interval
(`status_check_interval`) or the admin default (`global_status_check_interval`
under `GET/PUT /host/status/settings`), through core's jump host chain when the
host has one. It never logs in: on RADIUS or Duo backed devices a login fires
a real 2FA push. That gives "reachable" or "offline". A host is "online" when
a login worked: an open session (`ctx.hosts.trackSession`) or a plugin's
`ctx.hosts.status.reportLogin`. Polling is demand driven: a user asking for
`GET /host/status` starts their own hosts. Core emits `host.status`
(`{ hostId, ownerUserId, status, previous, online, reason? }`) when a status
changes, and `host.key.updated` (`{ hostId }`) when a changed host key is
accepted. The status half of the old `stats_config` moved into the two columns
(`host-status-config-migration.ts`); its metrics half is host-metrics's.
**Host metrics (B16)** polls only hosts somebody is looking at (a tab, a
dashboard row, a homepage widget, or another plugin through the
`host-metrics.viewers` service, v1: `register(hostId)`, `heartbeat`,
`unregister`, all as the caller). It samples over `ctx.ssh` pools and timers
from `ctx.schedule`, keeps the adaptive metrics concurrency (one worker per 20
polled hosts, 5 to 50, `METRICS_POLL_CONCURRENCY` overrides), and waits for
core to call a host reachable before the first heavy sample. It emits
`plugin.host-metrics.snapshot` (`{ hostId, ownerUserId, metrics }`) for every
sample, `plugin.host-metrics.status` (`{ hostId, ownerUserId, state,
previous }`, state `collecting`, `auth_failed`, `host_key_changed` or
`unreachable`) when collection changes, and `plugin.host-metrics.health-check`
(`{ hostId, userId, checkId, ok, detail? }`) for each health check result;
automations subscribes to them. A plugin adds data to every sample by
providing `host-metrics.collectors` (v1: `collect(client, hostId)`) under its
own provider name; the result lands under `extra[<name>]`. Proxmox does not:
its stats come from the Proxmox API, not from SSH on the host. The intervals,
history retention and the new-host default are admin settings, the
temperature unit a user setting, and the rest of `stats_config` host
settings.
### 10. Lifecycle
`activate(ctx)` creates all state. Everything registered through `ctx` or `app`
@@ -999,7 +1049,11 @@ the database, unused, until 3.0.0 removes it. A copy reads it with raw SQL
through `utils/crypto-migration/raw-rows.ts`, whose `selectRows` and
`runStatement` work on all three engines: `getDb().all()` exists only on
SQLite. Remote desktop's host options and `user_preferences.rdp_defaults` are
the first columns handled this way.
the first columns handled this way. **B16** split `stats_config` the same way, into
two core columns and host-metrics's host settings. Adding columns and
dropping one in the same drizzle-kit run makes it ask whether that is a
rename, which it cannot do without a terminal, so B16 generated two
migrations: the added columns first, then the no-op drop.
---
@@ -1266,12 +1320,13 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.asUser(userId, fn)` | none, always audited | **A1** |
| `ctx.currentActor()` | none | **A1** |
| `ctx.db.define` / `.client` / `.refs` | `db:own` | **A3** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9** |
| `ctx.db.persist` / `.dialect` | `db:own` (persist only) | **A9**, lazy **B16** |
| `ctx.sync.registerEntity` | none | **A3** |
| `ctx.http.router` / `ctx.ws.route` / `.upgrade` | `network:serve` | **A4** |
| `ctx.rbac.has` / `.hasFor` / `.require` | own permissions only | **A5** |
| `ctx.capabilities.has` / `.require` | the capability itself | **B11** |
| `ctx.hosts.*` | `hosts:read` / `hosts:write` | **B4**, extended **B5** |
| `ctx.hosts.status.*` | `hosts:read` | **B16** |
| `ctx.ssh.*` | `ssh:connect`, `credentials:use` | **A8** |
| `ctx.settings.*` | `settings:read-core` (readCore only) | **A6** |
| `ctx.notify.*` | `notify:send` | A6 |
@@ -1280,6 +1335,7 @@ Built per plugin in `src/backend/plugins/ctx.ts` and passed to `activate`.
| `ctx.desktop.launchNativeRdp` / `.available` | `desktop:window` (launch only) | **B14** |
| `ctx.credentials.resolveHostProtocol` | `credentials:read` | **B14** |
| `ctx.audit.record` | none, the actor is the runtime's | **B9** |
| `ctx.schedule.every` / `.after` | none | **B16** |
| `ctx.fetch` | `network:outbound` | B |
**B11** added `ctx.capabilities.has(capability)` / `.require(capability)`, a
@@ -1431,6 +1487,39 @@ resolution (the owner's shared snapshot or their own override), never the
owner's raw secret; no connect access, or no host, is `null`. Every call is
audited as `plugin_credentials_read`, allowed or refused.
**B16** added `ctx.hosts.status`, the plugin side of core's host status
check (see [Host status](#host-status-is-core)):
- `get(hostId)` is core's last result, or `null` before the first check or
for a host with status checks off. `check(hostId)` checks now unless the
last result is under 30 seconds old. host-metrics calls it before the first
heavy sample of a host.
- `reportLogin(hostId, { ok, hostKeyChanged? })` tells core a login to the
host worked or failed. A working login is what turns "reachable" into
"online". Synchronous and not audited, like `trackSession`: a poller calls
it on every sample.
- `registerPort(connectionType, resolve)` tells core which port proves a host
of that connection type is up, for a protocol whose port is the plugin's own
host setting. remote-desktop registers `rdp`, `vnc` and `telnet`; without
it core checks the host's port. Removed on deactivate.
**B16** also added `ctx.ssh.dropPooled(pool, hostId)`, which closes this
plugin's pooled connections to one host (host-metrics calls it on
`host.updated`). It only touches keys this plugin created, so, like
deactivate, it never closes another plugin's connections. The core pool is
never cleared as a whole by a plugin.
**B16** added `ctx.schedule`: `every(ms, fn, { jitterMs?, runNow? })` and
`after(ms, fn)`, each returning a function that stops it. Timers live in the
plugin's disposable bag, so deactivate clears them; a run that throws is
logged, and a repeating job skips a tick while its last run is still going.
`jitterMs` delays the first run by a random amount up to that, so a fleet of
per-host timers spreads out. Needs no capability.
`ctx.db.persist({ lazy: true })` (**B16**) marks the database dirty and lets
the debounced save write it, for frequent low-value writes such as metrics
samples; the default still saves before returning.
`ctx.hosts.create` (**B14**) hands the created row to every plugin's
`hostImportNormalizer`, the same as a bulk import row, so a plugin creating a
host can set another plugin's host settings (proxmox marks an imported
@@ -1961,7 +2050,8 @@ Then, with the app running:
## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2) and remote-desktop (B14), which import nothing from core. The others still reach core by relative
(B2), remote-desktop (B14) and host-metrics (B16), which import nothing from
core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or
@@ -1988,9 +2078,9 @@ What the lint fence enforces today, in `eslint.config.mjs`:
| Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 120 files | D1 |
| A plugin importing core by relative path | Warning | 63 files | D1 |
| A plugin importing another plugin's source | Warning | 3 files | B18 |
| A plugin frontend importing core through `@/` | Warning | 84 files | D1 |
| A plugin importing core by relative path | Warning | 29 files | D1 |
| A plugin importing another plugin's source | Warning | 1 file | B18 |
A warning does not fail a build, so the counts are held by
`scripts/check-plugin-boundaries.cjs`, run by `npm run lint`. Every offender is
@@ -2012,12 +2102,12 @@ Known specifics:
automations is disabled. In B18 `ai` calls automations through an
`automations` service as an `optionalDependency` and the direct import goes
away.
- **TODO(B18):** `plugins/host-metrics/src/backend/` reaches `automations`
twice, for a `MetricsSnapshot` type and a fire-and-forget
`import("...headless-viewer.js")` that registers a viewer bridge. Both
degrade silently when automations is absent, so host-metrics declares it as
an `optionalDependency` (added in A2; it previously declared nothing, and
the loader had no reason to order the two).
- **B16** moved host-metrics onto the SDK: it imports nothing from core or
automations. Automations reaches it through the `host-metrics.viewers`
service and its `plugin.host-metrics.*` events, and core reaches it through
the `dashboard.hostMetrics` and `homepage.hostMetrics` slots and the
`host-metrics.disk` action. Core's `hosts/metrics-shared/` is gone: ai,
automations, proxmox and tailscale use `@termix/plugin-sdk/host-commands`.
- Core feature servers that are not plugins yet still own ports: dashboard
30006 and homepage 30012. Each keeps its nginx block until its own Phase B
step. No plugin owns a port any more (A4). **B6** moved file-manager off
+56 -19
View File
@@ -10,29 +10,15 @@ it's done.
shared with host-metrics's originals. Promote a shared version into
`@termix/plugin-sdk/ui` once a second manager-card plugin needs one, per the
contract's "add to the SDK when a second plugin needs it" rule. Owner: D1.
- **B3 (tailscale):** `plugins/tailscale/src/backend/host-metrics-manager.ts`
and `routes.ts` still resolve hosts and access through core's
`PermissionManager`/`resolveHostById`/`DataCrypto` by relative import
instead of `ctx.hosts`, which **B4** built. Same debt every other
host-metrics manager already carries; not new, but tailscale now carries it
too. Owner: host-metrics's own Phase B step (it and every manager plugin
convert together).
- **B4 (fleets/host-metrics):** `src/backend/hosts/metrics-shared/` (platform
detection, package commands, exec-elevated) is still core's own copy, used
by host-metrics, which has not converted yet. `@termix/plugin-sdk/host-commands`
is the SDK copy fleets now uses; core's copy was left in place rather than
turned into a re-export, since host-metrics's own conversion step is where
its imports should move wholesale. Owner: host-metrics's Phase B step.
- **B5 (proxmox):** `runAdaptivePolling` and `cn` are duplicated in
`plugins/proxmox/src/frontend/stats/` rather than shared with core's
`src/ui/lib/` originals. `useConnectionRetry` (the third duplicate B5 left
here) is resolved: **B6** promoted it into `@termix/plugin-sdk/frontend`
once file-manager became its fourth caller (after proxmox, docker,
host-metrics, remote-desktop) and switched proxmox's own copy over too.
host-metrics's own conversion needs `runAdaptivePolling` for its stats tab,
so promote it into `@termix/plugin-sdk/frontend` (and `cn` into
`@termix/plugin-sdk/ui`) the next time either is touched. Owner: D1, or
host-metrics's Phase B step if it lands first.
`runAdaptivePolling` and `cn` are exported from `@termix/plugin-sdk/ui`
now (B9), and host-metrics uses them there (B16); only proxmox's copies are
left to switch. Owner: D1.
- **B5 (proxmox):** `runDueProxmoxAutoSyncs` in
`plugins/proxmox/src/backend/routes.ts` still reaches
`createCurrentPluginSettingsRepository`/`createCurrentHostRepository` by
@@ -102,8 +88,9 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`logActivity` is a core `@/main-axios` call and the plugin imports nothing
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would
restore it for this plugin and let docker, file-manager and host-metrics drop
their own `@/main-axios` import for it too. Owner: D1.
restore it for this plugin and let docker and file-manager drop their own
`@/main-axios` import for it too (host-metrics uses the `logActivity` the
ui entry exports). Owner: D1.
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected
remote server are no longer merged into the tab: the old code polled the
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no
@@ -345,6 +332,46 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
`@termix/plugin-sdk/ui`), workspaces included. `npm run type-check` covers
plugins through `tsconfig.plugins*.json` and passes. Owner: D0.
- **B16 (host-metrics):** automations still sets up its headless viewers
through `setViewerRegistry` in `plugins/automations/src/backend/headless-viewer.ts`,
which nothing calls any more, so hosts an automation watches are only
sampled while somebody has them open. Switch it to the
`host-metrics.viewers` service (`register(hostId)` as the automation's
owner, `heartbeat`, `unregister`) and make host-metrics an optional
dependency again. Its triggers already listen to the final topics
(`plugin.host-metrics.snapshot`, `plugin.host-metrics.health-check`, core
`host.status`, `host.login`), but through core's bus by relative import
instead of `ctx.events`. Owner: B17.
- **B16 (host-metrics):** Termix-Mobile read `statsConfig` on each host
(`statusCheckEnabled`, `enabledWidgets`) and the host status from the
metrics API. The host payload now carries `statusCheckEnabled` and
`statusCheckInterval` at the top level, the metrics options under
`pluginSettings["host-metrics"]`, and statuses come from `GET /host/status`.
The mobile app needs the matching change, or the host payload a
`statsConfig` built from those fields for one release. Owner: D1.
- **B16 (host-metrics):** a host's status is only checked once its owner has
asked for statuses (opened the app) since the server started: a user who
only has a host shared with them sees no dot for it until then. This was
already true before B16 (the first user to load the host list started
polling for their own hosts only). Starting checks for the owners of every
host a requester can see would fix it. Owner: D1.
- **B16 (host-metrics):** the host JSON export does not carry host-metrics's
host settings (it did carry `statsConfig`); import reads both the old
`statsConfig` and `pluginSettings["host-metrics"]` through the plugin's
import normalizer. Same gap as the B7 line about exporting plugin host
settings generically. Owner: D1.
- **B16 (host-metrics):** the CPU and network collectors keep their last
sample per host in module-level maps (`widgets/cpu-collector.ts`,
`widgets/network-collector.ts`) to compute rates. The poller clears them on
deactivate and per host, so nothing leaks, but they are still module state
rather than per-activation. Owner: D1.
- **Pre-existing, found in B16:** automations
`tests/backend/tunnels.test.ts` fails "fails the step cleanly while the
tunnels plugin is off": it expects "tunnels plugin is not available" and
gets "tunnels.start is not a function". Same cause as the tunnels line
above: since B12 `ctx.services.get` hands back an empty handle instead of
throwing, so the step has to check `typeof start`. Owner: B17 or D0.
## Manual checks after 2.9.0
- SSH terminal: password auth, key auth (and an encrypted key's passphrase
@@ -362,6 +389,16 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
off records nothing, retention prunes an old recording on schedule, a guacd
RDP/VNC/Telnet recording still gets a row and plays back, and deleting a
user anonymizes their recordings instead of removing them.
- Host status and metrics: on a 2.8 database, hosts whose status checks were
off stay off and a custom interval survives; the host list dot goes
reachable, then online once the Host Metrics tab or a terminal logs in;
an RDP-only host on a custom port shows online; the metrics tab, the
dashboard host bars, the homepage host status widget, the metrics chart
widget, the terminal toolbar bars and the file manager's disk bar; a
manager card (services, packages) and a health check; TOTP on the metrics
connect; the admin metrics interval, history retention and new-host default;
the temperature unit; disabling host metrics leaves every other plugin's
SSH working, then enabling it again.
- Remote desktop: an RDP, a VNC and a Telnet host; a host behind one jump
host (tunnels plugin on and off) and behind a chain of two; macOS Screen
Sharing over VNC with no username; RDP recording (and none with
+80 -1
View File
@@ -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
+21
View File
@@ -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();
}
+109 -2
View File
@@ -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: {
+1
View File
@@ -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 },