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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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