mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 14:15:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
+3
-1
@@ -1 +1,3 @@
|
||||
npx lint-staged
|
||||
# The .cmd shims on Windows add their own paths to each command, so keep
|
||||
# lint-staged's chunks well under cmd.exe's 8191 character limit.
|
||||
npx lint-staged --max-arg-length 4000
|
||||
@@ -161,7 +161,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-teIJ2CKVs77AjLM0kquz3mSdDahr9QtMZGG0tQjbbkU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
+1
-1
@@ -142,7 +142,7 @@ http {
|
||||
root /app/html;
|
||||
index index.html index.htm;
|
||||
expires off;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-teIJ2CKVs77AjLM0kquz3mSdDahr9QtMZGG0tQjbbkU='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'wasm-unsafe-eval' 'sha256-gjgrKvv0zkJ6QUgUJBcK4CPIIa2JbLMmHecileCoA9A='; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: http: https:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; media-src 'self' data: blob: http: https:; worker-src 'self' blob:; frame-src http: https:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
|
||||
add_header X-Frame-Options "DENY" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- ssh_data.enable_docker and docker_config moved to the docker plugin's host
|
||||
-- settings. The copy runs at boot, after this migration, so a real DROP
|
||||
-- COLUMN here would delete the data first. The columns stay in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -316,6 +316,13 @@
|
||||
"when": 1790266402855,
|
||||
"tag": "0044_jittery_thunderball",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 45,
|
||||
"version": "5",
|
||||
"when": 1790268466430,
|
||||
"tag": "0045_flowery_the_hood",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- ssh_data.enable_docker and docker_config moved to the docker plugin's host
|
||||
-- settings. The copy runs at boot, after this migration, so a real DROP
|
||||
-- COLUMN here would delete the data first. The columns stay in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -323,6 +323,13 @@
|
||||
"when": 1790266402403,
|
||||
"tag": "0045_thankful_shinobi_shaw",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 46,
|
||||
"version": "7",
|
||||
"when": 1790268465907,
|
||||
"tag": "0046_small_white_tiger",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
-- ssh_data.enable_docker and docker_config moved to the docker plugin's host
|
||||
-- settings. The copy runs at boot, after this migration, so a real DROP
|
||||
-- COLUMN here would delete the data first. The columns stay in place,
|
||||
-- unused, until 3.0.0.
|
||||
SELECT 1;
|
||||
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,13 @@
|
||||
"when": 1790266401908,
|
||||
"tag": "0041_nosy_mystique",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 42,
|
||||
"version": "6",
|
||||
"when": 1790268465325,
|
||||
"tag": "0042_many_old_lace",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -248,10 +248,18 @@ not merely unrouted: a terminal session must not outlive the plugin that owns it
|
||||
authenticated it, for a library that insists on owning its own WebSocketServer.
|
||||
guacamole-lite is why it exists. Three routes are public because they
|
||||
authenticate themselves in a way core cannot: the terminal (a share-link guest
|
||||
arrives with a share token), the Docker console (per-message auth), and the
|
||||
arrives with a share token), the Docker console (it refuses a socket without a signed-in user holding
|
||||
`docker.use`, and re-checks the data key per message), and the
|
||||
Guacamole display (a single-use encrypted connection token in the query, minted
|
||||
by an authenticated route).
|
||||
|
||||
**B15** moved the Docker console off `ctx.ws.upgrade` and its own
|
||||
`WebSocketServer` onto `ctx.ws.route` with `public` and `optionalAuth`: core
|
||||
resolves the caller's token, the handler closes a socket without a user or
|
||||
without `docker.use`, and it checks `isDataUnlocked()` on every connect
|
||||
message. Nothing is left at module scope, so disable, enable while a console
|
||||
is open closes it and the next enable serves new ones.
|
||||
|
||||
**B9** added `optionalAuth` for a public route: core still verifies a token
|
||||
when the client sent one and hands the handler that user, else an empty id.
|
||||
The terminal uses it, because one socket serves both signed-in users and
|
||||
@@ -348,7 +356,9 @@ plugin id in `src/ui` outside tests, apart from the entries in
|
||||
`scripts/shell-plugin-id-allowlist.json`, each with a reason (host data like
|
||||
the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
|
||||
`vnc` and `telnet` entries: the shell learns those protocols from
|
||||
`app.registerHostProtocol`.
|
||||
`app.registerHostProtocol`. **B15** removed both `docker` entries: the Hosts
|
||||
panel's Docker filter and the bulk Docker toggles went with the column, and
|
||||
the host editor no longer carries a Proxmox default that spelled `docker`.
|
||||
|
||||
#### The loader
|
||||
|
||||
@@ -581,7 +591,10 @@ 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
|
||||
metrics chart widget. **B15** added `useUiPreferencesContext`, whose `setOverride` the
|
||||
Docker manager uses to remember the card or table layout the user picked.
|
||||
The `docker` and `hostMetrics` areas are still declared in core's
|
||||
`types/ui-preferences.ts`; a plugin cannot contribute its own area yet. 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
|
||||
@@ -672,6 +685,17 @@ user. Recording files live
|
||||
under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
|
||||
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours.
|
||||
|
||||
**B15**'s docker plugin provides `docker.containers` (`listContainers`,
|
||||
`action`) and `docker.events` (`subscribe(hostId, listener)`), both gated on
|
||||
`docker.use`. The service name needs a dot, so the plain `docker` the step
|
||||
was sketched with became `docker.containers`. `docker.events` polls each
|
||||
subscribed host about once a minute as the subscribing user and diffs the
|
||||
snapshots; a subscription is idempotent, which is how automations picks it
|
||||
back up after docker is disabled and enabled again (it re-subscribes every
|
||||
watched host each minute). Automations uses both as optional services: its
|
||||
Docker step goes through `action`, and its `docker_event` trigger through
|
||||
`subscribe`.
|
||||
|
||||
A service call runs a permission check for its actor, so a share-link guest,
|
||||
who has no user, cannot make one. Guest link resolution is therefore
|
||||
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is
|
||||
@@ -802,6 +826,11 @@ missing service with an empty handle, like the runtime, instead of throwing. **B
|
||||
before), so `provide`/`consume`/`revoke` round-trip the way the real registry
|
||||
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example.
|
||||
|
||||
The doubles' `wsRoutes` (**B15**) record each route's `handler` (or the
|
||||
`upgrade` callback) and `options`, so a test can hand a route a fake socket;
|
||||
docker's `tests/backend/console.test.ts` opens a console, disposes the plugin
|
||||
and activates it again that way.
|
||||
|
||||
`renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
|
||||
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
|
||||
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs
|
||||
@@ -1366,8 +1395,14 @@ connect pipeline instead of importing ssh2 helpers from core:
|
||||
a chain has no single resolved host of its own.
|
||||
- `prepare(host, { client, purpose })`, `openTransport`,
|
||||
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a
|
||||
transport with its own prompt flow (docker's console, host metrics, **B6**'s
|
||||
interactive file-manager connect with its TOTP/Warpgate parking flow).
|
||||
transport with its own prompt flow (**B6**'s interactive file-manager
|
||||
connect with its TOTP/Warpgate parking flow).
|
||||
- **B15** showed the higher level covers a multi-request HTTP handshake too:
|
||||
the Docker panel passes `connect` a `prompt` channel whose `ask` answers the
|
||||
waiting request with what to ask (a code, a Warpgate sign-in, credentials)
|
||||
and resolves when the next request (`connect-totp`, `connect-warpgate`)
|
||||
brings the answer. A retry TOTP round re-asks instead of failing. docker
|
||||
has no auth code of its own left.
|
||||
- `requiresSecret(authType)` and `supportsBackground(authType)` ask the
|
||||
provider.
|
||||
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose`
|
||||
@@ -2050,8 +2085,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), remote-desktop (B14) and host-metrics (B16), which import nothing from
|
||||
core. The others still reach core by relative
|
||||
(B2), remote-desktop (B14), docker (B15) 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
|
||||
@@ -2078,8 +2113,8 @@ 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 | 84 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 29 files | D1 |
|
||||
| A plugin frontend importing core through `@/` | Warning | 70 files | D1 |
|
||||
| A plugin importing core by relative path | Warning | 24 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
|
||||
@@ -2119,8 +2154,10 @@ Known specifics:
|
||||
command history panel into ssh-terminal, which imports nothing from core.
|
||||
The file manager's terminal window renders the `terminal.view` component
|
||||
instead of importing the core terminal.
|
||||
- Host data columns (`enableDocker` and so on) stay in core types until
|
||||
Phase B moves them. Only UI branches on them left the shell. **B14** moved
|
||||
- Host data columns (`enableFileManager` and so on) stay in core types until
|
||||
Phase B moves them. Only UI branches on them left the shell. **B15** moved
|
||||
docker's `enableDocker` and `docker_config` (now `containerRuntime`) into
|
||||
its host settings. **B14** moved
|
||||
remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
|
||||
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
|
||||
the protocol logins stay core host fields.
|
||||
|
||||
@@ -80,7 +80,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
columns, so none of them can see a plugin's host settings. For tunnels the
|
||||
"Tunnel" filter and the bulk enable/disable entries were removed, and the
|
||||
export's "Tunnels" group became "Proxy" (SOCKS5 fields only) since
|
||||
`tunnelConnections` there is now stale. Bringing these back needs a way for
|
||||
`tunnelConnections` there is now stale. **B15** removed the "Docker" filter
|
||||
and the bulk Docker toggles the same way. Bringing these back needs a way for
|
||||
core to read and write host-scope plugin settings generically (keyed off
|
||||
`contributes.settings.host` or `hostCapability`) rather than per column.
|
||||
Owner: D1.
|
||||
@@ -88,9 +89,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 and file-manager drop their own
|
||||
`@/main-axios` import for it too (host-metrics uses the `logActivity` the
|
||||
ui entry exports). Owner: D1.
|
||||
restore it for this plugin and let file-manager drop its own
|
||||
`@/main-axios` import for it too (host-metrics and docker use 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
|
||||
@@ -289,7 +290,8 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
Their drizzle migrations are `SELECT 1;` on purpose (the copy runs after
|
||||
drizzle on Postgres and MySQL). Drop them physically in 3.0.0, once every
|
||||
install has booted 2.9.0. The legacy `guac_enabled` and `guac_url` rows in
|
||||
`settings` are left too. Owner: 3.0.0.
|
||||
`settings` are left too. **B15** did the same with `ssh_data.enable_docker`
|
||||
and `docker_config` (copied by `docker-settings-migration.ts`). Owner: 3.0.0.
|
||||
- **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
|
||||
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
|
||||
proxmox host columns. On Postgres and MySQL that runs at boot before
|
||||
@@ -371,6 +373,31 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
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.
|
||||
- **B15 (docker), for D2:** the console socket `/plugin-ws/docker/console`
|
||||
is a public route with `optionalAuth`. The handler refuses a socket with no
|
||||
signed-in user or without `docker.use` and checks the data key per connect
|
||||
message, but review it with the rest of the public routes.
|
||||
- **B15 (docker):** `ssh_data.show_docker_in_sidebar` (and its siblings for
|
||||
terminal, files, tunnels and server stats) are dead columns nothing reads;
|
||||
only the raw database export still copies them. Drop them the B14 way.
|
||||
Owner: D0.
|
||||
- **B15 (docker):** the Docker manager's card or table layout is still an
|
||||
override on core's Appearance presets (`types/ui-preferences.ts` declares a
|
||||
`docker` area, as it does `hostMetrics`), reached through the ui entry's
|
||||
`useAreaPreferences` and `useUiPreferencesContext`. A plugin cannot declare
|
||||
its own area yet. Owner: D1.
|
||||
- **B15 (docker):** the raw SQLite database import (`database.ts`) no longer
|
||||
brings `enable_docker` across, the same gap every earlier column move left
|
||||
there: it writes host rows only and never plugin settings. Owner: D1, with
|
||||
the generic host-settings export and import.
|
||||
- **B15 (docker), for B18:** the AI assistant's `list_hosts`/`get_host`
|
||||
tools still report `enableDocker` from the host row, which no longer has
|
||||
the column, so it always reads null. Read `pluginSettings.docker` or ask the
|
||||
`docker.containers` service instead. Owner: B18.
|
||||
- **B15 (docker), for B17:** automations' `docker-watcher.ts` still reads its
|
||||
own rules through core's automation repository and types by relative
|
||||
import; only the Docker side is on the SDK (`docker.containers` for the
|
||||
step, `docker.events` for the trigger). Owner: B17.
|
||||
|
||||
## Manual checks after 2.9.0
|
||||
|
||||
@@ -411,3 +438,9 @@ build`'s esbuild step has no static-asset-copy pipeline the way core's Vite
|
||||
that only had `connection_type = rdp` comes up as RDP with SSH off; RDP user
|
||||
defaults apply to a saved host; Quick Connect over RDP and VNC; the desktop
|
||||
app with a host on the remote server and one on This device.
|
||||
- Docker: a 2.8 host with Docker on (and one on Podman) keeps its switch and
|
||||
runtime; list, start, stop, logs, stats and the console on a Linux host;
|
||||
TOTP (including a wrong code first) and Warpgate on the connect; a host
|
||||
with no stored password asks for credentials; the homepage Docker widget;
|
||||
an automation Docker step and a `docker_event` trigger; disable Docker with
|
||||
a console open, then enable it and open another.
|
||||
@@ -487,7 +487,6 @@ export interface PluginHostRecord {
|
||||
enableTerminal?: boolean | null;
|
||||
enableFileManager?: boolean | null;
|
||||
enableTunnel?: boolean | null;
|
||||
enableDocker?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
[key: string]: unknown;
|
||||
|
||||
@@ -17,7 +17,24 @@ import type {
|
||||
import type { TermixApp } from "./frontend.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { PluginTableDefinition } from "./db.js";
|
||||
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
|
||||
import type {
|
||||
PluginMiddleware,
|
||||
PluginRouterOptions,
|
||||
PluginWebSocketHandler,
|
||||
PluginWebSockets,
|
||||
PluginWebSocketOptions,
|
||||
} from "./backend.js";
|
||||
|
||||
/** A ctx.ws registration as the test doubles record it. */
|
||||
export interface FakeWsRoute {
|
||||
path: string;
|
||||
raw: boolean;
|
||||
/** For ctx.ws.route. */
|
||||
handler?: PluginWebSocketHandler;
|
||||
/** For ctx.ws.upgrade. */
|
||||
upgrade?: Parameters<PluginWebSockets["upgrade"]>[1];
|
||||
options?: PluginWebSocketOptions;
|
||||
}
|
||||
import type {
|
||||
PluginLoginMethod,
|
||||
PluginSecondFactor,
|
||||
@@ -115,8 +132,11 @@ export interface FakePluginContext {
|
||||
syncEntities: SyncEntityRegistration[];
|
||||
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */
|
||||
tombstones: Array<{ userId: string; entityType: string; syncId: string }>;
|
||||
/** WebSocket routes registered through ctx.ws, in order. */
|
||||
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||
/**
|
||||
* WebSocket routes registered through ctx.ws, in order, with the handler
|
||||
* and options, so a test can hand a route a fake socket.
|
||||
*/
|
||||
wsRoutes: FakeWsRoute[];
|
||||
/** Router options passed to ctx.http.router, in order. */
|
||||
httpRouters: Array<PluginRouterOptions | undefined>;
|
||||
/** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */
|
||||
@@ -237,7 +257,7 @@ export function createFakeContext(
|
||||
entityType: string;
|
||||
syncId: string;
|
||||
}> = [];
|
||||
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||
const wsRoutes: FakeWsRoute[] = [];
|
||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
const settings = new Map<string, unknown>();
|
||||
@@ -466,11 +486,16 @@ export function createFakeContext(
|
||||
},
|
||||
|
||||
ws: {
|
||||
route: (path) => {
|
||||
wsRoutes.push({ path, raw: false });
|
||||
route: (path, handler, wsOptions) => {
|
||||
wsRoutes.push({ path, raw: false, handler, options: wsOptions });
|
||||
},
|
||||
upgrade: (path) => {
|
||||
wsRoutes.push({ path, raw: true });
|
||||
upgrade: (path, handler, wsOptions) => {
|
||||
wsRoutes.push({
|
||||
path,
|
||||
raw: true,
|
||||
upgrade: handler,
|
||||
options: wsOptions,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"optionalDependencies": {
|
||||
"snippets": "^1.0.0",
|
||||
"fleets": "^1.0.0",
|
||||
"tunnels": "^1.0.0"
|
||||
"tunnels": "^1.0.0",
|
||||
"docker": "^1.0.0"
|
||||
},
|
||||
"requires": [
|
||||
{
|
||||
@@ -46,6 +47,16 @@
|
||||
"service": "tunnels.access",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "docker.containers",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
},
|
||||
{
|
||||
"service": "docker.events",
|
||||
"versionRange": "^1.0.0",
|
||||
"optional": true
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
|
||||
@@ -2,14 +2,11 @@ import {
|
||||
DEFAULT_STEP_TIMEOUT_MS,
|
||||
type Step,
|
||||
} from "../../../../../src/types/automations.js";
|
||||
import {
|
||||
execCommand,
|
||||
execElevated,
|
||||
shellSingleQuote,
|
||||
} from "@termix/plugin-sdk/host-commands";
|
||||
import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "../ssh.js";
|
||||
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
|
||||
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
|
||||
import { runDockerAction } from "../docker.js";
|
||||
import { runTunnelAction } from "../tunnels.js";
|
||||
import { sendAutomationNotification } from "../notify.js";
|
||||
import { automationFetch } from "../http.js";
|
||||
@@ -227,11 +224,15 @@ async function runDocker(
|
||||
};
|
||||
}
|
||||
|
||||
// The Docker HTTP routes are tied to an interactive session, so run the
|
||||
// equivalent command over the same pooled SSH connection everything else
|
||||
// uses. The container name is quoted because it comes from a template.
|
||||
const command = `docker ${step.action} ${shellSingleQuote(container)}`;
|
||||
return execOnHost(target.host, command, false, context, step.timeoutMs);
|
||||
const result = await runDockerAction(
|
||||
context.userId,
|
||||
target.id,
|
||||
container,
|
||||
step.action,
|
||||
);
|
||||
return result.ok
|
||||
? { output: `Container ${container}: ${step.action} done` }
|
||||
: { output: "", error: result.error };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,41 +1,29 @@
|
||||
import type { AutomationDefinition } from "../../../../src/types/automations.js";
|
||||
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
|
||||
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js";
|
||||
import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { execCommand } from "@termix/plugin-sdk/host-commands";
|
||||
import { withSshConnection } from "./ssh.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { subscribeDockerEvents, type DockerEvent } from "./docker.js";
|
||||
import { onDockerEvent } from "./triggers.js";
|
||||
|
||||
/**
|
||||
* Container state polling for docker_event triggers.
|
||||
* Keeps a docker.events subscription for every host a docker_event trigger
|
||||
* names, as the automation's owner. The docker plugin does the polling; this
|
||||
* only follows which hosts are watched.
|
||||
*
|
||||
* Everything else Docker-related in the backend hangs off an interactive
|
||||
* session that only exists while somebody has the UI open, so a trigger built
|
||||
* on it would only ever fire while being watched. This polls over the same
|
||||
* pooled SSH connection the other automation steps use, and only for hosts a
|
||||
* docker_event trigger actually names, so an install with no such automation
|
||||
* does no extra work at all.
|
||||
*
|
||||
* Events are derived by diffing successive snapshots: the poll interval is the
|
||||
* resolution, so a container that stops and starts between two polls is not
|
||||
* reported. That is the tradeoff for not holding a `docker events` stream open
|
||||
* against every host.
|
||||
* Subscribing is idempotent on the docker side, so every watched host is
|
||||
* subscribed again once a minute. That is what picks the subscriptions back
|
||||
* up after the docker plugin is disabled and enabled again.
|
||||
*/
|
||||
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const EXEC_TIMEOUT_MS = 15_000;
|
||||
const RESUBSCRIBE_MS = 60_000;
|
||||
|
||||
interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
interface Watch {
|
||||
listener: (event: DockerEvent) => void;
|
||||
unsubscribe: (() => void) | null;
|
||||
subscribedAt: number;
|
||||
}
|
||||
|
||||
/** Last snapshot per host, so transitions can be spotted. */
|
||||
const snapshots = new Map<number, Map<string, ContainerState>>();
|
||||
const lastPolledAt = new Map<number, number>();
|
||||
const watches = new Map<string, Watch>();
|
||||
|
||||
const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
|
||||
|
||||
/** Hosts named by an enabled docker_event trigger, with the owning user. */
|
||||
export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
|
||||
@@ -60,8 +48,8 @@ export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
|
||||
} else if (selector?.kind === "hosts") {
|
||||
for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
|
||||
}
|
||||
// Fleet and "all" selectors are deliberately not expanded: polling every
|
||||
// host a user owns for container state is far too costly to do blindly.
|
||||
// Fleet and "all" selectors are deliberately not expanded: watching
|
||||
// every host a user owns for container state is far too costly.
|
||||
}
|
||||
} catch {
|
||||
return watched;
|
||||
@@ -70,165 +58,58 @@ export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
|
||||
return watched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `docker ps -a` output. One JSON object per line, matching the format
|
||||
* string the container routes use.
|
||||
*/
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* Works out which events a pair of snapshots implies.
|
||||
*
|
||||
* A container missing from the previous snapshot is treated as newly seen
|
||||
* rather than started, so the first poll after a restart does not replay every
|
||||
* running container as a fresh start event.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting")
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
|
||||
// Health is independent of state: a container can go unhealthy while it
|
||||
// stays up, which is exactly the case worth alerting on.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
const PS_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
async function pollHost(hostId: number, userId: string): Promise<void> {
|
||||
const host = await resolveHostById(hostId, userId);
|
||||
if (!host) {
|
||||
snapshots.delete(hostId);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await withSshConnection(
|
||||
host as never as never,
|
||||
{ pool: "fleet", purpose: "fleet" },
|
||||
(client) =>
|
||||
execCommand(
|
||||
client,
|
||||
`docker ps -a --format ${PS_FORMAT}`,
|
||||
EXEC_TIMEOUT_MS,
|
||||
),
|
||||
/** Brings the subscriptions in line with the watched hosts. */
|
||||
export async function reconcileDockerWatch(
|
||||
now: number = Date.now(),
|
||||
watched?: Map<number, string>,
|
||||
): Promise<void> {
|
||||
const wanted = watched ?? (await listDockerWatchedHosts());
|
||||
const wantedKeys = new Set(
|
||||
[...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
|
||||
);
|
||||
|
||||
if (result.code !== 0 && result.code !== null) {
|
||||
// Docker missing or not permitted on this host. Drop the snapshot so a
|
||||
// later success is treated as a first observation rather than a diff.
|
||||
snapshots.delete(hostId);
|
||||
return;
|
||||
for (const [key, watch] of watches) {
|
||||
if (wantedKeys.has(key)) continue;
|
||||
watch.unsubscribe?.();
|
||||
watches.delete(key);
|
||||
}
|
||||
|
||||
const current = parseContainerStates(result.stdout);
|
||||
const previous = snapshots.get(hostId);
|
||||
snapshots.set(hostId, current);
|
||||
|
||||
if (!previous) return;
|
||||
|
||||
for (const { container, event } of diffContainerStates(previous, current)) {
|
||||
await onDockerEvent({
|
||||
hostId,
|
||||
ownerUserId: userId,
|
||||
container,
|
||||
event,
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polls every watched host whose interval has elapsed. Called from the
|
||||
* automation scheduler tick rather than owning a timer of its own.
|
||||
*/
|
||||
export async function pollDockerEvents(
|
||||
now: number = Date.now(),
|
||||
): Promise<void> {
|
||||
const watched = await listDockerWatchedHosts();
|
||||
|
||||
for (const hostId of [...snapshots.keys()]) {
|
||||
if (!watched.has(hostId)) {
|
||||
snapshots.delete(hostId);
|
||||
lastPolledAt.delete(hostId);
|
||||
for (const [hostId, userId] of wanted) {
|
||||
const key = keyOf(hostId, userId);
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
listener: (event) => {
|
||||
void onDockerEvent({
|
||||
hostId: event.hostId,
|
||||
ownerUserId: userId,
|
||||
container: event.container,
|
||||
event: event.event,
|
||||
}).catch(() => undefined);
|
||||
},
|
||||
unsubscribe: null,
|
||||
subscribedAt: 0,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [hostId, userId] of watched) {
|
||||
const last = lastPolledAt.get(hostId) ?? 0;
|
||||
if (now - last < POLL_INTERVAL_MS) continue;
|
||||
// Host credentials cannot be decrypted while the owner's key is locked.
|
||||
if (!canAccess(userId)) continue;
|
||||
lastPolledAt.set(hostId, now);
|
||||
|
||||
try {
|
||||
await pollHost(hostId, userId);
|
||||
} catch (error) {
|
||||
statsLogger.warn("Docker event poll failed", {
|
||||
operation: "automation_docker_poll_error",
|
||||
watch.unsubscribe = await subscribeDockerEvents(
|
||||
userId,
|
||||
hostId,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
watch.listener,
|
||||
);
|
||||
watch.subscribedAt = watch.unsubscribe ? now : 0;
|
||||
} catch {
|
||||
watch.subscribedAt = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function canAccess(userId: string): boolean {
|
||||
try {
|
||||
return DataCrypto.canUserAccessData(userId);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Clears cached state, for shutdown and tests. */
|
||||
/** Drops every subscription, for shutdown and tests. */
|
||||
export function resetDockerWatcher(): void {
|
||||
snapshots.clear();
|
||||
lastPolledAt.clear();
|
||||
for (const watch of watches.values()) watch.unsubscribe?.();
|
||||
watches.clear();
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginServices | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setDockerServices(services: PluginServices | null): void {
|
||||
current = services;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: DockerEventName;
|
||||
}
|
||||
|
||||
/** The docker plugin's "docker.containers" service, version 1. */
|
||||
interface DockerService {
|
||||
action: (
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
/** The docker plugin's "docker.events" service, version 1. */
|
||||
interface DockerEventsService {
|
||||
subscribe: (
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
) => Promise<() => void>;
|
||||
}
|
||||
|
||||
const UNAVAILABLE = "The docker plugin is not available";
|
||||
|
||||
function service<T extends object>(
|
||||
name: string,
|
||||
userId: string,
|
||||
): Partial<T> | null {
|
||||
try {
|
||||
return current?.get<T>(name, { userId }) ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts, stops or restarts a container as userId. Optional: without the
|
||||
* docker plugin the step fails with a clear message.
|
||||
*/
|
||||
export async function runDockerAction(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: "start" | "stop" | "restart",
|
||||
): Promise<{ ok: boolean; unavailable?: boolean; error?: string }> {
|
||||
const docker = service<DockerService>("docker.containers", userId);
|
||||
if (!docker || typeof docker.action !== "function") {
|
||||
return { ok: false, unavailable: true, error: UNAVAILABLE };
|
||||
}
|
||||
try {
|
||||
await docker.action(hostId, container, action);
|
||||
return { ok: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes `listener` to container changes on a host as userId, or
|
||||
* resolves null while the docker plugin is off.
|
||||
*/
|
||||
export async function subscribeDockerEvents(
|
||||
userId: string,
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
): Promise<(() => void) | null> {
|
||||
const events = service<DockerEventsService>("docker.events", userId);
|
||||
if (!events || typeof events.subscribe !== "function") return null;
|
||||
return events.subscribe(hostId, listener);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { setPluginServices } from "./snippets.js";
|
||||
import { setTunnelServices } from "./tunnels.js";
|
||||
import { setDockerServices } from "./docker.js";
|
||||
import { startAutomationsService, stopAutomationsService } from "./routes.js";
|
||||
import {
|
||||
startAutomationScheduler,
|
||||
@@ -23,6 +24,8 @@ export async function activate(ctx: PluginContext) {
|
||||
ctx.disposables.add(() => setPluginServices(null));
|
||||
setTunnelServices(ctx.services);
|
||||
ctx.disposables.add(() => setTunnelServices(null));
|
||||
setDockerServices(ctx.services);
|
||||
ctx.disposables.add(() => setDockerServices(null));
|
||||
|
||||
// Nothing arrives here while the tunnels plugin is off, which is the whole
|
||||
// of the optional dependency.
|
||||
|
||||
@@ -4,7 +4,7 @@ import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
|
||||
import { statsLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import { computeNextDueAt } from "./cron.js";
|
||||
import { hasDwelled, isCoolingDown } from "./conditions.js";
|
||||
import { pollDockerEvents } from "./docker-watcher.js";
|
||||
import { reconcileDockerWatch, resetDockerWatcher } from "./docker-watcher.js";
|
||||
import { AutomationEngine } from "./engine.js";
|
||||
import { reconcileHeadlessViewers } from "./headless-viewer.js";
|
||||
import {
|
||||
@@ -57,6 +57,7 @@ export function stopAutomationScheduler(): void {
|
||||
tickTimer = null;
|
||||
startupTimer = null;
|
||||
unsubscribeAutomationTriggers();
|
||||
resetDockerWatcher();
|
||||
}
|
||||
|
||||
/** Exposed for tests; the interval calls this. */
|
||||
@@ -69,7 +70,7 @@ export async function tick(now: Date = new Date()): Promise<void> {
|
||||
await reconcileHeadlessViewers().catch(() => undefined);
|
||||
await runDueSchedules(now);
|
||||
await recheckOpenBreaches(now);
|
||||
await pollDockerEvents(now.getTime()).catch(() => undefined);
|
||||
await reconcileDockerWatch(now.getTime()).catch(() => undefined);
|
||||
await pruneIfDue(now);
|
||||
} catch (error) {
|
||||
statsLogger.warn("Automation scheduler tick failed", {
|
||||
|
||||
@@ -1,108 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||
|
||||
const fired = vi.hoisted(() => [] as unknown[]);
|
||||
|
||||
vi.mock("../../src/backend/triggers.js", () => ({
|
||||
onDockerEvent: async (event: unknown) => {
|
||||
fired.push(event);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../../../../src/backend/database/repositories/factory.js", () => ({
|
||||
createCurrentAutomationRepository: () => ({ listAllEnabled: async () => [] }),
|
||||
}));
|
||||
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
reconcileDockerWatch,
|
||||
resetDockerWatcher,
|
||||
} from "../../src/backend/docker-watcher.js";
|
||||
import {
|
||||
setDockerServices,
|
||||
runDockerAction,
|
||||
} from "../../src/backend/docker.js";
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part: turning
|
||||
* `docker ps` output into states, and turning two snapshots into events.
|
||||
*/
|
||||
type Listener = (event: {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: string;
|
||||
}) => void;
|
||||
|
||||
describe("parseContainerStates", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n");
|
||||
/** A docker.events provider that records who subscribed to what. */
|
||||
function fakeEvents() {
|
||||
const subscriptions = new Map<string, Set<Listener>>();
|
||||
const calls: Array<{ userId?: string; hostId: number }> = [];
|
||||
const services = {
|
||||
provide: () => {},
|
||||
providers: () => [],
|
||||
get: (name: string, options?: { userId?: string }) => {
|
||||
if (name !== "docker.events") return {};
|
||||
return {
|
||||
subscribe: async (hostId: number, listener: Listener) => {
|
||||
calls.push({ userId: options?.userId, hostId });
|
||||
const key = `${options?.userId}:${hostId}`;
|
||||
const set = subscriptions.get(key) ?? new Set<Listener>();
|
||||
set.add(listener);
|
||||
subscriptions.set(key, set);
|
||||
return () => set.delete(listener);
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PluginServices;
|
||||
const emit = (key: string, event: Parameters<Listener>[0]) => {
|
||||
for (const listener of subscriptions.get(key) ?? []) listener(event);
|
||||
};
|
||||
return { services, subscriptions, calls, emit };
|
||||
}
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
|
||||
expect(states.get("web")).toEqual({ state: "running", unhealthy: false });
|
||||
expect(states.get("db")).toEqual({ state: "exited", unhealthy: false });
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("skips blank and malformed lines rather than failing the poll", () => {
|
||||
const output = [
|
||||
'{"name":"web","state":"running","status":"Up"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
].join("\n");
|
||||
|
||||
const states = parseContainerStates(output);
|
||||
expect([...states.keys()]).toEqual(["web"]);
|
||||
});
|
||||
|
||||
it("returns nothing for empty output", () => {
|
||||
expect(parseContainerStates("").size).toBe(0);
|
||||
});
|
||||
beforeEach(() => {
|
||||
fired.length = 0;
|
||||
});
|
||||
|
||||
describe("diffContainerStates", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
afterEach(() => {
|
||||
resetDockerWatcher();
|
||||
setDockerServices(null);
|
||||
});
|
||||
|
||||
it("reports a container that stopped", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", exited]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "exited" }]);
|
||||
describe("docker watcher", () => {
|
||||
it("subscribes each watched host as its owner and fires the trigger", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
|
||||
await reconcileDockerWatch(0, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toEqual([{ userId: "user-1", hostId: 7 }]);
|
||||
|
||||
events.emit("user-1:7", { hostId: 7, container: "web", event: "exited" });
|
||||
await Promise.resolve();
|
||||
expect(fired).toEqual([
|
||||
{ hostId: 7, ownerUserId: "user-1", container: "web", event: "exited" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports a container that started", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", exited]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "started" }]);
|
||||
it("re-subscribes once a minute and drops hosts no longer watched", async () => {
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
const watched = new Map([[7, "user-1"]]);
|
||||
|
||||
await reconcileDockerWatch(1_000, watched);
|
||||
await reconcileDockerWatch(30_000, watched);
|
||||
expect(events.calls).toHaveLength(1);
|
||||
await reconcileDockerWatch(62_000, watched);
|
||||
expect(events.calls).toHaveLength(2);
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(1);
|
||||
|
||||
await reconcileDockerWatch(63_000, new Map());
|
||||
expect(events.subscriptions.get("user-1:7")?.size).toBe(0);
|
||||
});
|
||||
|
||||
it("reports a container that began restarting", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "restarting", unhealthy: false }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "restarting" }]);
|
||||
it("does nothing while the docker plugin is off, then picks it up", async () => {
|
||||
setDockerServices({
|
||||
get: () => ({}),
|
||||
} as unknown as PluginServices);
|
||||
await expect(
|
||||
reconcileDockerWatch(0, new Map([[7, "user-1"]])),
|
||||
).resolves.toBeUndefined();
|
||||
|
||||
const events = fakeEvents();
|
||||
setDockerServices(events.services);
|
||||
await reconcileDockerWatch(1, new Map([[7, "user-1"]]));
|
||||
expect(events.calls).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reports a container that went unhealthy while still running", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", { state: "running", unhealthy: true }]]),
|
||||
);
|
||||
expect(events).toEqual([{ container: "web", event: "unhealthy" }]);
|
||||
it("fails a docker step cleanly while the plugin is off", async () => {
|
||||
setDockerServices({ get: () => ({}) } as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: false,
|
||||
unavailable: true,
|
||||
error: "The docker plugin is not available",
|
||||
});
|
||||
});
|
||||
|
||||
it("stays quiet when nothing changed", () => {
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", running]]),
|
||||
new Map([["web", running]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not re-announce a container that is still unhealthy", () => {
|
||||
const unhealthy = { state: "running", unhealthy: true };
|
||||
const events = diffContainerStates(
|
||||
new Map([["web", unhealthy]]),
|
||||
new Map([["web", unhealthy]]),
|
||||
);
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
// A first sighting is a baseline, not an event: otherwise every container
|
||||
// running at boot would report itself as freshly started.
|
||||
it("treats a newly seen container as a baseline", () => {
|
||||
const events = diffContainerStates(new Map(), new Map([["web", running]]));
|
||||
expect(events).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores a container that disappeared", () => {
|
||||
const events = diffContainerStates(new Map([["web", running]]), new Map());
|
||||
expect(events).toEqual([]);
|
||||
it("runs a docker step through the docker service", async () => {
|
||||
const action = vi.fn(async () => {});
|
||||
setDockerServices({
|
||||
get: (name: string) => (name === "docker.containers" ? { action } : {}),
|
||||
} as unknown as PluginServices);
|
||||
expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
|
||||
ok: true,
|
||||
});
|
||||
expect(action).toHaveBeenCalledWith(7, "web", "restart");
|
||||
});
|
||||
});
|
||||
@@ -3,3 +3,20 @@
|
||||
## 1.0.0
|
||||
|
||||
Bundled with Termix 2.9.0.
|
||||
|
||||
- Runs entirely through the plugin SDK: no imports from Termix core.
|
||||
- Connects through Termix's one SSH pipeline, so TOTP, Warpgate, jump hosts
|
||||
and proxies behave the same as in the terminal. A wrong TOTP code asks
|
||||
again instead of ending the connect.
|
||||
- Each host's Docker switch and container runtime are this plugin's host
|
||||
settings now, moved over on upgrade. The host editor shows them in the
|
||||
Plugins tab.
|
||||
- The API lives at `/plugin-api/docker/` and the console at
|
||||
`/plugin-ws/docker/console`. Disabling the plugin closes open consoles and
|
||||
enabling it again works without a restart.
|
||||
- New `docker.use` permission, given to the admin and user roles.
|
||||
- Provides `docker.containers` (list containers, start, stop and the rest)
|
||||
and `docker.events` (container state changes) for other plugins, which
|
||||
Automations uses for its Docker step and trigger.
|
||||
- The Docker homepage widget comes from this plugin.
|
||||
- Downloaded logs are the plain log text.
|
||||
@@ -4,6 +4,18 @@ Container management over SSH: list, inspect, start, stop and view logs for Dock
|
||||
|
||||
Bundled with Termix and enabled by default.
|
||||
|
||||
## Services
|
||||
|
||||
- `docker.containers` 1.0.0: `listContainers(hostId, { all? })` and
|
||||
`action(hostId, container, "start" | "stop" | "restart" | "pause" |
|
||||
"unpause" | "remove")`, run over a pooled SSH connection as the caller.
|
||||
- `docker.events` 1.0.0: `subscribe(hostId, listener)` calls the listener
|
||||
with `{ hostId, container, event }` when a container exits, starts,
|
||||
restarts or turns unhealthy, polled about once a minute as the caller.
|
||||
Subscribing the same listener again is a no-op.
|
||||
|
||||
Both need `docker.use`.
|
||||
|
||||
## Layout
|
||||
|
||||
manifest.json id, capabilities and what this plugin contributes
|
||||
|
||||
@@ -100,26 +100,37 @@
|
||||
"stats": "Stats",
|
||||
"consoleTab": "Console",
|
||||
"startContainerToAccess": "Start the container to access the console",
|
||||
"noHostSelected": "No host selected"
|
||||
"noHostSelected": "No host selected",
|
||||
"failedToFetchLogs": "Failed to fetch logs: {{error}}",
|
||||
"failedToDownloadLogs": "Failed to download logs: {{error}}"
|
||||
},
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker Integration",
|
||||
"enableDockerMonitor": "Enable Docker",
|
||||
"enableDockerMonitorDesc": "Monitor and manage containers on this host via Docker",
|
||||
"containerRuntime": "Container Runtime",
|
||||
"containerRuntimeDesc": "Choose the CLI used for container management on this host",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
|
||||
},
|
||||
"terminal": {
|
||||
"retry": "Retry"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
"title": "Use Docker",
|
||||
"description": "Open the Docker manager and container consoles on hosts with Docker enabled."
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Enable Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Choose the CLI used for container management on this host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker-integrasie",
|
||||
"enableDockerMonitor": "Aktiveer Docker",
|
||||
"enableDockerMonitorDesc": "Monitor en bestuur houers op hierdie gasheer via Docker",
|
||||
"containerRuntime": "Houerlooptyd",
|
||||
"containerRuntimeDesc": "Kies die CLI wat vir houerbestuur op hierdie gasheer gebruik word",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Sien houers, lees logs en kyk na statistieke op enige gasheer wat Docker gebruik."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktiveer Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Houerlooptyd",
|
||||
"description": "Kies die CLI wat vir houerbestuur op hierdie gasheer gebruik word",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "تكامل Docker",
|
||||
"enableDockerMonitor": "تمكين Docker",
|
||||
"enableDockerMonitorDesc": "مراقبة وإدارة الحاويات على هذا المضيف عبر Docker",
|
||||
"containerRuntime": "بيئة تشغيل الحاويات",
|
||||
"containerRuntimeDesc": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "عامل ميناء",
|
||||
"feature_docker_desc": "اطلع على الحاويات، واقرأ السجلات، وراقب الإحصائيات على أي مضيف يعمل بنظام Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "تمكين Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "بيئة تشغيل الحاويات",
|
||||
"description": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Докер"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Докер",
|
||||
"dockerIntegration": "Интеграция на Docker",
|
||||
"enableDockerMonitor": "Активиране на Докер",
|
||||
"enableDockerMonitorDesc": "Наблюдавайте и управлявайте контейнери на този хост чрез Docker",
|
||||
"containerRuntime": "Изпълнение на контейнера",
|
||||
"containerRuntimeDesc": "Изберете CLI, използван за управление на контейнери на този хост",
|
||||
"containerRuntimeDocker": "Докер",
|
||||
"containerRuntimePodman": "Подман"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Докер",
|
||||
"feature_docker_desc": "Вижте контейнери, прочетете лог файлове и наблюдавайте статистики на всеки хост, на който работи Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Активиране на Докер"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Изпълнение на контейнера",
|
||||
"description": "Изберете CLI, използван за управление на контейнери на този хост",
|
||||
"docker": "Докер",
|
||||
"podman": "Подман"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "ডকার"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "ডকার",
|
||||
"dockerIntegration": "ডকার ইন্টিগ্রেশন",
|
||||
"enableDockerMonitor": "ডকার সক্রিয় করুন",
|
||||
"enableDockerMonitorDesc": "ডকারের মাধ্যমে এই হোস্টে কন্টেইনারগুলি নিরীক্ষণ ও পরিচালনা করুন।",
|
||||
"containerRuntime": "কন্টেইনার রানটাইম",
|
||||
"containerRuntimeDesc": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
|
||||
"containerRuntimeDocker": "ডকার",
|
||||
"containerRuntimePodman": "পডম্যান"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "ডকার",
|
||||
"feature_docker_desc": "ডকার চালিত যেকোনো হোস্টে কন্টেইনার দেখুন, লগ পড়ুন এবং পরিসংখ্যান পর্যবেক্ষণ করুন।"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "ডকার সক্রিয় করুন"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "কন্টেইনার রানটাইম",
|
||||
"description": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
|
||||
"docker": "ডকার",
|
||||
"podman": "পডম্যান"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integració de Docker",
|
||||
"enableDockerMonitor": "Activa Docker",
|
||||
"enableDockerMonitorDesc": "Supervisar i gestionar els contenidors d'aquest amfitrió mitjançant Docker",
|
||||
"containerRuntime": "Temps d'execució del contenidor",
|
||||
"containerRuntimeDesc": "Trieu la CLI utilitzada per a la gestió de contenidors en aquest amfitrió",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Veure contenidors, llegir registres i observar estadístiques de qualsevol host que executi Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activa Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Temps d'execució del contenidor",
|
||||
"description": "Trieu la CLI utilitzada per a la gestió de contenidors en aquest amfitrió",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Přístavní dělník"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Přístavní dělník",
|
||||
"dockerIntegration": "Integrace Dockeru",
|
||||
"enableDockerMonitor": "Povolit Docker",
|
||||
"enableDockerMonitorDesc": "Monitorování a správa kontejnerů na tomto hostiteli pomocí Dockeru",
|
||||
"containerRuntime": "Běh kontejneru",
|
||||
"containerRuntimeDesc": "Vyberte rozhraní CLI používané pro správu kontejnerů na tomto hostiteli",
|
||||
"containerRuntimeDocker": "Přístavní dělník",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Přístavní dělník",
|
||||
"feature_docker_desc": "Zobrazujte kontejnery, čtěte protokoly a sledujte statistiky na jakémkoli hostiteli s Dockerem."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Povolit Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Běh kontejneru",
|
||||
"description": "Vyberte rozhraní CLI používané pro správu kontejnerů na tomto hostiteli",
|
||||
"docker": "Přístavní dělník",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker-integration",
|
||||
"enableDockerMonitor": "Aktivér Docker",
|
||||
"enableDockerMonitorDesc": "Overvåg og administrer containere på denne vært via Docker",
|
||||
"containerRuntime": "Containerkørselstid",
|
||||
"containerRuntimeDesc": "Vælg den CLI, der bruges til containeradministration på denne vært",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Se containere, læs logfiler og se statistikker på enhver vært, der kører Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktivér Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Containerkørselstid",
|
||||
"description": "Vælg den CLI, der bruges til containeradministration på denne vært",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker-Integration",
|
||||
"enableDockerMonitor": "Docker aktivieren",
|
||||
"enableDockerMonitorDesc": "Container auf diesem Host über Docker überwachen und verwalten",
|
||||
"containerRuntime": "Container-Runtime",
|
||||
"containerRuntimeDesc": "Wählen Sie die CLI für die Container-Verwaltung auf diesem Host",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Container anzeigen, Protokolle lesen und Statistiken auf jedem Host, auf dem Docker ausgeführt wird, einsehen."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker aktivieren"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container-Runtime",
|
||||
"description": "Wählen Sie die CLI für die Container-Verwaltung auf diesem Host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Λιμενεργάτης"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Λιμενεργάτης",
|
||||
"dockerIntegration": "Ενσωμάτωση Docker",
|
||||
"enableDockerMonitor": "Ενεργοποίηση Docker",
|
||||
"enableDockerMonitorDesc": "Παρακολούθηση και διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή μέσω του Docker",
|
||||
"containerRuntime": "Χρόνος εκτέλεσης κοντέινερ",
|
||||
"containerRuntimeDesc": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
|
||||
"containerRuntimeDocker": "Λιμενεργάτης",
|
||||
"containerRuntimePodman": "Πόντμαν"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Λιμενεργάτης",
|
||||
"feature_docker_desc": "Δείτε κοντέινερ, διαβάστε αρχεία καταγραφής και παρακολουθήστε στατιστικά στοιχεία σε οποιονδήποτε κεντρικό υπολογιστή που εκτελεί Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ενεργοποίηση Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Χρόνος εκτέλεσης κοντέινερ",
|
||||
"description": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
|
||||
"docker": "Λιμενεργάτης",
|
||||
"podman": "Πόντμαν"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integración con Docker",
|
||||
"enableDockerMonitor": "Activar Docker",
|
||||
"enableDockerMonitorDesc": "Supervisar y gestionar contenedores en este host mediante Docker.",
|
||||
"containerRuntime": "Runtime de contenedores",
|
||||
"containerRuntimeDesc": "Elegir la CLI utilizada para la gestión de contenedores en este host.",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Estibador",
|
||||
"feature_docker_desc": "Vea los contenedores, lea los registros y observe las estadísticas en cualquier host que ejecute Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activar Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de contenedores",
|
||||
"description": "Elegir la CLI utilizada para la gestión de contenedores en este host.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Satamatyöläinen"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Satamatyöläinen",
|
||||
"dockerIntegration": "Docker-integraatio",
|
||||
"enableDockerMonitor": "Ota Docker käyttöön",
|
||||
"enableDockerMonitorDesc": "Valvo ja hallinnoi tämän isännän säilöjä Dockerin kautta",
|
||||
"containerRuntime": "Säilön suoritusaika",
|
||||
"containerRuntimeDesc": "Valitse CLI, jota käytetään säilön hallintaan tässä isännässä",
|
||||
"containerRuntimeDocker": "Satamatyöläinen",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Satamatyöläinen",
|
||||
"feature_docker_desc": "Katso säilöjä, lue lokeja ja seuraa tilastoja millä tahansa Dockeria käyttävällä isännällä."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ota Docker käyttöön"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Säilön suoritusaika",
|
||||
"description": "Valitse CLI, jota käytetään säilön hallintaan tässä isännässä",
|
||||
"docker": "Satamatyöläinen",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Intégration Docker",
|
||||
"enableDockerMonitor": "Activer Docker",
|
||||
"enableDockerMonitorDesc": "Surveiller et gérer les conteneurs sur cet hôte via Docker",
|
||||
"containerRuntime": "Moteur d'exécution de conteneurs",
|
||||
"containerRuntimeDesc": "Choisissez le CLI utilisé pour la gestion des conteneurs sur cet hôte",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Consultez les conteneurs, lisez les journaux et surveillez les statistiques sur n'importe quel hôte exécutant Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activer Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Moteur d'exécution de conteneurs",
|
||||
"description": "Choisissez le CLI utilisé pour la gestion des conteneurs sur cet hôte",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "דוקר"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "דוקר",
|
||||
"dockerIntegration": "אינטגרציה עם Docker",
|
||||
"enableDockerMonitor": "הפעל את Docker",
|
||||
"enableDockerMonitorDesc": "ניטור וניהול של מכולות במארח זה באמצעות Docker",
|
||||
"containerRuntime": "זמן ריצה של המכולה",
|
||||
"containerRuntimeDesc": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
|
||||
"containerRuntimeDocker": "דוקר",
|
||||
"containerRuntimePodman": "פודמן"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "דוקר",
|
||||
"feature_docker_desc": "ראה קונטיינרים, קרא יומני רישום וצפה בסטטיסטיקות על כל מארח שמפעיל את Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "הפעל את Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "זמן ריצה של המכולה",
|
||||
"description": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
|
||||
"docker": "דוקר",
|
||||
"podman": "פודמן"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker एकीकरण",
|
||||
"enableDockerMonitor": "Docker सक्षम करें",
|
||||
"enableDockerMonitorDesc": "इस होस्ट पर Docker के माध्यम से कंटेनरों की निगरानी और प्रबंधन करें",
|
||||
"containerRuntime": "कंटेनर रनटाइम",
|
||||
"containerRuntimeDesc": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "डाक में काम करनेवाला मज़दूर",
|
||||
"feature_docker_desc": "डॉकर चलाने वाले किसी भी होस्ट पर कंटेनर देखें, लॉग पढ़ें और आंकड़े देखें।"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker सक्षम करें"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "कंटेनर रनटाइम",
|
||||
"description": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Dokkmunkás"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Dokkmunkás",
|
||||
"dockerIntegration": "Docker-integráció",
|
||||
"enableDockerMonitor": "Docker engedélyezése",
|
||||
"enableDockerMonitorDesc": "Dockeren keresztül figyelheti és kezelheti a konténereket ezen a gazdagépen",
|
||||
"containerRuntime": "Konténer futásideje",
|
||||
"containerRuntimeDesc": "Válassza ki a gazdagépen a konténerkezeléshez használt parancssori felületet",
|
||||
"containerRuntimeDocker": "Dokkmunkás",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Dokkmunkás",
|
||||
"feature_docker_desc": "Tekintsd meg a konténereket, olvasd el a naplókat és figyeld a statisztikákat bármelyik Dockert futtató gazdagépen."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker engedélyezése"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Konténer futásideje",
|
||||
"description": "Válassza ki a gazdagépen a konténerkezeléshez használt parancssori felületet",
|
||||
"docker": "Dokkmunkás",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Buruh pelabuhan"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Buruh pelabuhan",
|
||||
"dockerIntegration": "Integrasi Docker",
|
||||
"enableDockerMonitor": "Aktifkan Docker",
|
||||
"enableDockerMonitorDesc": "Pantau dan kelola kontainer di host ini melalui Docker.",
|
||||
"containerRuntime": "Lingkungan Eksekusi Kontainer",
|
||||
"containerRuntimeDesc": "Pilih CLI yang digunakan untuk manajemen kontainer pada host ini.",
|
||||
"containerRuntimeDocker": "Buruh pelabuhan",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Buruh pelabuhan",
|
||||
"feature_docker_desc": "Lihat kontainer, baca log, dan pantau statistik pada host mana pun yang menjalankan Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktifkan Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Lingkungan Eksekusi Kontainer",
|
||||
"description": "Pilih CLI yang digunakan untuk manajemen kontainer pada host ini.",
|
||||
"docker": "Buruh pelabuhan",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integrazione Docker",
|
||||
"enableDockerMonitor": "Abilita Docker",
|
||||
"enableDockerMonitorDesc": "Monitora e gestisci i container su questo host tramite Docker",
|
||||
"containerRuntime": "Runtime container",
|
||||
"containerRuntimeDesc": "Scegli la CLI utilizzata per la gestione dei container su questo host",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Visualizza i container, leggi i log e controlla le statistiche su qualsiasi host che esegue Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Abilita Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime container",
|
||||
"description": "Scegli la CLI utilizzata per la gestione dei container su questo host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker統合",
|
||||
"enableDockerMonitor": "Docker有効化",
|
||||
"enableDockerMonitorDesc": "Docker経由でこのホスト上のコンテナを監視・管理します。",
|
||||
"containerRuntime": "コンテナランタイム",
|
||||
"containerRuntimeDesc": "このホストのコンテナ管理に使用するCLIを選択します。",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "ドッカー",
|
||||
"feature_docker_desc": "Dockerを実行している任意のホスト上で、コンテナの表示、ログの読み取り、統計情報の監視が可能です。"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker有効化"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "コンテナランタイム",
|
||||
"description": "このホストのコンテナ管理に使用するCLIを選択します。",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker 통합",
|
||||
"enableDockerMonitor": "Docker 활성화",
|
||||
"enableDockerMonitorDesc": "Docker를 통해 이 호스트의 컨테이너를 모니터링하고 관리합니다.",
|
||||
"containerRuntime": "컨테이너 런타임",
|
||||
"containerRuntimeDesc": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "도커",
|
||||
"feature_docker_desc": "Docker가 실행 중인 모든 호스트에서 컨테이너를 확인하고, 로그를 읽고, 통계를 볼 수 있습니다."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker 활성화"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "컨테이너 런타임",
|
||||
"description": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker-integratie",
|
||||
"enableDockerMonitor": "Docker inschakelen",
|
||||
"enableDockerMonitorDesc": "Containers op deze host bewaken en beheren via Docker.",
|
||||
"containerRuntime": "Container Runtime",
|
||||
"containerRuntimeDesc": "Kies de CLI die op deze host wordt gebruikt voor containerbeheer.",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Bekijk containers, lees logboeken en bekijk statistieken op elke host waarop Docker draait."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker inschakelen"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Container Runtime",
|
||||
"description": "Kies de CLI die op deze host wordt gebruikt voor containerbeheer.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Dokker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Dokker",
|
||||
"dockerIntegration": "Docker-integrasjon",
|
||||
"enableDockerMonitor": "Aktiver Docker",
|
||||
"enableDockerMonitorDesc": "Overvåk og administrer containere på denne verten via Docker",
|
||||
"containerRuntime": "Containerkjøringstid",
|
||||
"containerRuntimeDesc": "Velg CRI-et som brukes for containeradministrasjon på denne verten",
|
||||
"containerRuntimeDocker": "Dokker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Dokker",
|
||||
"feature_docker_desc": "Se containere, les logger og se statistikk på alle verter som kjører Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktiver Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Containerkjøringstid",
|
||||
"description": "Velg CRI-et som brukes for containeradministrasjon på denne verten",
|
||||
"docker": "Dokker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integracja z Dockerem",
|
||||
"enableDockerMonitor": "Włącz Docker",
|
||||
"enableDockerMonitorDesc": "Monitoruj i zarządzaj kontenerami na tym hoście za pomocą Dockera",
|
||||
"containerRuntime": "Środowisko wykonawcze kontenera",
|
||||
"containerRuntimeDesc": "Wybierz CLI używane do zarządzania kontenerami na tym hoście",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Przeglądaj kontenery, czytaj logi i śledź statystyki na dowolnym hoście, na którym działa Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Włącz Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Środowisko wykonawcze kontenera",
|
||||
"description": "Wybierz CLI używane do zarządzania kontenerami na tym hoście",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integração do Docker",
|
||||
"enableDockerMonitor": "Habilitar Docker",
|
||||
"enableDockerMonitorDesc": "Monitore e gerencie contêineres neste host via Docker.",
|
||||
"containerRuntime": "Tempo de execução do contêiner",
|
||||
"containerRuntimeDesc": "Escolha a CLI usada para gerenciamento de contêineres neste host.",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Visualize contêineres, leia registros e acompanhe as estatísticas em qualquer host que execute o Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Habilitar Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Tempo de execução do contêiner",
|
||||
"description": "Escolha a CLI usada para gerenciamento de contêineres neste host.",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Integração com Docker",
|
||||
"enableDockerMonitor": "Ativar Docker",
|
||||
"enableDockerMonitorDesc": "Monitorizar e gerir contentores neste host via Docker",
|
||||
"containerRuntime": "Runtime de Contentores",
|
||||
"containerRuntimeDesc": "Escolha a CLI usada para a gestão de contentores neste host",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Visualize contentores, leia registos e acompanhe as estatísticas em qualquer host que execute o Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Ativar Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de Contentores",
|
||||
"description": "Escolha a CLI usada para a gestão de contentores neste host",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docher"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docher",
|
||||
"dockerIntegration": "Integrare Docker",
|
||||
"enableDockerMonitor": "Activează Docker",
|
||||
"enableDockerMonitorDesc": "Monitorizați și gestionați containerele de pe această gazdă prin Docker",
|
||||
"containerRuntime": "Runtime de container",
|
||||
"containerRuntimeDesc": "Alegeți interfața CLI utilizată pentru gestionarea containerelor pe această gazdă",
|
||||
"containerRuntimeDocker": "Docher",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docher",
|
||||
"feature_docker_desc": "Vizualizați containere, citiți jurnale și urmăriți statistici pe orice gazdă care rulează Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Activează Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Runtime de container",
|
||||
"description": "Alegeți interfața CLI utilizată pentru gestionarea containerelor pe această gazdă",
|
||||
"docker": "Docher",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Интеграция Docker",
|
||||
"enableDockerMonitor": "Включить Docker",
|
||||
"enableDockerMonitorDesc": "Отслеживать и управлять контейнерами на этом хосте через Docker",
|
||||
"containerRuntime": "Среда выполнения контейнеров",
|
||||
"containerRuntimeDesc": "Выберите CLI, используемый для управления контейнерами на этом хосте",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Просматривайте контейнеры, читайте журналы и отслеживайте статистику на любом хосте, где запущен Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Включить Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Среда выполнения контейнеров",
|
||||
"description": "Выберите CLI, используемый для управления контейнерами на этом хосте",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Докер"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Докер",
|
||||
"dockerIntegration": "Интеграција Докера",
|
||||
"enableDockerMonitor": "Омогући Докер",
|
||||
"enableDockerMonitorDesc": "Пратите и управљајте контејнерима на овом хосту путем Докера",
|
||||
"containerRuntime": "Време извршавања контејнера",
|
||||
"containerRuntimeDesc": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
|
||||
"containerRuntimeDocker": "Докер",
|
||||
"containerRuntimePodman": "Подман"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Докер",
|
||||
"feature_docker_desc": "Погледајте контејнере, читајте логове и пратите статистику на било ком хосту који користи Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Омогући Докер"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Време извршавања контејнера",
|
||||
"description": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
|
||||
"docker": "Докер",
|
||||
"podman": "Подман"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Hamnarbetare"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Hamnarbetare",
|
||||
"dockerIntegration": "Docker-integration",
|
||||
"enableDockerMonitor": "Aktivera Docker",
|
||||
"enableDockerMonitorDesc": "Övervaka och hantera containrar på den här värden via Docker",
|
||||
"containerRuntime": "Containerkörningstid",
|
||||
"containerRuntimeDesc": "Välj det CRI som används för containerhantering på den här värden",
|
||||
"containerRuntimeDocker": "Hamnarbetare",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Hamnarbetare",
|
||||
"feature_docker_desc": "Se containrar, läs loggar och titta på statistik för alla värdar som kör Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Aktivera Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Containerkörningstid",
|
||||
"description": "Välj det CRI som används för containerhantering på den här värden",
|
||||
"docker": "Hamnarbetare",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "ด็อกเกอร์"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "ด็อกเกอร์",
|
||||
"dockerIntegration": "การผสานรวม Docker",
|
||||
"enableDockerMonitor": "เปิดใช้งาน Docker",
|
||||
"enableDockerMonitorDesc": "ตรวจสอบและจัดการคอนเทนเนอร์บนโฮสต์นี้ผ่าน Docker",
|
||||
"containerRuntime": "รันไทม์คอนเทนเนอร์",
|
||||
"containerRuntimeDesc": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
|
||||
"containerRuntimeDocker": "ด็อกเกอร์",
|
||||
"containerRuntimePodman": "พอดแมน"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "ด็อกเกอร์",
|
||||
"feature_docker_desc": "ดูคอนเทนเนอร์ อ่านบันทึก และตรวจสอบสถิติบนโฮสต์ใดก็ได้ที่ใช้งาน Docker"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "เปิดใช้งาน Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "รันไทม์คอนเทนเนอร์",
|
||||
"description": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
|
||||
"docker": "ด็อกเกอร์",
|
||||
"podman": "พอดแมน"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker Entegrasyonu",
|
||||
"enableDockerMonitor": "Docker'ı Etkinleştir",
|
||||
"enableDockerMonitorDesc": "Docker aracılığıyla bu ana bilgisayardaki kapsayıcıları izle ve yönet",
|
||||
"containerRuntime": "Kapsayıcı Çalışma Zamanı",
|
||||
"containerRuntimeDesc": "Bu ana bilgisayarda kapsayıcı yönetimi için kullanılan CLI'yi seçin",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Liman işçisi",
|
||||
"feature_docker_desc": "Docker çalıştıran herhangi bir sunucudaki konteynerleri görüntüleyin, günlükleri okuyun ve istatistikleri izleyin."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Docker'ı Etkinleştir"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Kapsayıcı Çalışma Zamanı",
|
||||
"description": "Bu ana bilgisayarda kapsayıcı yönetimi için kullanılan CLI'yi seçin",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Докер"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Докер",
|
||||
"dockerIntegration": "Інтеграція Docker",
|
||||
"enableDockerMonitor": "Увімкнути Докер",
|
||||
"enableDockerMonitorDesc": "Моніторинг та керування контейнерами на цьому хості через Docker",
|
||||
"containerRuntime": "Середовище виконання контейнера",
|
||||
"containerRuntimeDesc": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
|
||||
"containerRuntimeDocker": "Докер",
|
||||
"containerRuntimePodman": "Подман"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Докер",
|
||||
"feature_docker_desc": "Переглядайте контейнери, читайте журнали та переглядайте статистику на будь-якому хості, на якому працює Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Увімкнути Докер"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Середовище виконання контейнера",
|
||||
"description": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
|
||||
"docker": "Докер",
|
||||
"podman": "Подман"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Tích hợp Docker",
|
||||
"enableDockerMonitor": "Bật Docker",
|
||||
"enableDockerMonitorDesc": "Giám sát và quản lý container trên máy chủ này qua Docker",
|
||||
"containerRuntime": "Môi trường chạy container",
|
||||
"containerRuntimeDesc": "Chọn CLI dùng để quản lý container trên máy chủ này",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "Xem các container, đọc nhật ký và theo dõi số liệu thống kê trên bất kỳ máy chủ nào đang chạy Docker."
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "Bật Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "Môi trường chạy container",
|
||||
"description": "Chọn CLI dùng để quản lý container trên máy chủ này",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker 集成",
|
||||
"enableDockerMonitor": "启用 Docker",
|
||||
"enableDockerMonitorDesc": "通过 Docker 监控和管理此主机上的容器",
|
||||
"containerRuntime": "容器运行时",
|
||||
"containerRuntimeDesc": "选择此主机上用于容器管理的 CLI",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "在任何运行 Docker 的主机上查看容器、读取日志及监控运行状态。"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "启用 Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "容器运行时",
|
||||
"description": "选择此主机上用于容器管理的 CLI",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -99,18 +99,21 @@
|
||||
"nav": {
|
||||
"docker": "Docker"
|
||||
},
|
||||
"hosts": {
|
||||
"tabDocker": "Docker",
|
||||
"dockerIntegration": "Docker 整合",
|
||||
"enableDockerMonitor": "啟用 Docker",
|
||||
"enableDockerMonitorDesc": "透過 Docker 監控及管理此主機上的容器",
|
||||
"containerRuntime": "容器執行環境",
|
||||
"containerRuntimeDesc": "選擇用於此主機容器管理的 CLI",
|
||||
"containerRuntimeDocker": "Docker",
|
||||
"containerRuntimePodman": "Podman"
|
||||
},
|
||||
"onboarding": {
|
||||
"feature_docker": "Docker",
|
||||
"feature_docker_desc": "查看執行 Docker 的任何主機上的容器、讀取日誌和查看統計資料。"
|
||||
},
|
||||
"settings": {
|
||||
"host": {
|
||||
"enable": {
|
||||
"label": "啟用 Docker"
|
||||
},
|
||||
"containerRuntime": {
|
||||
"label": "容器執行環境",
|
||||
"description": "選擇用於此主機容器管理的 CLI",
|
||||
"docker": "Docker",
|
||||
"podman": "Podman"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,20 @@
|
||||
"credentials:use",
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"audit:read",
|
||||
"ui:surface"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
"service": "docker.containers",
|
||||
"version": "1.0.0",
|
||||
"permission": "docker.use"
|
||||
},
|
||||
{
|
||||
"service": "docker.events",
|
||||
"version": "1.0.0",
|
||||
"permission": "docker.use"
|
||||
}
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
{
|
||||
@@ -31,10 +42,39 @@
|
||||
"openFrom": ["host-context-menu", "palette"]
|
||||
}
|
||||
],
|
||||
"hostCapability": {
|
||||
"key": "enableDocker",
|
||||
"labelKey": "hosts.dockerIntegration",
|
||||
"editorTab": "docker"
|
||||
"permissions": [
|
||||
{
|
||||
"name": "use",
|
||||
"titleKey": "permissions.use.title",
|
||||
"descriptionKey": "permissions.use.description",
|
||||
"defaultRoles": ["admin", "user"]
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"host": {
|
||||
"enableKey": "enableDocker",
|
||||
"enableLabelKey": "settings.host.enable.label",
|
||||
"fields": [
|
||||
{
|
||||
"key": "containerRuntime",
|
||||
"type": "select",
|
||||
"labelKey": "settings.host.containerRuntime.label",
|
||||
"descriptionKey": "settings.host.containerRuntime.description",
|
||||
"requires": "enableDocker",
|
||||
"default": "docker",
|
||||
"options": [
|
||||
{
|
||||
"value": "docker",
|
||||
"labelKey": "settings.host.containerRuntime.docker"
|
||||
},
|
||||
{
|
||||
"value": "podman",
|
||||
"labelKey": "settings.host.containerRuntime.podman"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"locales": "locales",
|
||||
|
||||
@@ -0,0 +1,404 @@
|
||||
/**
|
||||
* The Docker panel's interactive connect.
|
||||
*
|
||||
* Core's pipeline does the handshake; this only bridges its prompt channel to
|
||||
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a
|
||||
* password the host does not store) answers the request that is waiting with
|
||||
* what to ask, and the next request (connect-totp, connect-warpgate) carries
|
||||
* the answer back and waits for whatever comes after it: the session, another
|
||||
* prompt, or an error.
|
||||
*/
|
||||
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginSshHost,
|
||||
PluginSshPromptRequest,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerSessions } from "./sessions.js";
|
||||
import {
|
||||
connectionLog,
|
||||
getErrorMessage,
|
||||
type ConnectionLogLine,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
/** What one HTTP request answers with. */
|
||||
export interface ConnectStep {
|
||||
status: number;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
const TOTP_WAIT_MS = 3 * 60 * 1000;
|
||||
const WARPGATE_WAIT_MS = 5 * 60 * 1000;
|
||||
const STEP_WAIT_MS = 90 * 1000;
|
||||
// Covers the longest a person may take on a prompt, plus the handshake.
|
||||
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000;
|
||||
|
||||
const PASSWORD_PROMPT = /password/i;
|
||||
|
||||
/** Hands each step to whichever request is waiting for one. */
|
||||
export class PendingConnect {
|
||||
constructor(
|
||||
readonly userId: string,
|
||||
readonly hostId: number,
|
||||
) {}
|
||||
|
||||
expiresAt = Date.now() + TOTP_WAIT_MS;
|
||||
/** What the open prompt expects, or null when nothing is being asked. */
|
||||
awaiting: "totp" | "warpgate" | null = null;
|
||||
abandoned = false;
|
||||
private waiter: ((step: ConnectStep) => void) | null = null;
|
||||
private queued: ConnectStep | null = null;
|
||||
private answer: ((value: string | null) => void) | null = null;
|
||||
|
||||
emit(step: ConnectStep): void {
|
||||
if (this.waiter) {
|
||||
const waiter = this.waiter;
|
||||
this.waiter = null;
|
||||
waiter(step);
|
||||
} else {
|
||||
this.queued = step;
|
||||
}
|
||||
}
|
||||
|
||||
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
|
||||
if (this.queued) {
|
||||
const step = this.queued;
|
||||
this.queued = null;
|
||||
return Promise.resolve(step);
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
if (this.waiter !== settle) return;
|
||||
this.waiter = null;
|
||||
this.cancel();
|
||||
resolve({
|
||||
status: 408,
|
||||
body: { error: "Connection timed out. Please reconnect." },
|
||||
});
|
||||
}, timeoutMs);
|
||||
const settle = (step: ConnectStep) => {
|
||||
clearTimeout(timer);
|
||||
resolve(step);
|
||||
};
|
||||
this.waiter = settle;
|
||||
});
|
||||
}
|
||||
|
||||
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> {
|
||||
this.awaiting = kind;
|
||||
this.expiresAt = Date.now() + waitMs;
|
||||
return new Promise((resolve) => {
|
||||
this.answer = resolve;
|
||||
});
|
||||
}
|
||||
|
||||
respond(value: string | null): void {
|
||||
const answer = this.answer;
|
||||
this.answer = null;
|
||||
this.awaiting = null;
|
||||
answer?.(value);
|
||||
}
|
||||
|
||||
/** Nobody will read the result: give up the prompt and drop the client. */
|
||||
cancel(): void {
|
||||
this.abandoned = true;
|
||||
this.respond(null);
|
||||
}
|
||||
}
|
||||
|
||||
function classifyConnectError(
|
||||
message: string,
|
||||
logs: ConnectionLogLine[],
|
||||
): { hostKeyChanged: boolean; authFailed: boolean } {
|
||||
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||
logs.push(
|
||||
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
|
||||
);
|
||||
} else if (
|
||||
message.includes("ECONNREFUSED") ||
|
||||
message.includes("ETIMEDOUT")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
|
||||
);
|
||||
} else if (message.includes("verification failed")) {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"error",
|
||||
"handshake",
|
||||
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||
),
|
||||
);
|
||||
return { hostKeyChanged: true, authFailed: false };
|
||||
} else if (
|
||||
message.includes("handshake") ||
|
||||
message.includes("key exchange")
|
||||
) {
|
||||
logs.push(
|
||||
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
|
||||
);
|
||||
} else if (/authentication/i.test(message)) {
|
||||
logs.push(
|
||||
connectionLog("error", "auth", `Authentication failed: ${message}`),
|
||||
);
|
||||
return { hostKeyChanged: false, authFailed: true };
|
||||
} else {
|
||||
logs.push(
|
||||
connectionLog("error", "error", `SSH connection failed: ${message}`),
|
||||
);
|
||||
}
|
||||
return { hostKeyChanged: false, authFailed: false };
|
||||
}
|
||||
|
||||
export interface StartConnectInput {
|
||||
sessionId: string;
|
||||
userId: string;
|
||||
host: PluginSshHost;
|
||||
runtime: ContainerRuntime;
|
||||
logs: ConnectionLogLine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a connect and returns the first step for the request that asked.
|
||||
* The pending entry lives in `sessions` until the connect settles, so the
|
||||
* answer routes can find it.
|
||||
*/
|
||||
export async function startConnect(
|
||||
ctx: PluginContext,
|
||||
sessions: DockerSessions,
|
||||
log: DockerLogger,
|
||||
input: StartConnectInput,
|
||||
): Promise<ConnectStep> {
|
||||
const { sessionId, userId, host, runtime, logs } = input;
|
||||
const hostId = host.id;
|
||||
const authType = (host.authType as string) || "none";
|
||||
const credentialless = !ctx.ssh.requiresSecret(authType);
|
||||
|
||||
const pending = new PendingConnect(userId, hostId);
|
||||
sessions.setPending(sessionId, pending);
|
||||
|
||||
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
|
||||
logs.push(connectionLog("info", "docker_auth", "Verification required"));
|
||||
const answer = pending.ask("totp", TOTP_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_totp: true,
|
||||
sessionId,
|
||||
prompt,
|
||||
connectionLogs: logs,
|
||||
...extra,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
};
|
||||
|
||||
const ask = async (
|
||||
request: PluginSshPromptRequest,
|
||||
): Promise<string | null> => {
|
||||
if (pending.abandoned) return null;
|
||||
switch (request.kind) {
|
||||
case "totp":
|
||||
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
|
||||
case "warpgate": {
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"info",
|
||||
"docker_auth",
|
||||
"Warpgate authentication required",
|
||||
),
|
||||
);
|
||||
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
requires_warpgate: true,
|
||||
sessionId,
|
||||
url: request.url,
|
||||
securityKey: request.securityKey,
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return answer;
|
||||
}
|
||||
case "input": {
|
||||
const isPassword = PASSWORD_PROMPT.test(request.prompt);
|
||||
if (isPassword && credentialless) {
|
||||
pending.abandoned = true;
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { status: "auth_required", reason: "no_keyboard" },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
if (!isPassword && !request.isPush) return "";
|
||||
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
|
||||
logs.push(
|
||||
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||
);
|
||||
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
|
||||
|
||||
ctx.ssh
|
||||
.connect<Client>(host, {
|
||||
purpose: "docker",
|
||||
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||
prompt: { ask },
|
||||
})
|
||||
.then(
|
||||
(connection) => {
|
||||
sessions.clearPending(sessionId);
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: true });
|
||||
if (pending.abandoned) {
|
||||
connection.dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: sessionId,
|
||||
client: connection.client,
|
||||
dispose: connection.dispose,
|
||||
hostId,
|
||||
userId,
|
||||
runtime,
|
||||
isWindows: false,
|
||||
lastActive: Date.now(),
|
||||
activeOperations: 0,
|
||||
};
|
||||
sessions.add(session);
|
||||
|
||||
connection.client.exec("ver", (err, stream) => {
|
||||
if (err || !stream) return;
|
||||
let output = "";
|
||||
stream.on("data", (d: Buffer) => {
|
||||
output += d.toString();
|
||||
});
|
||||
stream.on("close", () => {
|
||||
if (output.toLowerCase().includes("windows")) {
|
||||
session.isWindows = true;
|
||||
}
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
});
|
||||
|
||||
void ctx.audit.record({
|
||||
action: "docker_connect",
|
||||
resourceType: "host",
|
||||
resourceId: String(hostId),
|
||||
resourceName: (host.name as string) || undefined,
|
||||
success: true,
|
||||
});
|
||||
|
||||
logs.push(
|
||||
connectionLog(
|
||||
"success",
|
||||
"connected",
|
||||
"SSH connection established successfully",
|
||||
),
|
||||
);
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: { success: true, status: "success", connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
(error: unknown) => {
|
||||
sessions.clearPending(sessionId);
|
||||
const message = getErrorMessage(error, "SSH connection failed");
|
||||
if (pending.abandoned) return;
|
||||
|
||||
const outcome = (error as { outcome?: Record<string, unknown> })
|
||||
.outcome;
|
||||
if (outcome?.status === "interaction-required") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 401,
|
||||
body: {
|
||||
error: message,
|
||||
requiresAuthInteraction: outcome.interaction,
|
||||
...(typeof outcome.flag === "string"
|
||||
? { [outcome.flag]: true }
|
||||
: {}),
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (outcome?.status === "error") {
|
||||
logs.push(connectionLog("error", "docker_auth", message));
|
||||
pending.emit({
|
||||
status: 400,
|
||||
body: { error: message, connectionLogs: logs },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { hostKeyChanged, authFailed } = classifyConnectError(
|
||||
message,
|
||||
logs,
|
||||
);
|
||||
if (hostKeyChanged || authFailed) {
|
||||
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
|
||||
}
|
||||
log.error("Docker SSH connection failed", {
|
||||
hostId,
|
||||
userId,
|
||||
error: message,
|
||||
});
|
||||
|
||||
if (credentialless && authFailed) {
|
||||
pending.emit({
|
||||
status: 200,
|
||||
body: {
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
connectionLogs: logs,
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
pending.emit({
|
||||
status: 500,
|
||||
body: { success: false, message, connectionLogs: logs },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
return pending.next();
|
||||
}
|
||||
|
||||
/** Carries a person's answer to a parked connect and waits for what's next. */
|
||||
export async function answerConnect(
|
||||
sessions: DockerSessions,
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
kind: "totp" | "warpgate",
|
||||
value: string,
|
||||
): Promise<ConnectStep> {
|
||||
const pending = sessions.getPending(sessionId);
|
||||
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
|
||||
return {
|
||||
status: 404,
|
||||
body: { error: "Authentication session expired. Please reconnect." },
|
||||
};
|
||||
}
|
||||
if (pending.awaiting !== kind) {
|
||||
return {
|
||||
status: 400,
|
||||
body: {
|
||||
error:
|
||||
kind === "warpgate"
|
||||
? "Session is not a Warpgate session"
|
||||
: "Session is not waiting for a code",
|
||||
},
|
||||
};
|
||||
}
|
||||
pending.respond(value);
|
||||
return pending.next();
|
||||
}
|
||||
@@ -1,652 +1,343 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import type { Client as SSHClient } from "ssh2";
|
||||
import type { PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { connectSsh } from "./ssh.js";
|
||||
import { WebSocketServer, WebSocket } from "ws";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { systemLogger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { SSHHost } from "../../../../src/types/index.js";
|
||||
import { AsyncResource } from "node:async_hooks";
|
||||
import { StringDecoder } from "node:string_decoder";
|
||||
import type { Client, ClientChannel } from "ssh2";
|
||||
import { WebSocket, type RawData } from "ws";
|
||||
import type {
|
||||
PluginContext,
|
||||
PluginWebSocketConnection,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
containerCommand,
|
||||
getContainerRuntimeConfig,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import {
|
||||
hostAddressMismatch,
|
||||
CONTAINER_ID_RE,
|
||||
HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
HOST_NOT_ON_THIS_SERVER_MESSAGE,
|
||||
} from "../../../../src/backend/hosts/host-identity.js";
|
||||
import { extractWebSocketToken } from "../../../../src/backend/utils/ws-auth.js";
|
||||
import {
|
||||
asObject,
|
||||
asString,
|
||||
MAX_WS_MESSAGE_BYTES,
|
||||
getErrorMessage,
|
||||
hostAddressMismatch,
|
||||
parseWsMessage,
|
||||
toTerminalDimension,
|
||||
} from "../../../../src/backend/utils/ws-message.js";
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
const sshLogger = systemLogger;
|
||||
const SHELLS = ["bash", "sh", "ash", "zsh"];
|
||||
const PING_MS = 30_000;
|
||||
|
||||
interface SSHSession {
|
||||
client: SSHClient;
|
||||
stream: import("ssh2").ClientChannel | null;
|
||||
isConnected: boolean;
|
||||
containerId?: string;
|
||||
shell?: string;
|
||||
hostId?: number;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
interface ConsoleSession {
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
stream: ClientChannel | null;
|
||||
hostId: number;
|
||||
containerId: string;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
const activeSessions = new Map<string, SSHSession>();
|
||||
|
||||
/**
|
||||
* Created by startConsoleServer(), not at module load.
|
||||
*
|
||||
* Importing this file used to bind a port as a side effect, which meant the
|
||||
* plugin could not be disabled and re-enabled: the module stays in the ESM
|
||||
* cache, so the second activate() got a server that had already been closed.
|
||||
* There is no port now -- core routes /plugin-ws/docker/console here -- but
|
||||
* the same lifecycle applies to the server object itself.
|
||||
*/
|
||||
let wss: WebSocketServer | null = null;
|
||||
|
||||
function handleConnection(ws: WebSocket, req: import("http").IncomingMessage) {
|
||||
void onConsoleConnection(ws, req);
|
||||
}
|
||||
|
||||
/** Builds the console WebSocket server. Call from the plugin's activate(). */
|
||||
export function startConsoleServer(): Promise<void> {
|
||||
if (wss) return Promise.resolve();
|
||||
|
||||
const server = new WebSocketServer({
|
||||
noServer: true,
|
||||
maxPayload: MAX_WS_MESSAGE_BYTES,
|
||||
});
|
||||
|
||||
// One bad socket must not take the server down.
|
||||
server.on("error", (error) => {
|
||||
sshLogger.error("Docker console WebSocket server error", error, {
|
||||
operation: "wss_error",
|
||||
});
|
||||
});
|
||||
server.on("connection", handleConnection);
|
||||
|
||||
wss = server;
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/**
|
||||
* Hands one upgrade to the console server.
|
||||
*
|
||||
* Registered as a public ctx.ws route because onConsoleConnection does its own
|
||||
* per-message authentication, which is where the container and host checks
|
||||
* live too.
|
||||
*/
|
||||
export function handleConsoleUpgrade(
|
||||
request: import("http").IncomingMessage,
|
||||
socket: import("stream").Duplex,
|
||||
head: Buffer,
|
||||
): void {
|
||||
const server = wss;
|
||||
if (!server) {
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
server.handleUpgrade(request, socket, head, (ws) => {
|
||||
server.emit("connection", ws, request);
|
||||
});
|
||||
}
|
||||
|
||||
async function detectShell(
|
||||
session: SSHSession,
|
||||
function shellExists(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
containerId: string,
|
||||
): Promise<string> {
|
||||
const shells = ["bash", "sh", "ash"];
|
||||
|
||||
for (const shell of shells) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
session.client.exec(
|
||||
containerCommand(
|
||||
session.containerRuntime,
|
||||
`exec ${containerId} which ${shell}`,
|
||||
),
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not found`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
shell: string,
|
||||
): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
client.exec(
|
||||
containerCommand(runtime, `exec ${containerId} which ${shell}`),
|
||||
(err, stream) => {
|
||||
if (err) return resolve(false);
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
stream.on("close", (code: number) =>
|
||||
resolve(code === 0 && output.trim().length > 0),
|
||||
);
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", () => resolve(false));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return shell;
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
async function pickShell(
|
||||
client: Client,
|
||||
runtime: ContainerRuntime,
|
||||
containerId: string,
|
||||
requested: string | undefined,
|
||||
): Promise<string> {
|
||||
if (requested && (await shellExists(client, runtime, containerId, requested)))
|
||||
return requested;
|
||||
for (const shell of ["bash", "sh", "ash"]) {
|
||||
if (await shellExists(client, runtime, containerId, shell)) return shell;
|
||||
}
|
||||
|
||||
return "sh";
|
||||
}
|
||||
|
||||
async function onConsoleConnection(
|
||||
ws: WebSocket,
|
||||
req: import("http").IncomingMessage,
|
||||
) {
|
||||
const token = extractWebSocketToken(req);
|
||||
|
||||
if (!token) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const authManagerInstance = AuthManager.getInstance();
|
||||
let payload;
|
||||
try {
|
||||
payload = await authManagerInstance.verifyJWTToken(token);
|
||||
} catch (error) {
|
||||
sshLogger.warn("Docker console JWT verification failed", {
|
||||
operation: "docker_console_auth_error",
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
if (!payload?.userId || payload.pendingTOTP) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
|
||||
const userId = payload.userId;
|
||||
const sessionId = `docker-console-${Date.now()}-${Math.random()}`;
|
||||
sshLogger.info("Docker console WebSocket connected", {
|
||||
operation: "docker_console_connect",
|
||||
sessionId,
|
||||
userId,
|
||||
/**
|
||||
* The container console at /plugin-ws/docker/console.
|
||||
*
|
||||
* Public with optionalAuth: core still resolves the caller's token, and the
|
||||
* handler refuses a socket without a user or without docker.use. Every live
|
||||
* console is closed when the plugin deactivates.
|
||||
*/
|
||||
export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
|
||||
const live = new Set<() => void>();
|
||||
ctx.disposables.add(() => {
|
||||
for (const end of [...live]) end();
|
||||
live.clear();
|
||||
});
|
||||
|
||||
let sshSession: SSHSession | null = null;
|
||||
|
||||
const wsPingInterval = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.ping();
|
||||
const onConnection = async (connection: PluginWebSocketConnection) => {
|
||||
const ws = connection.socket as WebSocket;
|
||||
const userId = connection.userId;
|
||||
if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
|
||||
ws.close(1008, "Authentication required");
|
||||
return;
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
const cleanup = () => {
|
||||
clearInterval(wsPingInterval);
|
||||
if (!sshSession) return;
|
||||
sshSession.stream?.end();
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
sshSession = null;
|
||||
};
|
||||
let session: ConsoleSession | null = null;
|
||||
const send = (message: Record<string, unknown>) => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
|
||||
};
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
try {
|
||||
const message = parseWsMessage(data);
|
||||
const endSession = () => {
|
||||
if (!session) return;
|
||||
const current = session;
|
||||
session = null;
|
||||
current.stream?.end();
|
||||
current.dispose();
|
||||
};
|
||||
|
||||
switch (message.type) {
|
||||
case "connect": {
|
||||
const connectData = asObject(message.data);
|
||||
const hostConfig = asObject(
|
||||
connectData.hostConfig,
|
||||
) as unknown as SSHHost;
|
||||
const containerId = asString(connectData.containerId);
|
||||
const shell = asString(connectData.shell) || undefined;
|
||||
const cols = toTerminalDimension(connectData.cols) || 80;
|
||||
const rows = toTerminalDimension(connectData.rows) || 24;
|
||||
const pingTimer = setInterval(() => {
|
||||
if (ws.readyState === WebSocket.OPEN) ws.ping();
|
||||
}, PING_MS);
|
||||
|
||||
const hostId = hostConfig?.id;
|
||||
const cleanup = () => {
|
||||
clearInterval(pingTimer);
|
||||
endSession();
|
||||
live.delete(shutdown);
|
||||
};
|
||||
const shutdown = () => {
|
||||
cleanup();
|
||||
try {
|
||||
ws.close(1001, "Docker plugin disabled");
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
};
|
||||
live.add(shutdown);
|
||||
|
||||
if (!hostId || !containerId) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const connect = async (data: unknown) => {
|
||||
if (!connection.isDataUnlocked()) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Session expired - please log in again",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const payload = asObject(data);
|
||||
const hostConfig = asObject(payload.hostConfig);
|
||||
const hostId = Number(hostConfig.id);
|
||||
const syncId =
|
||||
typeof hostConfig.syncId === "string" ? hostConfig.syncId : null;
|
||||
const containerId = asString(payload.containerId);
|
||||
const shell = asString(payload.shell) || undefined;
|
||||
const cols = toTerminalDimension(payload.cols) || 80;
|
||||
const rows = toTerminalDimension(payload.rows) || 24;
|
||||
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(containerId)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid container ID",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!hostId || !containerId) {
|
||||
send({
|
||||
type: "error",
|
||||
message: "Host configuration and container ID are required",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!CONTAINER_ID_RE.test(containerId)) {
|
||||
send({ type: "error", message: "Invalid container ID" });
|
||||
return;
|
||||
}
|
||||
if (shell && !SHELLS.includes(shell)) {
|
||||
send({ type: "error", message: "Invalid shell" });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedShells = ["bash", "sh", "ash", "zsh"];
|
||||
if (shell && !allowedShells.includes(shell)) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Invalid shell",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
endSession();
|
||||
|
||||
if (!hostConfig.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: "Docker is not enabled on this host",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Resolve host with credentials server-side
|
||||
const { resolveHostById, resolveHostBySyncId } =
|
||||
await import("../../../../src/backend/hosts/host-resolver.js");
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const hostSyncId = hostConfig?.syncId;
|
||||
const resolvedHost = hostSyncId
|
||||
? await resolveHostBySyncId(hostSyncId, userId)
|
||||
: await resolveHostById(hostId, userId);
|
||||
|
||||
if (!resolvedHost) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: hostSyncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The connection below dials resolvedHost.ip outright, so if this
|
||||
// server has a different machine under the id the client sent, the
|
||||
// console opens on that machine's Docker daemon instead.
|
||||
if (
|
||||
!hostSyncId &&
|
||||
hostAddressMismatch(hostConfig?.ip, resolvedHost.ip)
|
||||
) {
|
||||
sshLogger.error(
|
||||
"Refusing Docker console: host id resolves to a different address here",
|
||||
undefined,
|
||||
{
|
||||
operation: "docker_console_host_id_mismatch",
|
||||
hostId,
|
||||
userId,
|
||||
clientIp: hostConfig?.ip,
|
||||
resolvedIp: resolvedHost.ip,
|
||||
},
|
||||
);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: HOST_ADDRESS_MISMATCH_MESSAGE,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resolvedHost.enableDocker) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// The console socket is public and authenticates itself, so the
|
||||
// acting user is carried on the host rather than the request.
|
||||
const { client } = await connectSsh(
|
||||
{
|
||||
...(resolvedHost as unknown as PluginSshHost),
|
||||
userId,
|
||||
} as PluginSshHost,
|
||||
{ purpose: "docker-console", timeoutMs: 65000 },
|
||||
);
|
||||
|
||||
const { runtime: containerRuntime } = getContainerRuntimeConfig(
|
||||
resolvedHost.dockerConfig,
|
||||
);
|
||||
|
||||
sshSession = {
|
||||
client,
|
||||
stream: null,
|
||||
isConnected: true,
|
||||
containerId,
|
||||
hostId: resolvedHost.id,
|
||||
containerRuntime,
|
||||
};
|
||||
|
||||
activeSessions.set(sessionId, sshSession);
|
||||
|
||||
let shellToUse = shell || "bash";
|
||||
|
||||
if (shell) {
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
client.exec(
|
||||
containerCommand(
|
||||
containerRuntime,
|
||||
`exec ${containerId} which ${shell}`,
|
||||
),
|
||||
(err, stream) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
let output = "";
|
||||
stream.on("data", (data: Buffer) => {
|
||||
output += data.toString();
|
||||
});
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code === 0 && output.trim()) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error(`Shell ${shell} not available`));
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr) => {
|
||||
reject(streamErr);
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
} catch {
|
||||
sshLogger.warn(
|
||||
`Requested shell ${shell} not found, detecting available shell`,
|
||||
{
|
||||
operation: "shell_validation",
|
||||
sessionId,
|
||||
containerId,
|
||||
requestedShell: shell,
|
||||
},
|
||||
);
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
} else {
|
||||
shellToUse = await detectShell(sshSession, containerId);
|
||||
}
|
||||
|
||||
sshSession.shell = shellToUse;
|
||||
|
||||
const execCommand = containerCommand(
|
||||
containerRuntime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
);
|
||||
sshLogger.info("Attaching to Docker container", {
|
||||
operation: "docker_attach",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
client.exec(
|
||||
execCommand,
|
||||
{
|
||||
pty: {
|
||||
term: "xterm-256color",
|
||||
cols,
|
||||
rows,
|
||||
},
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Failed to create docker exec", err, {
|
||||
operation: "docker_exec",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
sshSession!.stream = stream;
|
||||
sshLogger.success("Docker container attached", {
|
||||
operation: "docker_attach_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: resolvedHost.id,
|
||||
containerId,
|
||||
});
|
||||
|
||||
// Buffers incomplete multi-byte UTF-8 sequences across chunk
|
||||
// boundaries so box-drawing/special characters don't get
|
||||
// corrupted when a character is split across TCP packets.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const text = decoder.write(data);
|
||||
if (!text) return;
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "output",
|
||||
data: text,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
|
||||
stream.on("error", (streamErr) => {
|
||||
sshLogger.error("Docker console stream error", streamErr, {
|
||||
operation: "docker_console_stream_error",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "disconnected",
|
||||
message: "Console session ended",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (sshSession) {
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: shell && shell !== shellToUse,
|
||||
},
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
sshLogger.error("Failed to connect to container", error, {
|
||||
operation: "console_connect",
|
||||
sessionId,
|
||||
containerId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(
|
||||
error,
|
||||
"Failed to connect to container",
|
||||
),
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
try {
|
||||
// syncId names the host on both sides of a sync pair; the numeric
|
||||
// id only names it in the database the client is displaying.
|
||||
const host = await ctx.ssh.resolveHost(hostId, { syncId });
|
||||
if (!host) {
|
||||
send({
|
||||
type: "error",
|
||||
message: syncId
|
||||
? HOST_NOT_ON_THIS_SERVER_MESSAGE
|
||||
: "Host not found",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
|
||||
log.error("Refusing Docker console: host id resolves elsewhere", {
|
||||
hostId,
|
||||
userId,
|
||||
});
|
||||
send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
|
||||
return;
|
||||
}
|
||||
|
||||
case "input": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const input = asString(message.data);
|
||||
if (input) sshSession.stream.write(input);
|
||||
}
|
||||
break;
|
||||
const settings = await readDockerHostSettings(ctx, host.id);
|
||||
if (!settings.enabled) {
|
||||
send({
|
||||
type: "error",
|
||||
message:
|
||||
"Docker is not enabled for this host. Enable it in Host Settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
case "resize": {
|
||||
if (sshSession && sshSession.stream) {
|
||||
const dimensions = asObject(message.data);
|
||||
const cols = toTerminalDimension(dimensions.cols);
|
||||
const rows = toTerminalDimension(dimensions.rows);
|
||||
const { client, dispose } = await ctx.ssh.connect<Client>(host, {
|
||||
purpose: "docker-console",
|
||||
timeoutMs: 65_000,
|
||||
});
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
dispose();
|
||||
return;
|
||||
}
|
||||
const current: ConsoleSession = {
|
||||
client,
|
||||
dispose,
|
||||
stream: null,
|
||||
hostId: host.id,
|
||||
containerId,
|
||||
runtime: settings.runtime,
|
||||
};
|
||||
session = current;
|
||||
|
||||
const shellToUse = await pickShell(
|
||||
client,
|
||||
settings.runtime,
|
||||
containerId,
|
||||
shell,
|
||||
);
|
||||
if (session !== current) return;
|
||||
|
||||
client.exec(
|
||||
containerCommand(
|
||||
settings.runtime,
|
||||
`exec -it ${containerId} /bin/${shellToUse}`,
|
||||
),
|
||||
{ pty: { term: "xterm-256color", cols, rows } },
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Failed to start console: ${err.message}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (session !== current) {
|
||||
stream.end();
|
||||
return;
|
||||
}
|
||||
current.stream = stream;
|
||||
|
||||
// Buffers split multi-byte UTF-8 sequences across chunks.
|
||||
const decoder = new StringDecoder("utf-8");
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
const text = decoder.write(chunk);
|
||||
if (text) send({ type: "output", data: text });
|
||||
});
|
||||
stream.stderr.on("data", () => {});
|
||||
stream.stderr.on("error", () => {});
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
send({
|
||||
type: "error",
|
||||
message: `Console error: ${streamErr.message}`,
|
||||
});
|
||||
});
|
||||
stream.on("close", () => {
|
||||
send({ type: "disconnected", message: "Console session ended" });
|
||||
if (session === current) endSession();
|
||||
});
|
||||
|
||||
send({
|
||||
type: "connected",
|
||||
data: {
|
||||
shell: shellToUse,
|
||||
requestedShell: shell,
|
||||
shellChanged: !!shell && shell !== shellToUse,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
log.error("Failed to connect to container", error, {
|
||||
hostId,
|
||||
containerId,
|
||||
});
|
||||
send({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "Failed to connect to container"),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.on(
|
||||
"message",
|
||||
AsyncResource.bind(async (raw: RawData) => {
|
||||
let type: string;
|
||||
let data: unknown;
|
||||
try {
|
||||
({ type, data } = parseWsMessage(raw));
|
||||
} catch (error) {
|
||||
send({ type: "error", message: getErrorMessage(error) });
|
||||
return;
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case "connect":
|
||||
await connect(data);
|
||||
break;
|
||||
case "input": {
|
||||
const input = asString(data);
|
||||
if (input) session?.stream?.write(input);
|
||||
break;
|
||||
}
|
||||
case "resize": {
|
||||
const size = asObject(data);
|
||||
const cols = toTerminalDimension(size.cols);
|
||||
const rows = toTerminalDimension(size.rows);
|
||||
if (cols && rows)
|
||||
sshSession.stream.setWindow(rows, cols, rows, cols);
|
||||
session?.stream?.setWindow(rows, cols, rows, cols);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case "disconnect": {
|
||||
if (sshSession) {
|
||||
if (sshSession.stream) {
|
||||
sshSession.stream.end();
|
||||
}
|
||||
sshSession.client.end();
|
||||
activeSessions.delete(sessionId);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
case "disconnect":
|
||||
if (session) {
|
||||
endSession();
|
||||
send({
|
||||
type: "disconnected",
|
||||
message: "Disconnected from container",
|
||||
}),
|
||||
);
|
||||
}
|
||||
break;
|
||||
});
|
||||
}
|
||||
break;
|
||||
case "ping":
|
||||
send({ type: "pong" });
|
||||
break;
|
||||
default:
|
||||
log.warn("Unknown docker console message", { type });
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
case "ping": {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: "pong" }));
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
sshLogger.warn("Unknown message type", {
|
||||
operation: "ws_message",
|
||||
type: message.type,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
sshLogger.error("WebSocket message error", error, {
|
||||
operation: "ws_message",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
type: "error",
|
||||
message: getErrorMessage(error, "An error occurred"),
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
sshLogger.info("Docker console disconnected", {
|
||||
operation: "docker_console_disconnect",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId: sshSession?.hostId,
|
||||
containerId: sshSession?.containerId,
|
||||
ws.on("close", cleanup);
|
||||
ws.on("error", (error: Error) => {
|
||||
log.error("Docker console socket error", error);
|
||||
cleanup();
|
||||
});
|
||||
cleanup();
|
||||
});
|
||||
};
|
||||
|
||||
ws.on("error", (error) => {
|
||||
sshLogger.error("WebSocket error", error, {
|
||||
operation: "ws_error",
|
||||
sessionId,
|
||||
});
|
||||
|
||||
cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes every live console session and the WebSocket server. Called from the
|
||||
* plugin's deactivate().
|
||||
*
|
||||
* There is deliberately no process signal handler here. Core owns shutdown:
|
||||
* gracefulShutdown in src/backend/starter.ts calls shutdownPlugins(), which
|
||||
* runs every plugin's deactivate. A plugin calling process.exit() itself
|
||||
* skipped the rest of that sequence, including other plugins and the
|
||||
* database flush.
|
||||
*/
|
||||
export function closeConsoleServer(): Promise<void> {
|
||||
activeSessions.forEach((session) => {
|
||||
if (session.stream) {
|
||||
session.stream.end();
|
||||
}
|
||||
session.client.end();
|
||||
});
|
||||
|
||||
activeSessions.clear();
|
||||
|
||||
const server = wss;
|
||||
wss = null;
|
||||
if (!server) return Promise.resolve();
|
||||
|
||||
server.off("connection", handleConnection);
|
||||
for (const client of server.clients) {
|
||||
try {
|
||||
client.close(1001, "Docker plugin disabled");
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
}
|
||||
return new Promise((resolve) => server.close(() => resolve()));
|
||||
ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -4,33 +4,6 @@ export function normalizeContainerRuntime(value: unknown): ContainerRuntime {
|
||||
return value === "podman" ? "podman" : "docker";
|
||||
}
|
||||
|
||||
export function getContainerRuntimeConfig(raw: unknown): {
|
||||
runtime: ContainerRuntime;
|
||||
} {
|
||||
if (!raw) {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
let config: unknown = raw;
|
||||
if (typeof raw === "string") {
|
||||
try {
|
||||
config = JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
}
|
||||
|
||||
if (!config || typeof config !== "object") {
|
||||
return { runtime: "docker" };
|
||||
}
|
||||
|
||||
return {
|
||||
runtime: normalizeContainerRuntime(
|
||||
(config as Record<string, unknown>).runtime,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
|
||||
* so PATH additions from installers like Homebrew or OrbStack are missing.
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* Container state snapshots and the events two of them imply, for the
|
||||
* docker.events service.
|
||||
*
|
||||
* Events come from diffing successive `ps -a` snapshots, so the poll interval
|
||||
* is the resolution: a container that stops and starts between two polls is
|
||||
* not reported. That is the tradeoff for not holding a `docker events` stream
|
||||
* open against every watched host.
|
||||
*/
|
||||
|
||||
export interface ContainerState {
|
||||
/** Docker's own state word: running, exited, restarting, ... */
|
||||
state: string;
|
||||
/** Health from the status text, when the image declares a healthcheck. */
|
||||
unhealthy: boolean;
|
||||
}
|
||||
|
||||
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||
|
||||
export const PS_STATE_FORMAT = `'{"name":"{{.Names}}","state":"{{.State}}","status":"{{.Status}}"}'`;
|
||||
|
||||
/** Parses `ps -a --format PS_STATE_FORMAT` output, one JSON object per line. */
|
||||
export function parseContainerStates(
|
||||
output: string,
|
||||
): Map<string, ContainerState> {
|
||||
const states = new Map<string, ContainerState>();
|
||||
|
||||
for (const line of output.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed) as {
|
||||
name?: string;
|
||||
state?: string;
|
||||
status?: string;
|
||||
};
|
||||
if (!parsed.name) continue;
|
||||
|
||||
states.set(parsed.name, {
|
||||
state: (parsed.state ?? "").toLowerCase(),
|
||||
unhealthy: /\(unhealthy\)/i.test(parsed.status ?? ""),
|
||||
});
|
||||
} catch {
|
||||
// A partial line is not worth failing the whole poll over.
|
||||
}
|
||||
}
|
||||
|
||||
return states;
|
||||
}
|
||||
|
||||
/**
|
||||
* The events a pair of snapshots implies. A container missing from the
|
||||
* previous snapshot counts as newly seen rather than started, so the first
|
||||
* poll after a restart does not replay every running container.
|
||||
*/
|
||||
export function diffContainerStates(
|
||||
previous: Map<string, ContainerState>,
|
||||
current: Map<string, ContainerState>,
|
||||
): Array<{ container: string; event: DockerEventName }> {
|
||||
const events: Array<{ container: string; event: DockerEventName }> = [];
|
||||
|
||||
for (const [name, now] of current) {
|
||||
const before = previous.get(name);
|
||||
if (!before) continue;
|
||||
|
||||
if (before.state !== now.state) {
|
||||
if (now.state === "exited")
|
||||
events.push({ container: name, event: "exited" });
|
||||
else if (now.state === "running")
|
||||
events.push({ container: name, event: "started" });
|
||||
else if (now.state === "restarting")
|
||||
events.push({ container: name, event: "restarting" });
|
||||
}
|
||||
|
||||
// Health is independent of state: a container can go unhealthy while it
|
||||
// stays up, which is exactly the case worth alerting on.
|
||||
if (!before.unhealthy && now.unhealthy) {
|
||||
events.push({ container: name, event: "unhealthy" });
|
||||
}
|
||||
}
|
||||
|
||||
return events;
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { RawData } from "ws";
|
||||
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export type Meta = Record<string, unknown>;
|
||||
|
||||
export interface DockerLogger {
|
||||
info: (message: string, meta?: Meta) => void;
|
||||
warn: (message: string, meta?: Meta) => void;
|
||||
/** An error, or structured context, or both. */
|
||||
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
|
||||
}
|
||||
|
||||
function withMeta(message: string, meta?: Meta): string {
|
||||
if (!meta || Object.keys(meta).length === 0) return message;
|
||||
try {
|
||||
return `${message} ${JSON.stringify(meta)}`;
|
||||
} catch {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
|
||||
/** ctx.log with the structured context the old core logger carried. */
|
||||
export function createLogger(log: PluginLogger): DockerLogger {
|
||||
return {
|
||||
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||
error: (message, errorOrMeta, meta) => {
|
||||
if (errorOrMeta instanceof Error) {
|
||||
log.error(withMeta(message, meta), errorOrMeta);
|
||||
return;
|
||||
}
|
||||
log.error(
|
||||
withMeta(message, {
|
||||
...((errorOrMeta as Meta | undefined) ?? {}),
|
||||
...meta,
|
||||
}),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export type LogLevel = "info" | "success" | "warning" | "error";
|
||||
|
||||
/** One line of the connection log the panel shows while connecting. */
|
||||
export interface ConnectionLogLine {
|
||||
type: LogLevel;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export function connectionLog(
|
||||
type: LogLevel,
|
||||
stage: string,
|
||||
message: string,
|
||||
): ConnectionLogLine {
|
||||
return { type, stage, message };
|
||||
}
|
||||
|
||||
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||
|
||||
// Cap on a single decoded text frame. Control messages are tiny, and console
|
||||
// input is bounded by what a user can type or paste.
|
||||
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||
|
||||
function rawByteLength(raw: RawData): number {
|
||||
if (Buffer.isBuffer(raw)) return raw.length;
|
||||
if (Array.isArray(raw))
|
||||
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||
return 0;
|
||||
}
|
||||
|
||||
/** Parses a frame into `{ type, data }`, throwing on anything else. */
|
||||
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
|
||||
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||
throw new Error("Message too large");
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
throw new Error("Invalid JSON");
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("Message must be a JSON object");
|
||||
}
|
||||
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||
if (typeof type !== "string") {
|
||||
throw new Error("Message type must be a string");
|
||||
}
|
||||
return { type, data };
|
||||
}
|
||||
|
||||
export function asObject(data: unknown): Record<string, unknown> {
|
||||
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||
? (data as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
export function asString(value: unknown): string {
|
||||
return typeof value === "string" ? value : "";
|
||||
}
|
||||
|
||||
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||
export function toTerminalDimension(value: unknown): number {
|
||||
const n = typeof value === "number" ? value : Number(value);
|
||||
if (!Number.isFinite(n)) return 0;
|
||||
const rounded = Math.floor(n);
|
||||
if (rounded < 1) return 0;
|
||||
return Math.min(rounded, 10000);
|
||||
}
|
||||
|
||||
function normalizeHostAddress(value: unknown): string {
|
||||
if (typeof value !== "string") return "";
|
||||
return value
|
||||
.replace(/^\[|\]$/g, "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a numeric host id resolved to a different machine than the client
|
||||
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||
*/
|
||||
export function hostAddressMismatch(
|
||||
clientAddress: unknown,
|
||||
resolvedAddress: unknown,
|
||||
): boolean {
|
||||
const resolved = normalizeHostAddress(resolvedAddress);
|
||||
if (!resolved) return false;
|
||||
return resolved !== normalizeHostAddress(clientAddress);
|
||||
}
|
||||
|
||||
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||
"The host ids on this device and on the sync server have drifted apart. " +
|
||||
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||
|
||||
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||
"This host does not exist on the sync server, so the connection was refused. " +
|
||||
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||
|
||||
export function newSessionId(prefix: string): string {
|
||||
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import {
|
||||
normalizeContainerRuntime,
|
||||
type ContainerRuntime,
|
||||
} from "./container-runtime.js";
|
||||
|
||||
export const PLUGIN_ID = "docker";
|
||||
|
||||
export interface DockerHostSettings {
|
||||
enabled: boolean;
|
||||
runtime: ContainerRuntime;
|
||||
}
|
||||
|
||||
export async function readDockerHostSettings(
|
||||
ctx: PluginContext,
|
||||
hostId: number,
|
||||
): Promise<DockerHostSettings> {
|
||||
const values = await ctx.settings.getAll("host", hostId);
|
||||
return {
|
||||
enabled: values.enableDocker === true,
|
||||
runtime: normalizeContainerRuntime(values.containerRuntime),
|
||||
};
|
||||
}
|
||||
|
||||
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||
let parsed = value;
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||
? (parsed as Record<string, unknown>)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function asBool(value: unknown): boolean | undefined {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === 1 || value === "1" || value === "true") return true;
|
||||
if (value === 0 || value === "0" || value === "false") return false;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The docker host settings in an imported or created host row: the nested
|
||||
* `pluginSettings.docker` of a Termix export first, then the flat
|
||||
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
|
||||
* exports, proxmox guests marked as Docker hosts).
|
||||
*/
|
||||
export function normalizeImportedHost(
|
||||
raw: Record<string, unknown>,
|
||||
): Record<string, unknown> | null {
|
||||
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
|
||||
const out: Record<string, unknown> = {};
|
||||
|
||||
const enabled = asBool(
|
||||
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
|
||||
);
|
||||
if (enabled !== undefined) out.enableDocker = enabled;
|
||||
|
||||
const runtime =
|
||||
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
|
||||
if (runtime === "docker" || runtime === "podman") {
|
||||
out.containerRuntime = runtime;
|
||||
}
|
||||
|
||||
return Object.keys(out).length > 0 ? out : null;
|
||||
}
|
||||
@@ -1,82 +1,33 @@
|
||||
import express, { type Router } from "express";
|
||||
import type { Router } from "express";
|
||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||
import { setPluginSsh } from "./ssh.js";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js";
|
||||
import { registerDockerContainerRoutes } from "./container-routes.js";
|
||||
import {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
executeDockerCommand,
|
||||
} from "./session-manager.js";
|
||||
import {
|
||||
DOCKER_TIMESTAMP_RE,
|
||||
getRequestUserId,
|
||||
registerDockerSshRoutes,
|
||||
} from "./routes.js";
|
||||
import {
|
||||
closeConsoleServer,
|
||||
handleConsoleUpgrade,
|
||||
startConsoleServer,
|
||||
} from "./console.js";
|
||||
import { createLogger } from "./helpers.js";
|
||||
import { createDockerSessions } from "./sessions.js";
|
||||
import { registerRoutes } from "./routes.js";
|
||||
import { registerConsole } from "./console.js";
|
||||
import { registerServices } from "./services.js";
|
||||
import { normalizeImportedHost } from "./host-settings.js";
|
||||
|
||||
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null =
|
||||
null;
|
||||
export type {
|
||||
DockerContainerAction,
|
||||
DockerContainerSummary,
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "./services.js";
|
||||
|
||||
export async function activate(ctx: PluginContext) {
|
||||
setPluginSsh(ctx.ssh);
|
||||
ctx.disposables.add(() => setPluginSsh(null));
|
||||
// An express app rather than the router directly, so the existing
|
||||
// register*Routes helpers keep the Application they expect. Core mounts it
|
||||
// at /plugin-api/docker and runs compression, CORS, cookies, auth and body
|
||||
// parsing in front of it.
|
||||
const app = express();
|
||||
app.use((_req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
next();
|
||||
});
|
||||
const log = createLogger(ctx.log);
|
||||
const sessions = createDockerSessions(ctx.schedule, log);
|
||||
ctx.disposables.add(() => sessions.closeAll());
|
||||
|
||||
registerDockerSshRoutes(app);
|
||||
registerDockerContainerRoutes(app, {
|
||||
sshSessions,
|
||||
pendingTOTPSessions,
|
||||
getRequestUserId,
|
||||
executeDockerCommand,
|
||||
dockerTimestampPattern: DOCKER_TIMESTAMP_RE,
|
||||
});
|
||||
|
||||
ctx.http.router<Router>({ bodyLimit: "100mb" }).use(app);
|
||||
|
||||
authManagerInstance = AuthManager.getInstance();
|
||||
try {
|
||||
await authManagerInstance.initialize();
|
||||
} catch (err) {
|
||||
logger.error("Failed to initialize Docker backend", err, {
|
||||
operation: "startup",
|
||||
});
|
||||
}
|
||||
|
||||
await startConsoleServer();
|
||||
|
||||
// Public because the console authenticates each session over the socket
|
||||
// itself, where the container and host checks also live.
|
||||
ctx.ws.upgrade(
|
||||
"/console",
|
||||
(request, socket, head) =>
|
||||
handleConsoleUpgrade(
|
||||
request as Parameters<typeof handleConsoleUpgrade>[0],
|
||||
socket as Parameters<typeof handleConsoleUpgrade>[1],
|
||||
head as Buffer,
|
||||
),
|
||||
{ public: true },
|
||||
);
|
||||
registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
|
||||
registerConsole(ctx, log);
|
||||
registerServices(ctx, log);
|
||||
ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
|
||||
|
||||
ctx.log.info(
|
||||
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
|
||||
);
|
||||
}
|
||||
|
||||
export async function deactivate() {
|
||||
await closeConsoleServer();
|
||||
authManagerInstance = null;
|
||||
}
|
||||
export async function deactivate() {}
|
||||
+781
-1103
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||
import { containerCommand } from "./container-runtime.js";
|
||||
import {
|
||||
PS_STATE_FORMAT,
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
type ContainerState,
|
||||
type DockerEventName,
|
||||
} from "./container-state.js";
|
||||
import { readDockerHostSettings } from "./host-settings.js";
|
||||
import { execOnClient } from "./sessions.js";
|
||||
import { listFormat, parseContainerList } from "./routes.js";
|
||||
import {
|
||||
CONTAINER_ID_RE,
|
||||
getErrorMessage,
|
||||
type DockerLogger,
|
||||
} from "./helpers.js";
|
||||
|
||||
export const POOL = "docker";
|
||||
const TICK_MS = 15_000;
|
||||
const POLL_INTERVAL_MS = 60_000;
|
||||
const HOST_CACHE_MS = 5 * 60_000;
|
||||
|
||||
export interface DockerContainerSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state: string;
|
||||
ports: string;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type DockerContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
|
||||
|
||||
const ACTIONS: DockerContainerAction[] = [
|
||||
"start",
|
||||
"stop",
|
||||
"restart",
|
||||
"pause",
|
||||
"unpause",
|
||||
"remove",
|
||||
];
|
||||
|
||||
/** The "docker.containers" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerServiceV1 {
|
||||
listContainers: (
|
||||
hostId: number,
|
||||
options?: { all?: boolean },
|
||||
) => Promise<DockerContainerSummary[]>;
|
||||
action: (
|
||||
hostId: number,
|
||||
container: string,
|
||||
action: DockerContainerAction,
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
export interface DockerEvent {
|
||||
hostId: number;
|
||||
container: string;
|
||||
event: DockerEventName;
|
||||
}
|
||||
|
||||
/** The "docker.events" service, version 1. Runs as the caller's user. */
|
||||
export interface DockerEventsV1 {
|
||||
/**
|
||||
* Calls `listener` for container state changes on a host, polled about
|
||||
* once a minute as the subscribing user. Subscribing the same listener
|
||||
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
|
||||
* a restarted docker plugin. Resolves to an unsubscribe function.
|
||||
*/
|
||||
subscribe: (
|
||||
hostId: number,
|
||||
listener: (event: DockerEvent) => void,
|
||||
) => Promise<() => void>;
|
||||
}
|
||||
|
||||
function actorOf(ctx: PluginContext): string {
|
||||
const userId = ctx.currentActor();
|
||||
if (!userId) throw new Error("The docker service needs a calling user");
|
||||
return userId;
|
||||
}
|
||||
|
||||
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
|
||||
const runOnHost = async <T>(
|
||||
hostId: number,
|
||||
command: (runtime: "docker" | "podman") => string,
|
||||
handle: (output: string) => T,
|
||||
): Promise<T> => {
|
||||
const { runtime } = await readDockerHostSettings(ctx, hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
hostId,
|
||||
{ pool: POOL, purpose: "docker" },
|
||||
(client) => execOnClient(client, command(runtime)),
|
||||
);
|
||||
return handle(output);
|
||||
};
|
||||
|
||||
ctx.services.provide<DockerServiceV1>("docker.containers", {
|
||||
listContainers: async (hostId, options) => {
|
||||
actorOf(ctx);
|
||||
const all = options?.all !== false;
|
||||
return runOnHost(
|
||||
hostId,
|
||||
(runtime) =>
|
||||
containerCommand(
|
||||
runtime,
|
||||
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
|
||||
),
|
||||
(output) =>
|
||||
parseContainerList(output) as unknown as DockerContainerSummary[],
|
||||
);
|
||||
},
|
||||
action: async (hostId, container, action) => {
|
||||
actorOf(ctx);
|
||||
if (!CONTAINER_ID_RE.test(container)) {
|
||||
throw new Error("Invalid container name");
|
||||
}
|
||||
if (!ACTIONS.includes(action)) {
|
||||
throw new Error(`Unknown container action: ${action}`);
|
||||
}
|
||||
const args =
|
||||
action === "remove" ? `rm ${container}` : `${action} ${container}`;
|
||||
await runOnHost(
|
||||
hostId,
|
||||
(runtime) => containerCommand(runtime, args),
|
||||
() => undefined,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
interface Watch {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
listeners: Set<(event: DockerEvent) => void>;
|
||||
snapshot?: Map<string, ContainerState>;
|
||||
host?: { value: PluginSshHost; at: number };
|
||||
lastPolledAt: number;
|
||||
failing: boolean;
|
||||
}
|
||||
const watches = new Map<string, Watch>();
|
||||
ctx.disposables.add(() => watches.clear());
|
||||
|
||||
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
|
||||
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
|
||||
return watch.host.value;
|
||||
}
|
||||
const host = await ctx.asUser(watch.userId, () =>
|
||||
ctx.ssh.resolveHost(watch.hostId),
|
||||
);
|
||||
watch.host = host ? { value: host, at: Date.now() } : undefined;
|
||||
return host;
|
||||
};
|
||||
|
||||
const pollOne = async (watch: Watch) => {
|
||||
try {
|
||||
const host = await hostFor(watch);
|
||||
if (!host) throw new Error("Host not found");
|
||||
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
|
||||
const output = await ctx.ssh.withConnection<string, Client>(
|
||||
host,
|
||||
{ pool: POOL, purpose: "docker" },
|
||||
(client) =>
|
||||
execOnClient(
|
||||
client,
|
||||
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
|
||||
),
|
||||
);
|
||||
const current = parseContainerStates(output);
|
||||
const previous = watch.snapshot;
|
||||
watch.snapshot = current;
|
||||
watch.failing = false;
|
||||
if (!previous) return;
|
||||
for (const { container, event } of diffContainerStates(
|
||||
previous,
|
||||
current,
|
||||
)) {
|
||||
for (const listener of [...watch.listeners]) {
|
||||
try {
|
||||
listener({ hostId: watch.hostId, container, event });
|
||||
} catch (error) {
|
||||
log.warn("Docker event listener failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
// Docker missing, the host down or the owner's data locked. A later
|
||||
// success is a first observation again rather than a diff.
|
||||
watch.snapshot = undefined;
|
||||
watch.host = undefined;
|
||||
if (!watch.failing) {
|
||||
log.warn("Docker event poll failed", {
|
||||
hostId: watch.hostId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
watch.failing = true;
|
||||
}
|
||||
};
|
||||
|
||||
ctx.schedule.every(TICK_MS, async () => {
|
||||
const now = Date.now();
|
||||
for (const watch of [...watches.values()]) {
|
||||
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
|
||||
watch.lastPolledAt = now;
|
||||
await pollOne(watch);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.events.on("host.updated", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
ctx.ssh.dropPooled(POOL, hostId);
|
||||
for (const watch of watches.values()) {
|
||||
if (watch.hostId === hostId) watch.host = undefined;
|
||||
}
|
||||
});
|
||||
ctx.events.on("host.deleted", (payload) => {
|
||||
const { hostId } = payload as { hostId?: number };
|
||||
if (!hostId) return;
|
||||
for (const [key, watch] of watches) {
|
||||
if (watch.hostId === hostId) watches.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.services.provide<DockerEventsV1>("docker.events", {
|
||||
subscribe: async (hostId, listener) => {
|
||||
const userId = actorOf(ctx);
|
||||
const key = `${userId}:${hostId}`;
|
||||
let watch = watches.get(key);
|
||||
if (!watch) {
|
||||
watch = {
|
||||
userId,
|
||||
hostId,
|
||||
listeners: new Set(),
|
||||
lastPolledAt: 0,
|
||||
failing: false,
|
||||
};
|
||||
watches.set(key, watch);
|
||||
}
|
||||
watch.listeners.add(listener);
|
||||
const current = watch;
|
||||
return () => {
|
||||
current.listeners.delete(listener);
|
||||
if (current.listeners.size === 0 && watches.get(key) === current) {
|
||||
watches.delete(key);
|
||||
}
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
||||
// The plugin build rewrites the prefix to the compiled output path; see
|
||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
||||
import { Client as SSHClient } from "ssh2";
|
||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
|
||||
const sshLogger = logger;
|
||||
|
||||
export interface SSHSession {
|
||||
client: SSHClient;
|
||||
isConnected: boolean;
|
||||
lastActive: number;
|
||||
timeout?: NodeJS.Timeout;
|
||||
activeOperations: number;
|
||||
hostId?: number;
|
||||
userId?: string;
|
||||
isWindows?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export interface PendingTOTPSession {
|
||||
client: SSHClient;
|
||||
finish: (responses: string[]) => void;
|
||||
config: Record<string, unknown>;
|
||||
createdAt: number;
|
||||
sessionId: string;
|
||||
hostId?: number;
|
||||
ip?: string;
|
||||
port?: number;
|
||||
username?: string;
|
||||
userId?: string;
|
||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
||||
totpPromptIndex?: number;
|
||||
resolvedPassword?: string;
|
||||
totpAttempts: number;
|
||||
isWarpgate?: boolean;
|
||||
containerRuntime?: ContainerRuntime;
|
||||
}
|
||||
|
||||
export const sshSessions: Record<string, SSHSession> = {};
|
||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
||||
|
||||
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
||||
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
|
||||
const session = pendingTOTPSessions[sessionId];
|
||||
if (now - session.createdAt > 180000) {
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
delete pendingTOTPSessions[sessionId];
|
||||
}
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
export function cleanupSession(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.activeOperations > 0) {
|
||||
sshLogger.warn(
|
||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
||||
{
|
||||
operation: "cleanup_deferred",
|
||||
sessionId,
|
||||
activeOperations: session.activeOperations,
|
||||
},
|
||||
);
|
||||
scheduleSessionCleanup(sessionId);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
session.client.end();
|
||||
} catch {
|
||||
// expected
|
||||
}
|
||||
clearTimeout(session.timeout);
|
||||
delete sshSessions[sessionId];
|
||||
}
|
||||
}
|
||||
|
||||
export function scheduleSessionCleanup(sessionId: string) {
|
||||
const session = sshSessions[sessionId];
|
||||
if (session) {
|
||||
if (session.timeout) clearTimeout(session.timeout);
|
||||
|
||||
session.timeout = setTimeout(() => {
|
||||
cleanupSession(sessionId);
|
||||
}, SESSION_IDLE_TIMEOUT);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeDockerCommand(
|
||||
session: SSHSession,
|
||||
command: string,
|
||||
sessionId?: string,
|
||||
userId?: string,
|
||||
hostId?: number,
|
||||
): Promise<string> {
|
||||
const startTime = Date.now();
|
||||
sshLogger.info("Executing Docker command", {
|
||||
operation: "docker_command_exec",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
session.client.exec(command, (err, stream) => {
|
||||
if (err) {
|
||||
sshLogger.error("Docker command execution error", err, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0) {
|
||||
sshLogger.error("Docker command failed", undefined, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
exitCode: code,
|
||||
stderr,
|
||||
});
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
sshLogger.success("Docker command completed", {
|
||||
operation: "docker_command_success",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
duration: Date.now() - startTime,
|
||||
});
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
|
||||
stream.on("error", (streamErr: Error) => {
|
||||
sshLogger.error("Docker command stream error", streamErr, {
|
||||
operation: "execute_docker_command",
|
||||
sessionId,
|
||||
userId,
|
||||
hostId,
|
||||
command: command.split(" ")[1],
|
||||
});
|
||||
reject(streamErr);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||
import type { ContainerRuntime } from "./container-runtime.js";
|
||||
import type { DockerLogger } from "./helpers.js";
|
||||
|
||||
const SESSION_IDLE_MS = 60 * 60 * 1000;
|
||||
const PENDING_SWEEP_MS = 60 * 1000;
|
||||
|
||||
/** A live SSH connection behind the Docker panel, owned by one user. */
|
||||
export interface DockerSession {
|
||||
id: string;
|
||||
client: Client;
|
||||
dispose: () => void;
|
||||
hostId: number;
|
||||
userId: string;
|
||||
runtime: ContainerRuntime;
|
||||
isWindows: boolean;
|
||||
lastActive: number;
|
||||
activeOperations: number;
|
||||
cancelIdle?: () => void;
|
||||
}
|
||||
|
||||
/** A connect still waiting on a person (TOTP, Warpgate). */
|
||||
export interface PendingEntry {
|
||||
userId: string;
|
||||
hostId: number;
|
||||
expiresAt: number;
|
||||
/** Gives up: answers the open prompt with nothing. */
|
||||
cancel: () => void;
|
||||
}
|
||||
|
||||
export interface DockerSessions {
|
||||
add: (session: DockerSession) => void;
|
||||
/** The session when `userId` owns it. */
|
||||
get: (id: string, userId: string) => DockerSession | undefined;
|
||||
close: (id: string) => void;
|
||||
touch: (session: DockerSession) => void;
|
||||
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
|
||||
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
|
||||
exec: (session: DockerSession, command: string) => Promise<string>;
|
||||
setPending: (id: string, entry: PendingEntry) => void;
|
||||
getPending: (id: string) => PendingEntry | undefined;
|
||||
clearPending: (id: string) => void;
|
||||
closeAll: () => void;
|
||||
}
|
||||
|
||||
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
|
||||
export function execOnClient(client: Client, command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
client.exec(command, (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
stream.on("close", (code: number) => {
|
||||
if (code !== 0 && code !== null && code !== undefined) {
|
||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||
} else {
|
||||
resolve(stdout);
|
||||
}
|
||||
});
|
||||
stream.on("data", (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
stream.stderr.on("data", (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
stream.on("error", (streamErr: Error) => reject(streamErr));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function createDockerSessions(
|
||||
schedule: PluginSchedule,
|
||||
log: DockerLogger,
|
||||
): DockerSessions {
|
||||
const sessions = new Map<string, DockerSession>();
|
||||
const pending = new Map<string, PendingEntry>();
|
||||
|
||||
const end = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
session.dispose();
|
||||
};
|
||||
|
||||
const scheduleIdle = (session: DockerSession) => {
|
||||
session.cancelIdle?.();
|
||||
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
|
||||
if (sessions.get(session.id) !== session) return;
|
||||
if (session.activeOperations > 0) {
|
||||
scheduleIdle(session);
|
||||
return;
|
||||
}
|
||||
end(session);
|
||||
});
|
||||
};
|
||||
|
||||
schedule.every(PENDING_SWEEP_MS, () => {
|
||||
const now = Date.now();
|
||||
for (const [id, entry] of pending) {
|
||||
if (now < entry.expiresAt) continue;
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
add: (session) => {
|
||||
const existing = sessions.get(session.id);
|
||||
if (existing) end(existing);
|
||||
sessions.set(session.id, session);
|
||||
session.client.once("close", () => {
|
||||
if (sessions.get(session.id) === session) {
|
||||
session.cancelIdle?.();
|
||||
sessions.delete(session.id);
|
||||
}
|
||||
});
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
get: (id, userId) => {
|
||||
const session = sessions.get(id);
|
||||
return session && session.userId === userId ? session : undefined;
|
||||
},
|
||||
|
||||
close: (id) => {
|
||||
const session = sessions.get(id);
|
||||
if (session) end(session);
|
||||
const entry = pending.get(id);
|
||||
if (entry) {
|
||||
pending.delete(id);
|
||||
entry.cancel();
|
||||
}
|
||||
},
|
||||
|
||||
touch: (session) => {
|
||||
session.lastActive = Date.now();
|
||||
scheduleIdle(session);
|
||||
},
|
||||
|
||||
run: async (session, fn) => {
|
||||
session.lastActive = Date.now();
|
||||
session.activeOperations++;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
session.activeOperations--;
|
||||
}
|
||||
},
|
||||
|
||||
exec: async (session, command) => {
|
||||
const started = Date.now();
|
||||
try {
|
||||
return await execOnClient(session.client, command);
|
||||
} catch (error) {
|
||||
log.warn("Docker command failed", {
|
||||
hostId: session.hostId,
|
||||
command: command.split(" ")[2],
|
||||
durationMs: Date.now() - started,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
setPending: (id, entry) => {
|
||||
const existing = pending.get(id);
|
||||
if (existing) existing.cancel();
|
||||
pending.set(id, entry);
|
||||
},
|
||||
|
||||
getPending: (id) => pending.get(id),
|
||||
|
||||
clearPending: (id) => {
|
||||
pending.delete(id);
|
||||
},
|
||||
|
||||
closeAll: () => {
|
||||
for (const session of [...sessions.values()]) end(session);
|
||||
for (const entry of pending.values()) entry.cancel();
|
||||
pending.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { Client } from "ssh2";
|
||||
import type {
|
||||
PluginSsh,
|
||||
PluginSshConnection,
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
|
||||
let current: PluginSsh | null = null;
|
||||
|
||||
/** Set in activate, cleared on deactivate. */
|
||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
||||
current = ssh;
|
||||
}
|
||||
|
||||
export function pluginSsh(): PluginSsh {
|
||||
if (!current) throw new Error("The plugin is not active");
|
||||
return current;
|
||||
}
|
||||
|
||||
/** withConnection with ssh2's Client type filled in. */
|
||||
export function withSshConnection<T>(
|
||||
host: number | PluginSshHost,
|
||||
options: PluginSshConnectOptions & { pool: string },
|
||||
fn: (client: Client) => Promise<T>,
|
||||
): Promise<T> {
|
||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
||||
}
|
||||
|
||||
/** connect with ssh2's Client type filled in. */
|
||||
export function connectSsh(
|
||||
host: number | PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
): Promise<PluginSshConnection<Client>> {
|
||||
return pluginSsh().connect<Client>(host, options);
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
|
||||
import React from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { DockerManager } from "./DockerManager.tsx";
|
||||
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { toDockerHost } from "./types";
|
||||
|
||||
interface DockerAppProps {
|
||||
hostId?: string;
|
||||
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
|
||||
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={hostConfig}
|
||||
host={toDockerHost(
|
||||
hostConfig as unknown as Record<string, unknown>,
|
||||
)}
|
||||
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import React from "react";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Alert, AlertDescription } from "@/components/alert.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import {
|
||||
AlertCircle,
|
||||
Box,
|
||||
@@ -14,44 +9,46 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
useHost,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
|
||||
import { logActivity, getSSHHosts } from "@/main-axios";
|
||||
import {
|
||||
connectDockerSession,
|
||||
disconnectDockerSession,
|
||||
listDockerContainers,
|
||||
validateDockerAvailability,
|
||||
keepaliveDockerSession,
|
||||
verifyDockerTOTP,
|
||||
verifyDockerWarpgate,
|
||||
} from "./docker-api";
|
||||
Alert,
|
||||
AlertDescription,
|
||||
Button,
|
||||
Card,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
Input,
|
||||
SSHAuthDialog,
|
||||
Select2,
|
||||
Separator,
|
||||
TOTPDialog,
|
||||
WarpgateDialog,
|
||||
logActivity,
|
||||
useAdaptivePolling,
|
||||
useAreaPreferences,
|
||||
useConnectionLog,
|
||||
useTabsSafe,
|
||||
useUiPreferencesContext,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { DockerApiError, useDockerApi, type ConnectResult } from "./docker-api";
|
||||
import {
|
||||
dockerEnabled,
|
||||
hostTitle,
|
||||
toDockerHost,
|
||||
type DockerContainer,
|
||||
type DockerHost,
|
||||
type DockerValidation,
|
||||
} from "./types";
|
||||
import { ContainerList } from "./components/ContainerList.tsx";
|
||||
import { ContainerTable } from "./components/ContainerTable.tsx";
|
||||
import { ContainerDetail } from "./components/ContainerDetail.tsx";
|
||||
import { TOTPDialog } from "@/ssh/dialogs/TOTPDialog.tsx";
|
||||
import { SSHAuthDialog } from "@/ssh/dialogs/SSHAuthDialog.tsx";
|
||||
import { WarpgateDialog } from "@/ssh/dialogs/WarpgateDialog.tsx";
|
||||
import { useTabsSafe } from "@/shell/TabContext.tsx";
|
||||
import {
|
||||
useAreaPreferences,
|
||||
useUiPreferencesContext,
|
||||
} from "@/contexts/UiPreferencesContext";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import type { LogEntry } from "@/types/connection-log.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
|
||||
interface DockerManagerProps {
|
||||
hostConfig?: SSHHost;
|
||||
host?: DockerHost;
|
||||
title?: string;
|
||||
isVisible?: boolean;
|
||||
isTopbarOpen?: boolean;
|
||||
@@ -59,15 +56,21 @@ interface DockerManagerProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
type ConnectionLogInput = Omit<LogEntry, "id" | "timestamp">;
|
||||
interface PromptState {
|
||||
kind: "totp" | "warpgate";
|
||||
sessionId: string;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
}
|
||||
|
||||
interface DockerConnectionError {
|
||||
message?: string;
|
||||
connectionLogs?: ConnectionLogInput[];
|
||||
function newSessionId(): string {
|
||||
return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||
}
|
||||
|
||||
function DockerManagerInner({
|
||||
hostConfig,
|
||||
host,
|
||||
title,
|
||||
isVisible = true,
|
||||
isTopbarOpen = true,
|
||||
@@ -75,11 +78,24 @@ function DockerManagerInner({
|
||||
onClose,
|
||||
}: DockerManagerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const { addLog, setLogs, clearLogs } = useConnectionLog();
|
||||
const { currentTab, removeTab } = useTabsSafe();
|
||||
const dockerPrefs = useAreaPreferences("docker");
|
||||
const uiPrefsCtx = useUiPreferencesContext();
|
||||
const [currentHostConfig, setCurrentHostConfig] = React.useState(hostConfig);
|
||||
|
||||
// The shell's host list keeps the host current after an edit; a
|
||||
// standalone window has no shell, so it keeps the host it was given.
|
||||
const liveRecord = useHost(host?.id);
|
||||
const currentHost = React.useMemo(
|
||||
() =>
|
||||
liveRecord
|
||||
? toDockerHost(liveRecord as unknown as Record<string, unknown>)
|
||||
: host,
|
||||
[liveRecord, host],
|
||||
);
|
||||
const enabled = dockerEnabled(currentHost);
|
||||
|
||||
const [sessionId, setSessionId] = React.useState<string | null>(null);
|
||||
const [containers, setContainers] = React.useState<DockerContainer[]>([]);
|
||||
const containersSignatureRef = React.useRef("");
|
||||
@@ -105,23 +121,14 @@ function DockerManagerInner({
|
||||
React.useState<DockerValidation | null>(null);
|
||||
const [isValidating, setIsValidating] = React.useState(false);
|
||||
// Initial view follows the interface preset; switching it afterwards is a
|
||||
// per-session choice, same as before.
|
||||
// per-session choice.
|
||||
const [viewMode, setViewMode] = React.useState<"list" | "detail">(
|
||||
dockerPrefs.viewMode,
|
||||
);
|
||||
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
|
||||
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
|
||||
React.useState(false);
|
||||
const [totpRequired, setTotpRequired] = React.useState(false);
|
||||
const [totpSessionId, setTotpSessionId] = React.useState<string | null>(null);
|
||||
const [totpPrompt, setTotpPrompt] = React.useState<string>("");
|
||||
const [warpgateRequired, setWarpgateRequired] = React.useState(false);
|
||||
const [warpgateSessionId, setWarpgateSessionId] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [warpgateUrl, setWarpgateUrl] = React.useState<string>("");
|
||||
const [warpgateSecurityKey, setWarpgateSecurityKey] =
|
||||
React.useState<string>("");
|
||||
const [prompt, setPrompt] = React.useState<PromptState | null>(null);
|
||||
const [showAuthDialog, setShowAuthDialog] = React.useState(false);
|
||||
const [authReason, setAuthReason] = React.useState<
|
||||
"no_keyboard" | "auth_failed" | "timeout"
|
||||
@@ -131,214 +138,168 @@ function DockerManagerInner({
|
||||
const [retryCount, setRetryCount] = React.useState(0);
|
||||
|
||||
const activityLoggedRef = React.useRef(false);
|
||||
const activityLoggingRef = React.useRef(false);
|
||||
|
||||
const logDockerActivity = async () => {
|
||||
if (
|
||||
!currentHostConfig?.id ||
|
||||
activityLoggedRef.current ||
|
||||
activityLoggingRef.current
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
activityLoggingRef.current = true;
|
||||
const logDockerActivity = () => {
|
||||
if (!currentHost?.id || activityLoggedRef.current) return;
|
||||
activityLoggedRef.current = true;
|
||||
|
||||
try {
|
||||
const hostName =
|
||||
currentHostConfig.name ||
|
||||
`${currentHostConfig.username}@${currentHostConfig.ip}`;
|
||||
await logActivity("docker", currentHostConfig.id, hostName);
|
||||
} catch (err) {
|
||||
console.warn("Failed to log docker activity:", err);
|
||||
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
|
||||
activityLoggedRef.current = false;
|
||||
} finally {
|
||||
activityLoggingRef.current = false;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
if (hostConfig?.id !== currentHostConfig?.id) {
|
||||
setCurrentHostConfig(hostConfig);
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
setContainers([]);
|
||||
setSelectedContainer(null);
|
||||
setSessionId(null);
|
||||
setDockerValidation(null);
|
||||
setViewMode("list");
|
||||
}, [host?.id]);
|
||||
|
||||
const retryRef = React.useRef<ReturnType<typeof useConnectionRetry> | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const validate = async (sid: string, clearAfter: boolean) => {
|
||||
setSessionId(sid);
|
||||
setIsValidating(true);
|
||||
try {
|
||||
const validation = await docker.validate(sid);
|
||||
setDockerValidation(validation);
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
retryRef.current?.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
if (clearAfter) setTimeout(() => clearLogs(), 1000);
|
||||
retryRef.current?.markConnected();
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false);
|
||||
}
|
||||
}, [hostConfig?.id]);
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
const fetchLatestHostConfig = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
/** Acts on one step of a connect, whichever request it answered. */
|
||||
const applyConnectResult = async (
|
||||
sid: string,
|
||||
result: ConnectResult,
|
||||
clearAfter: boolean,
|
||||
) => {
|
||||
if (result.connectionLogs) setLogs(result.connectionLogs);
|
||||
|
||||
if (result.requires_warpgate) {
|
||||
setPrompt({
|
||||
kind: "warpgate",
|
||||
sessionId: sid,
|
||||
url: result.url || "",
|
||||
securityKey: result.securityKey || "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.requires_totp) {
|
||||
if (result.retry) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
}
|
||||
};
|
||||
setPrompt({
|
||||
kind: "totp",
|
||||
sessionId: sid,
|
||||
prompt: result.prompt || t("docker.verificationCodePrompt"),
|
||||
isPassword: result.isPassword,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.status === "auth_required") {
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setShowAuthDialog(true);
|
||||
return;
|
||||
}
|
||||
setPrompt(null);
|
||||
await validate(sid, clearAfter);
|
||||
};
|
||||
|
||||
fetchLatestHostConfig();
|
||||
const reportFailure = (error: unknown, fallback: string) => {
|
||||
if (error instanceof DockerApiError && error.connectionLogs?.length) {
|
||||
setLogs(error.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error instanceof Error ? error.message : fallback,
|
||||
});
|
||||
}
|
||||
retryRef.current?.markFailed();
|
||||
};
|
||||
|
||||
const handleHostsChanged = async () => {
|
||||
if (hostConfig?.id) {
|
||||
try {
|
||||
const hosts = await getSSHHosts();
|
||||
const updatedHost = hosts.find((h) => h.id === hostConfig.id);
|
||||
if (updatedHost) {
|
||||
setCurrentHostConfig(updatedHost);
|
||||
}
|
||||
} catch {
|
||||
// Silently handle error
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
return () =>
|
||||
window.removeEventListener("ssh-hosts:changed", handleHostsChanged);
|
||||
}, [hostConfig?.id]);
|
||||
const connect = async (credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHost?.id) return;
|
||||
const sid = newSessionId();
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
const result = await docker.connect(sid, currentHost.id, credentials);
|
||||
await applyConnectResult(sid, result, !credentials);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.connectionFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initializingRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
const initSession = async () => {
|
||||
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (initializingRef.current) return;
|
||||
initializingRef.current = true;
|
||||
|
||||
if (sessionId) {
|
||||
initializingRef.current = false;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsConnecting(true);
|
||||
clearLogs();
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason(
|
||||
result.reason === "no_keyboard" ? "no_keyboard" : "auth_failed",
|
||||
);
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
setTimeout(() => clearLogs(), 1000);
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
const dockerError = error as DockerConnectionError;
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
|
||||
if (Array.isArray(dockerError.connectionLogs)) {
|
||||
setLogs(dockerError.connectionLogs);
|
||||
} else {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: dockerError.message || t("docker.connectionFailed"),
|
||||
});
|
||||
}
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
initSession();
|
||||
if (!currentHost?.id || !enabled) return;
|
||||
if (initializingRef.current || sessionId) return;
|
||||
initializingRef.current = true;
|
||||
clearLogs();
|
||||
void connect();
|
||||
|
||||
return () => {
|
||||
initializingRef.current = false;
|
||||
if (sessionId) {
|
||||
disconnectDockerSession(sessionId).catch(() => {
|
||||
// Silently handle disconnect errors
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [currentHostConfig?.id, currentHostConfig?.enableDocker, retryCount]);
|
||||
}, [currentHost?.id, enabled, retryCount]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
return () => {
|
||||
docker.disconnect(sessionId).catch(() => {});
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!sessionId || !isVisible) return;
|
||||
|
||||
const keepalive = setInterval(
|
||||
() => {
|
||||
keepaliveDockerSession(sessionId).catch(() => {
|
||||
// Silently handle keepalive errors
|
||||
});
|
||||
docker.keepalive(sessionId).catch(() => {});
|
||||
},
|
||||
10 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => clearInterval(keepalive);
|
||||
}, [sessionId, isVisible]);
|
||||
|
||||
const refreshContainers = React.useCallback(async () => {
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
setContainers(data);
|
||||
setContainers(await docker.listContainers(sessionId, true));
|
||||
} catch {
|
||||
// Silently handle polling errors
|
||||
// the next poll tries again
|
||||
}
|
||||
}, [sessionId]);
|
||||
}, [sessionId, docker]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setHasLoadedContainersOnce(false);
|
||||
@@ -350,7 +311,7 @@ function DockerManagerInner({
|
||||
setIsLoadingContainers((loading) =>
|
||||
hasLoadedContainersOnce ? loading : true,
|
||||
);
|
||||
const data = await listDockerContainers(sessionId, true);
|
||||
const data = await docker.listContainers(sessionId, true);
|
||||
const signature = JSON.stringify(data);
|
||||
const changed = signature !== containersSignatureRef.current;
|
||||
containersSignatureRef.current = signature;
|
||||
@@ -378,124 +339,48 @@ function DockerManagerInner({
|
||||
setSelectedContainer(null);
|
||||
}, []);
|
||||
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!totpSessionId || !code) return;
|
||||
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerTOTP(totpSessionId, code);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setTotpRequired(false);
|
||||
setTotpPrompt("");
|
||||
setSessionId(totpSessionId);
|
||||
setTotpSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(totpSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("TOTP verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.totpVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
const closeTab = () => {
|
||||
if (currentTab !== null) removeTab(currentTab);
|
||||
};
|
||||
|
||||
const handleTotpCancel = () => {
|
||||
setTotpRequired(false);
|
||||
setTotpSessionId(null);
|
||||
setTotpPrompt("");
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
const handleTotpSubmit = async (code: string) => {
|
||||
if (!prompt || prompt.kind !== "totp" || !code) return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
|
||||
} catch (error) {
|
||||
reportFailure(error, t("docker.totpVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateContinue = async () => {
|
||||
if (!warpgateSessionId) return;
|
||||
|
||||
if (!prompt || prompt.kind !== "warpgate") return;
|
||||
const sid = prompt.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(true);
|
||||
try {
|
||||
setIsConnecting(true);
|
||||
const result = await verifyDockerWarpgate(warpgateSessionId);
|
||||
|
||||
if (result?.status === "success") {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
setSessionId(warpgateSessionId);
|
||||
setWarpgateSessionId(null);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(warpgateSessionId);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "validation",
|
||||
message: validation.error || t("docker.error"),
|
||||
details: validation.code
|
||||
? `Error code: ${validation.code}`
|
||||
: undefined,
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
}
|
||||
await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
|
||||
} catch (error) {
|
||||
console.error("Warpgate verification failed:", error);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "auth",
|
||||
message: t("docker.warpgateVerificationFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
reportFailure(error, t("docker.warpgateVerificationFailed"));
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWarpgateCancel = () => {
|
||||
setWarpgateRequired(false);
|
||||
setWarpgateSessionId(null);
|
||||
setWarpgateUrl("");
|
||||
setWarpgateSecurityKey("");
|
||||
const handlePromptCancel = () => {
|
||||
const sid = prompt?.sessionId;
|
||||
setPrompt(null);
|
||||
setIsConnecting(false);
|
||||
if (currentTab !== null) {
|
||||
removeTab(currentTab);
|
||||
}
|
||||
if (sid) docker.disconnect(sid).catch(() => {});
|
||||
closeTab();
|
||||
};
|
||||
|
||||
const handleWarpgateOpenUrl = () => {
|
||||
if (warpgateUrl) {
|
||||
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
|
||||
};
|
||||
|
||||
const handleAuthSubmit = async (credentials: {
|
||||
@@ -503,75 +388,12 @@ function DockerManagerInner({
|
||||
sshKey?: string;
|
||||
keyPassword?: string;
|
||||
}) => {
|
||||
if (!currentHostConfig?.id) return;
|
||||
|
||||
setShowAuthDialog(false);
|
||||
setIsConnecting(true);
|
||||
|
||||
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
try {
|
||||
const result = await connectDockerSession(sid, currentHostConfig.id, {
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
useSocks5: currentHostConfig.useSocks5,
|
||||
socks5Host: currentHostConfig.socks5Host,
|
||||
socks5Port: currentHostConfig.socks5Port,
|
||||
socks5Username: currentHostConfig.socks5Username,
|
||||
socks5Password: currentHostConfig.socks5Password,
|
||||
socks5ProxyChain: currentHostConfig.socks5ProxyChain,
|
||||
});
|
||||
|
||||
if (result?.requires_warpgate) {
|
||||
setWarpgateRequired(true);
|
||||
setWarpgateSessionId(sid);
|
||||
setWarpgateUrl(result.url || "");
|
||||
setWarpgateSecurityKey(result.securityKey || "N/A");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.requires_totp) {
|
||||
setTotpRequired(true);
|
||||
setTotpSessionId(sid);
|
||||
setTotpPrompt(result.prompt || t("docker.verificationCodePrompt"));
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result?.status === "auth_required") {
|
||||
setShowAuthDialog(true);
|
||||
setAuthReason("auth_failed");
|
||||
setIsConnecting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setSessionId(sid);
|
||||
|
||||
setIsValidating(true);
|
||||
const validation = await validateDockerAvailability(sid);
|
||||
setDockerValidation(validation);
|
||||
setIsValidating(false);
|
||||
|
||||
if (!validation.available) {
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} else {
|
||||
logDockerActivity();
|
||||
dockerConnectRetryRef.current.markConnected();
|
||||
}
|
||||
} catch (error) {
|
||||
setIsConnecting(false);
|
||||
setIsValidating(false);
|
||||
addLog({
|
||||
type: "error",
|
||||
stage: "connection",
|
||||
message: error?.message || t("docker.connectionFailed"),
|
||||
});
|
||||
dockerConnectRetryRef.current.markFailed();
|
||||
} finally {
|
||||
setIsConnecting(false);
|
||||
}
|
||||
await connect({
|
||||
userProvidedPassword: credentials.password,
|
||||
userProvidedSshKey: credentials.sshKey,
|
||||
userProvidedKeyPassword: credentials.keyPassword,
|
||||
});
|
||||
};
|
||||
|
||||
const handleAuthCancel = () => {
|
||||
@@ -592,15 +414,10 @@ function DockerManagerInner({
|
||||
connect: () => {
|
||||
handleRetry();
|
||||
},
|
||||
enabled:
|
||||
!!currentHostConfig?.enableDocker &&
|
||||
!totpRequired &&
|
||||
!warpgateRequired &&
|
||||
!showAuthDialog,
|
||||
enabled: enabled && !prompt && !showAuthDialog,
|
||||
autoStart: false,
|
||||
});
|
||||
const dockerConnectRetryRef = React.useRef(dockerConnectRetry);
|
||||
dockerConnectRetryRef.current = dockerConnectRetry;
|
||||
retryRef.current = dockerConnectRetry;
|
||||
|
||||
const topMarginPx = isTopbarOpen ? 74 : 16;
|
||||
const leftMarginPx = 8;
|
||||
@@ -619,9 +436,9 @@ function DockerManagerInner({
|
||||
|
||||
const containerClass = embedded
|
||||
? "h-full w-full text-foreground overflow-hidden bg-transparent"
|
||||
: "bg-canvas text-foreground rounded-lg border-2 border-edge overflow-hidden";
|
||||
: "bg-canvas text-foreground border-2 border-edge overflow-hidden";
|
||||
|
||||
if (!currentHostConfig?.enableDocker) {
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden">
|
||||
@@ -649,6 +466,40 @@ function DockerManagerInner({
|
||||
);
|
||||
}
|
||||
|
||||
const dialogs = (
|
||||
<>
|
||||
<TOTPDialog
|
||||
isOpen={prompt?.kind === "totp"}
|
||||
prompt={prompt?.prompt ?? ""}
|
||||
mode={prompt?.isPassword ? "password" : "totp"}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handlePromptCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={prompt?.kind === "warpgate"}
|
||||
url={prompt?.url ?? ""}
|
||||
securityKey={prompt?.securityKey ?? ""}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handlePromptCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHost && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHost.ip,
|
||||
port: currentHost.port,
|
||||
username: currentHost.username ?? "",
|
||||
name: currentHost.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
if (isConnecting || isValidating) {
|
||||
return (
|
||||
<div style={wrapperStyle} className={`${containerClass} relative`}>
|
||||
@@ -662,6 +513,7 @@ function DockerManagerInner({
|
||||
nextRetryInMs={dockerConnectRetry.nextRetryInMs}
|
||||
onManualRetry={dockerConnectRetry.retryNow}
|
||||
/>
|
||||
{dialogs}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -689,12 +541,12 @@ function DockerManagerInner({
|
||||
{viewMode === "detail" &&
|
||||
sessionId &&
|
||||
selectedContainer &&
|
||||
currentHostConfig ? (
|
||||
currentHost ? (
|
||||
<ContainerDetail
|
||||
sessionId={sessionId}
|
||||
containerId={selectedContainer}
|
||||
containers={containers}
|
||||
hostConfig={currentHostConfig}
|
||||
hostConfig={currentHost}
|
||||
onBack={handleBack}
|
||||
initialTab={detailInitialTab}
|
||||
/>
|
||||
@@ -833,34 +685,7 @@ function DockerManagerInner({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<TOTPDialog
|
||||
isOpen={totpRequired}
|
||||
prompt={totpPrompt}
|
||||
onSubmit={handleTotpSubmit}
|
||||
onCancel={handleTotpCancel}
|
||||
/>
|
||||
<WarpgateDialog
|
||||
isOpen={warpgateRequired}
|
||||
url={warpgateUrl}
|
||||
securityKey={warpgateSecurityKey}
|
||||
onContinue={handleWarpgateContinue}
|
||||
onCancel={handleWarpgateCancel}
|
||||
onOpenUrl={handleWarpgateOpenUrl}
|
||||
/>
|
||||
{currentHostConfig && (
|
||||
<SSHAuthDialog
|
||||
isOpen={showAuthDialog}
|
||||
reason={authReason}
|
||||
onSubmit={handleAuthSubmit}
|
||||
onCancel={handleAuthCancel}
|
||||
hostInfo={{
|
||||
ip: currentHostConfig.ip,
|
||||
port: currentHostConfig.port,
|
||||
username: currentHostConfig.username,
|
||||
name: currentHostConfig.name,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{dialogs}
|
||||
<ConnectionScreen
|
||||
status={dockerConnectRetry.status}
|
||||
message={t("docker.connecting")}
|
||||
|
||||
@@ -1,44 +1,24 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { useHost, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
import { DockerWidgetEditForm } from "./DockerWidgetEditForm";
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetComponentProps,
|
||||
WidgetTypeDefinition,
|
||||
} from "@/types/homepage-types";
|
||||
import { GRID_SIZE } from "@/types/homepage-types";
|
||||
import { getSSHHosts } from "@/api/ssh-host-management-api";
|
||||
import type { SSHHostWithStatus } from "@/main-axios";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import type { SSHHost } from "@/types/index";
|
||||
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle";
|
||||
import {
|
||||
GRID_SIZE,
|
||||
type DockerWidgetConfig,
|
||||
type WidgetComponentProps,
|
||||
type WidgetDefinition,
|
||||
} from "./homepage";
|
||||
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
|
||||
|
||||
function DockerWidget({
|
||||
widget,
|
||||
config,
|
||||
}: WidgetComponentProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const [host, setHost] = useState<SSHHostWithStatus | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const record = useHost(config.hostId || undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!config.hostId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
getSSHHosts()
|
||||
.then((hosts) => {
|
||||
const found = hosts.find(
|
||||
(h) => h.id === config.hostId && h.enableDocker,
|
||||
);
|
||||
setHost(found ?? null);
|
||||
})
|
||||
.catch(() => {})
|
||||
.finally(() => setLoading(false));
|
||||
}, [config.hostId]);
|
||||
|
||||
if (!config.hostId) {
|
||||
if (!config.hostId || !record || !dockerEnabled(record)) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
|
||||
<Box size={20} />
|
||||
@@ -47,22 +27,7 @@ function DockerWidget({
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.loading")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!host) {
|
||||
return (
|
||||
<div className="flex items-center justify-center w-full h-full text-xs text-muted-foreground/60">
|
||||
{t("homepage.widgetNoHostSelected")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const host = toDockerHost(record as unknown as Record<string, unknown>);
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-hidden">
|
||||
<WidgetTitle title={widget.title} icon={<Box size={11} />} />
|
||||
@@ -71,8 +36,8 @@ function DockerWidget({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<DockerManager
|
||||
hostConfig={host as unknown as SSHHost}
|
||||
title={host.name || host.ip}
|
||||
host={host}
|
||||
title={hostTitle(host)}
|
||||
isVisible={true}
|
||||
isTopbarOpen={false}
|
||||
embedded={true}
|
||||
@@ -83,7 +48,7 @@ function DockerWidget({
|
||||
}
|
||||
|
||||
/** Registered by the plugin while it runs. */
|
||||
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = {
|
||||
export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
|
||||
id: "docker_widget",
|
||||
name: "Docker Manager",
|
||||
description: "Embedded Docker container manager for a configured host",
|
||||
|
||||
@@ -1,18 +1,37 @@
|
||||
import type {
|
||||
DockerWidgetConfig,
|
||||
WidgetEditFormProps,
|
||||
} from "@/types/homepage-types";
|
||||
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
|
||||
import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Select2 } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
|
||||
import { dockerEnabled } from "./types";
|
||||
|
||||
export function DockerWidgetEditForm({
|
||||
config,
|
||||
onChange,
|
||||
}: WidgetEditFormProps<DockerWidgetConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const options = hosts.filter(
|
||||
(host) => host.enableSsh !== false && dockerEnabled(host),
|
||||
);
|
||||
|
||||
return (
|
||||
<SingleHostEditForm
|
||||
hostId={config.hostId}
|
||||
onChange={(hostId) => onChange({ ...config, hostId })}
|
||||
filter={(h) => !!(h.enableSsh && h.enableDocker)}
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-medium text-muted-foreground">
|
||||
{t("homepage.host")}
|
||||
</label>
|
||||
<Select2
|
||||
value={config.hostId || ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...config, hostId: Number(e.target.value) })
|
||||
}
|
||||
className="h-8 text-xs border border-border bg-background px-2"
|
||||
>
|
||||
<option value="">{t("homepage.selectHost")}</option>
|
||||
{options.map((host) => (
|
||||
<option key={host.id} value={host.id}>
|
||||
{host.name || host.ip}
|
||||
</option>
|
||||
))}
|
||||
</Select2>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Box } from "lucide-react";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { SectionCard, SettingRow, FakeSwitch } from "@/components/section-card";
|
||||
import type { HostEditorForm } from "@/sidebar/HostEditorData";
|
||||
|
||||
type SetHostField = <K extends keyof HostEditorForm>(
|
||||
key: K,
|
||||
value: HostEditorForm[K],
|
||||
) => void;
|
||||
|
||||
export function HostDockerTab({
|
||||
form,
|
||||
setField,
|
||||
}: {
|
||||
form: HostEditorForm;
|
||||
setField: SetHostField;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const dockerConfig = form.dockerConfig ?? { runtime: "docker" as const };
|
||||
const runtime =
|
||||
dockerConfig.runtime === "podman"
|
||||
? ("podman" as const)
|
||||
: ("docker" as const);
|
||||
|
||||
return (
|
||||
<SectionCard
|
||||
title={t("hosts.dockerIntegration")}
|
||||
icon={<Box className="size-3.5" />}
|
||||
>
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
<SettingRow
|
||||
label={t("hosts.enableDockerMonitor")}
|
||||
description={t("hosts.enableDockerMonitorDesc")}
|
||||
>
|
||||
<FakeSwitch
|
||||
checked={form.enableDocker}
|
||||
onChange={(v) => setField("enableDocker", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
{form.enableDocker && (
|
||||
<SettingRow
|
||||
label={t("hosts.containerRuntime")}
|
||||
description={t("hosts.containerRuntimeDesc")}
|
||||
>
|
||||
<Select2
|
||||
value={runtime}
|
||||
onChange={(e) =>
|
||||
setField("dockerConfig", {
|
||||
...dockerConfig,
|
||||
runtime: e.target.value as "docker" | "podman",
|
||||
})
|
||||
}
|
||||
className="h-7 w-44 text-xs border border-border bg-background px-2 outline-none focus:ring-1 focus:ring-ring"
|
||||
>
|
||||
<option value="docker">
|
||||
{t("hosts.containerRuntimeDocker")}
|
||||
</option>
|
||||
<option value="podman">
|
||||
{t("hosts.containerRuntimePodman")}
|
||||
</option>
|
||||
</Select2>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
</SectionCard>
|
||||
);
|
||||
}
|
||||
@@ -1,39 +1,42 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardContent,
|
||||
ConnectionLogProvider,
|
||||
ConnectionScreen,
|
||||
DEFAULT_TERMINAL_CONFIG,
|
||||
RobustClipboardProvider,
|
||||
Select2,
|
||||
TERMINAL_FONTS,
|
||||
copyToClipboard,
|
||||
ensureTerminalFontsLoaded,
|
||||
isElectron,
|
||||
pluginWsUrl,
|
||||
readFromClipboard,
|
||||
resolveConnectionOrigin,
|
||||
resolveTermixThemeColors,
|
||||
useAppTheme as useTheme,
|
||||
useConnectionLog,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerHost } from "../types";
|
||||
import React from "react";
|
||||
import { useXTerm } from "react-xtermjs";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { ClipboardAddon } from "@xterm/addon-clipboard";
|
||||
import { RobustClipboardProvider } from "@/lib/clipboard-provider";
|
||||
import { copyToClipboard, readFromClipboard } from "@/lib/clipboard";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { Card, CardContent } from "@/components/card.tsx";
|
||||
import { resolveConnectionOrigin } from "@/lib/connection-origin.ts";
|
||||
import { pluginWsUrl } from "@/lib/plugin-transport";
|
||||
import { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { isElectron } from "@/main-axios.ts";
|
||||
import {
|
||||
useTranslation,
|
||||
useConnectionRetry,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { resolveTermixThemeColors } from "@/lib/terminal-look/terminal-theme";
|
||||
import { DEFAULT_TERMINAL_CONFIG, TERMINAL_FONTS } from "@/lib/terminal-themes";
|
||||
import { ensureTerminalFontsLoaded } from "@/lib/terminal-look/terminal-global-styles";
|
||||
import { useTheme } from "@/components/theme-provider";
|
||||
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
|
||||
import {
|
||||
ConnectionLogProvider,
|
||||
useConnectionLog,
|
||||
} from "@/ssh/connection-log/ConnectionLogContext.tsx";
|
||||
|
||||
interface ConsoleTerminalProps {
|
||||
containerId: string;
|
||||
containerName: string;
|
||||
containerState: string;
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
}
|
||||
|
||||
export function ConsoleTerminal(
|
||||
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
|
||||
const { addLog, clearLogs } = useConnectionLog();
|
||||
|
||||
const terminalConfig = React.useMemo(
|
||||
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }),
|
||||
() => ({
|
||||
...DEFAULT_TERMINAL_CONFIG,
|
||||
...(hostConfig.terminalConfig as Partial<
|
||||
typeof DEFAULT_TERMINAL_CONFIG
|
||||
> | null),
|
||||
}),
|
||||
[hostConfig.terminalConfig],
|
||||
);
|
||||
|
||||
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
|
||||
JSON.stringify({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig,
|
||||
hostConfig: {
|
||||
id: hostConfig.id,
|
||||
syncId: hostConfig.syncId ?? null,
|
||||
ip: hostConfig.ip,
|
||||
},
|
||||
containerId,
|
||||
shell: selectedShell,
|
||||
cols,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button, Card, useConfirmation } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import {
|
||||
Box,
|
||||
Play,
|
||||
@@ -14,16 +14,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import {
|
||||
startDockerContainer,
|
||||
stopDockerContainer,
|
||||
restartDockerContainer,
|
||||
pauseDockerContainer,
|
||||
unpauseDockerContainer,
|
||||
removeDockerContainer,
|
||||
} from "../docker-api";
|
||||
import { useConfirmation } from "@/hooks/use-confirmation.ts";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface ContainerCardProps {
|
||||
container: DockerContainer;
|
||||
@@ -61,6 +52,7 @@ export function ContainerCard({
|
||||
}: ContainerCardProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const { confirmWithToast } = useConfirmation();
|
||||
const docker = useDockerApi();
|
||||
const [isStarting, setIsStarting] = React.useState(false);
|
||||
const [isStopping, setIsStopping] = React.useState(false);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
@@ -82,7 +74,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStarting(true);
|
||||
try {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -100,7 +92,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsStopping(true);
|
||||
try {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -118,7 +110,7 @@ export function ContainerCard({
|
||||
e.stopPropagation();
|
||||
setIsRestarting(true);
|
||||
try {
|
||||
await restartDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "restart");
|
||||
toast.success(t("docker.containerRestarted", { name: containerName }));
|
||||
onRefresh?.();
|
||||
} catch (err) {
|
||||
@@ -137,10 +129,10 @@ export function ContainerCard({
|
||||
setIsPausing(true);
|
||||
try {
|
||||
if (container.state === "paused") {
|
||||
await unpauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "unpause");
|
||||
toast.success(t("docker.containerUnpaused", { name: containerName }));
|
||||
} else {
|
||||
await pauseDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "pause");
|
||||
toast.success(t("docker.containerPaused", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
@@ -166,7 +158,7 @@ export function ContainerCard({
|
||||
async () => {
|
||||
setIsRemoving(true);
|
||||
try {
|
||||
await removeDockerContainer(
|
||||
await docker.removeContainer(
|
||||
sessionId,
|
||||
container.id,
|
||||
container.state === "running",
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Button, Card, Separator } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer, DockerHost } from "../types";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Card } from "@/components/card.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import {
|
||||
Activity,
|
||||
ArrowLeft,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
Terminal,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer, SSHHost } from "@/types";
|
||||
import { LogViewer } from "./LogViewer.tsx";
|
||||
import { ContainerStats } from "./ContainerStats.tsx";
|
||||
import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
|
||||
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
containers: DockerContainer[];
|
||||
hostConfig: SSHHost;
|
||||
hostConfig: DockerHost;
|
||||
onBack: () => void;
|
||||
initialTab?: DetailTab;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { ContainerCard } from "./ContainerCard.tsx";
|
||||
|
||||
interface ContainerListProps {
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { SectionCard, useAdaptivePolling } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerStats } from "../types";
|
||||
import React from "react";
|
||||
import {
|
||||
Activity,
|
||||
@@ -9,12 +11,9 @@ import {
|
||||
Network,
|
||||
RefreshCw,
|
||||
} from "lucide-react";
|
||||
import type { DockerStats } from "@/types";
|
||||
import { getContainerStats } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { SectionCard } from "@/components/section-card";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
|
||||
interface ContainerStatsProps {
|
||||
sessionId: string;
|
||||
@@ -30,6 +29,7 @@ export function ContainerStats({
|
||||
containerState,
|
||||
}: ContainerStatsProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [stats, setStats] = React.useState<DockerStats | null>(null);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -41,7 +41,7 @@ export function ContainerStats({
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getContainerStats(sessionId, containerId);
|
||||
const data = await docker.stats(sessionId, containerId);
|
||||
const previous = statsRef.current;
|
||||
const changed =
|
||||
!previous ||
|
||||
@@ -58,7 +58,7 @@ export function ContainerStats({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, containerState, t]);
|
||||
}, [sessionId, containerId, containerState, t, docker]);
|
||||
|
||||
useAdaptivePolling(
|
||||
fetchStats,
|
||||
@@ -246,7 +246,7 @@ export function ContainerStats({
|
||||
</span>
|
||||
<DockerBadge
|
||||
state={
|
||||
containerState as import("@/types").DockerContainer["state"]
|
||||
containerState as import("../types").DockerContainer["state"]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
import type { DockerContainer } from "../types";
|
||||
import React from "react";
|
||||
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import type { DockerContainer } from "@/types";
|
||||
import { startDockerContainer, stopDockerContainer } from "../docker-api";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
import { DockerBadge } from "./ContainerCard.tsx";
|
||||
|
||||
type DetailTab = "logs" | "stats" | "console";
|
||||
@@ -30,6 +30,7 @@ export function ContainerTable({
|
||||
statusFilter = "all",
|
||||
}: ContainerTableProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [pendingId, setPendingId] = React.useState<string | null>(null);
|
||||
|
||||
const filtered = React.useMemo(() => {
|
||||
@@ -56,10 +57,10 @@ export function ContainerTable({
|
||||
setPendingId(container.id);
|
||||
try {
|
||||
if (container.state === "running") {
|
||||
await stopDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "stop");
|
||||
toast.success(t("docker.containerStopped", { name: containerName }));
|
||||
} else {
|
||||
await startDockerContainer(sessionId, container.id);
|
||||
await docker.containerAction(sessionId, container.id, "start");
|
||||
toast.success(t("docker.containerStarted", { name: containerName }));
|
||||
}
|
||||
onRefresh?.();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { getErrorMessage } from "@/lib/error-message.js";
|
||||
import { getErrorMessage } from "../error-message";
|
||||
import {
|
||||
Button,
|
||||
Input,
|
||||
Select2,
|
||||
Separator,
|
||||
useAdaptivePolling,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import type { DockerLogOptions } from "../types";
|
||||
import React from "react";
|
||||
import { Button } from "@/components/button.tsx";
|
||||
import { Input } from "@/components/input.tsx";
|
||||
import { Separator } from "@/components/separator.tsx";
|
||||
import { Download, RefreshCw, Search, Trash2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import type { DockerLogOptions } from "@/types";
|
||||
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
|
||||
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
|
||||
import { Select2 } from "@/components/select2";
|
||||
import { useDockerApi } from "../docker-api";
|
||||
|
||||
interface LogViewerProps {
|
||||
sessionId: string;
|
||||
@@ -60,6 +62,7 @@ export function LogViewer({
|
||||
containerName,
|
||||
}: LogViewerProps): React.ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const docker = useDockerApi();
|
||||
const [rawLogs, setRawLogs] = React.useState<string[]>([]);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const [isDownloading, setIsDownloading] = React.useState(false);
|
||||
@@ -78,7 +81,7 @@ export function LogViewer({
|
||||
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
|
||||
timestamps: showTimestamps,
|
||||
};
|
||||
const data = await getContainerLogs(sessionId, containerId, options);
|
||||
const data = await docker.logs(sessionId, containerId, options);
|
||||
const next = data.logs.split("\n").filter(Boolean);
|
||||
const changed =
|
||||
next.length !== rawLogsRef.current.length ||
|
||||
@@ -89,11 +92,13 @@ export function LogViewer({
|
||||
}
|
||||
return changed;
|
||||
} catch (error) {
|
||||
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [sessionId, containerId, tailLines, showTimestamps]);
|
||||
}, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
fetchLogs();
|
||||
@@ -120,9 +125,10 @@ export function LogViewer({
|
||||
const handleDownload = async () => {
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
const blob = await downloadContainerLogs(sessionId, containerId, {
|
||||
const data = await docker.logs(sessionId, containerId, {
|
||||
timestamps: showTimestamps,
|
||||
});
|
||||
const blob = new Blob([data.logs], { type: "text/plain" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
@@ -133,7 +139,9 @@ export function LogViewer({
|
||||
document.body.removeChild(a);
|
||||
toast.success(t("docker.logsDownloaded"));
|
||||
} catch (error) {
|
||||
toast.error(`Failed to download logs: ${getErrorMessage(error)}`);
|
||||
toast.error(
|
||||
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
|
||||
@@ -1,345 +1,170 @@
|
||||
import axios from "axios";
|
||||
import { dockerApi, handleApiError } from "@/main-axios";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
usePluginApi,
|
||||
type PluginApiClient,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
import type {
|
||||
DockerContainer,
|
||||
DockerLogOptions,
|
||||
DockerStats,
|
||||
DockerValidation,
|
||||
} from "@/types/index";
|
||||
} from "./types";
|
||||
|
||||
type ApiConnectionLog = {
|
||||
export interface ApiConnectionLog {
|
||||
type: "info" | "success" | "warning" | "error";
|
||||
stage: string;
|
||||
stage: ConnectionStage;
|
||||
message: string;
|
||||
details?: Record<string, unknown>;
|
||||
};
|
||||
details?: string;
|
||||
}
|
||||
|
||||
type ConnectErrorResponse = {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
requires_totp?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
sessionId?: string;
|
||||
prompt?: string;
|
||||
url?: string;
|
||||
securityKey?: string;
|
||||
/** One step of a connect: connected, a prompt to answer, or auth needed. */
|
||||
export interface ConnectResult {
|
||||
success?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export async function connectDockerSession(
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
config?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
forceKeyboardInteractive?: boolean;
|
||||
useSocks5?: boolean;
|
||||
socks5Host?: string;
|
||||
socks5Port?: number;
|
||||
socks5Username?: string;
|
||||
socks5Password?: string;
|
||||
socks5ProxyChain?: unknown;
|
||||
},
|
||||
): Promise<{
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
requires_totp?: boolean;
|
||||
prompt?: string;
|
||||
isPassword?: boolean;
|
||||
status?: string;
|
||||
reason?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
retry?: boolean;
|
||||
requires_warpgate?: boolean;
|
||||
url?: string;
|
||||
url?: string | null;
|
||||
securityKey?: string;
|
||||
}> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect", {
|
||||
sessionId,
|
||||
hostId,
|
||||
...config,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error: unknown) {
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.status === "auth_required"
|
||||
) {
|
||||
return error.response.data;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
}
|
||||
|
||||
/** An API failure carrying the server's message and connection logs. */
|
||||
export class DockerApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
readonly connectionLogs?: ApiConnectionLog[],
|
||||
) {
|
||||
super(message);
|
||||
this.name = "DockerApiError";
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(error: unknown, action: string): DockerApiError {
|
||||
const response = (
|
||||
error as {
|
||||
response?: {
|
||||
status?: number;
|
||||
data?: {
|
||||
error?: string;
|
||||
message?: string;
|
||||
connectionLogs?: ApiConnectionLog[];
|
||||
};
|
||||
};
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_totp
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.requires_warpgate
|
||||
) {
|
||||
return error.response.data;
|
||||
}
|
||||
if (
|
||||
axios.isAxiosError<ConnectErrorResponse>(error) &&
|
||||
error.response?.data?.connectionLogs
|
||||
) {
|
||||
const data = error.response.data;
|
||||
const errorWithLogs = new Error(
|
||||
data.error || data.message || error.message,
|
||||
);
|
||||
Object.assign(errorWithLogs, {
|
||||
connectionLogs: data.connectionLogs,
|
||||
});
|
||||
throw errorWithLogs;
|
||||
}
|
||||
throw handleApiError(error, "connect to Docker SSH session");
|
||||
}
|
||||
)?.response;
|
||||
const data = response?.data;
|
||||
const message =
|
||||
data?.error ||
|
||||
data?.message ||
|
||||
(error instanceof Error ? error.message : `Failed to ${action}`);
|
||||
return new DockerApiError(message, response?.status, data?.connectionLogs);
|
||||
}
|
||||
|
||||
export async function verifyDockerTOTP(
|
||||
sessionId: string,
|
||||
totpCode: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
async function call<T>(
|
||||
action: string,
|
||||
request: () => Promise<{ data: T }>,
|
||||
): Promise<T> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-totp", {
|
||||
sessionId,
|
||||
totpCode,
|
||||
});
|
||||
return response.data;
|
||||
return (await request()).data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker TOTP");
|
||||
throw apiError(error, action);
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyDockerWarpgate(
|
||||
sessionId: string,
|
||||
): Promise<{ status: string; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/connect-warpgate", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "verify Docker Warpgate");
|
||||
}
|
||||
}
|
||||
export type ContainerAction =
|
||||
"start" | "stop" | "restart" | "pause" | "unpause";
|
||||
|
||||
export async function disconnectDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/disconnect", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "disconnect from Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function keepaliveDockerSession(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.post("/ssh/keepalive", {
|
||||
sessionId,
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "keepalive Docker SSH session");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerSessionStatus(
|
||||
sessionId: string,
|
||||
): Promise<{ success: boolean; connected: boolean }> {
|
||||
try {
|
||||
const response = await dockerApi.get("/ssh/status", {
|
||||
params: { sessionId },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker session status");
|
||||
}
|
||||
}
|
||||
|
||||
export async function validateDockerAvailability(
|
||||
sessionId: string,
|
||||
): Promise<DockerValidation> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/validate/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "validate Docker availability");
|
||||
}
|
||||
}
|
||||
|
||||
export async function listDockerContainers(
|
||||
sessionId: string,
|
||||
all: boolean = true,
|
||||
): Promise<DockerContainer[]> {
|
||||
try {
|
||||
const response = await dockerApi.get(`/containers/${sessionId}`, {
|
||||
params: { all },
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "list Docker containers");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getDockerContainerDetails(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerContainer> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get Docker container details");
|
||||
}
|
||||
}
|
||||
|
||||
export async function startDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/start`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "start Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/stop`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "stop Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/restart`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "restart Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function pauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/pause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "pause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function unpauseDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.post(
|
||||
`/containers/${sessionId}/${containerId}/unpause`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "unpause Docker container");
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeDockerContainer(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
force: boolean = false,
|
||||
): Promise<{ success: boolean; message: string }> {
|
||||
try {
|
||||
const response = await dockerApi.delete(
|
||||
`/containers/${sessionId}/${containerId}/remove`,
|
||||
{
|
||||
params: { force },
|
||||
export function createDockerApi(api: PluginApiClient) {
|
||||
return {
|
||||
connect: (
|
||||
sessionId: string,
|
||||
hostId: number,
|
||||
credentials?: {
|
||||
userProvidedPassword?: string;
|
||||
userProvidedSshKey?: string;
|
||||
userProvidedKeyPassword?: string;
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "remove Docker container");
|
||||
}
|
||||
) =>
|
||||
call<ConnectResult>("connect to Docker", () =>
|
||||
api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
|
||||
),
|
||||
|
||||
answerTotp: (sessionId: string, totpCode: string) =>
|
||||
call<ConnectResult>("verify the code", () =>
|
||||
api.post("/ssh/connect-totp", { sessionId, totpCode }),
|
||||
),
|
||||
|
||||
continueWarpgate: (sessionId: string) =>
|
||||
call<ConnectResult>("complete Warpgate authentication", () =>
|
||||
api.post("/ssh/connect-warpgate", { sessionId }),
|
||||
),
|
||||
|
||||
disconnect: (sessionId: string) =>
|
||||
call("disconnect from Docker", () =>
|
||||
api.post("/ssh/disconnect", { sessionId }),
|
||||
),
|
||||
|
||||
keepalive: (sessionId: string) =>
|
||||
call("keep the Docker session alive", () =>
|
||||
api.post("/ssh/keepalive", { sessionId }),
|
||||
),
|
||||
|
||||
validate: (sessionId: string) =>
|
||||
call<DockerValidation>("validate Docker", () =>
|
||||
api.get(`/validate/${sessionId}`),
|
||||
),
|
||||
|
||||
listContainers: (sessionId: string, all = true) =>
|
||||
call<DockerContainer[]>("list containers", () =>
|
||||
api.get(`/containers/${sessionId}`, { params: { all } }),
|
||||
),
|
||||
|
||||
containerAction: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
action: ContainerAction,
|
||||
) =>
|
||||
call<{ success: boolean; message: string }>(
|
||||
`${action} the container`,
|
||||
() => api.post(`/containers/${sessionId}/${containerId}/${action}`),
|
||||
),
|
||||
|
||||
removeContainer: (sessionId: string, containerId: string, force = false) =>
|
||||
call<{ success: boolean; message: string }>("remove the container", () =>
|
||||
api.delete(`/containers/${sessionId}/${containerId}/remove`, {
|
||||
params: { force },
|
||||
}),
|
||||
),
|
||||
|
||||
logs: (
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
) =>
|
||||
call<{ logs: string }>("load container logs", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/logs`, {
|
||||
params: options,
|
||||
}),
|
||||
),
|
||||
|
||||
stats: (sessionId: string, containerId: string) =>
|
||||
call<DockerStats>("load container stats", () =>
|
||||
api.get(`/containers/${sessionId}/${containerId}/stats`),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<{ logs: string }> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: options,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container logs");
|
||||
}
|
||||
}
|
||||
export type DockerApi = ReturnType<typeof createDockerApi>;
|
||||
|
||||
export async function downloadContainerLogs(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
options?: DockerLogOptions,
|
||||
): Promise<Blob> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/logs`,
|
||||
{
|
||||
params: { ...options, download: true },
|
||||
responseType: "blob",
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "download container logs");
|
||||
}
|
||||
}
|
||||
|
||||
export async function getContainerStats(
|
||||
sessionId: string,
|
||||
containerId: string,
|
||||
): Promise<DockerStats> {
|
||||
try {
|
||||
const response = await dockerApi.get(
|
||||
`/containers/${sessionId}/${containerId}/stats`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
throw handleApiError(error, "get container stats");
|
||||
}
|
||||
/** The Docker API on this plugin's own client. */
|
||||
export function useDockerApi(): DockerApi {
|
||||
const api = usePluginApi();
|
||||
return useMemo(() => createDockerApi(api), [api]);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export function getErrorMessage(
|
||||
error: unknown,
|
||||
fallback = "Unknown error",
|
||||
): string {
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** The homepage's grid step, in pixels. */
|
||||
export const GRID_SIZE = 30;
|
||||
|
||||
export interface DockerWidgetConfig {
|
||||
hostId: number;
|
||||
}
|
||||
|
||||
/** What the homepage hands a widget; only the fields this plugin reads. */
|
||||
export interface WidgetComponentProps<C> {
|
||||
widget: { title?: string | null };
|
||||
config: C;
|
||||
}
|
||||
|
||||
export interface WidgetEditFormProps<C> {
|
||||
config: C;
|
||||
onChange: (config: C) => void;
|
||||
}
|
||||
|
||||
export type WidgetDefinition<C> = Omit<
|
||||
HomepageWidgetContribution<C>,
|
||||
"defaultConfig"
|
||||
> & { defaultConfig: C };
|
||||
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import type {
|
||||
HomepageWidgetContribution,
|
||||
HostEditorSectionProps,
|
||||
StandaloneViewProps,
|
||||
TabProps,
|
||||
TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import type { SSHHost } from "@/types";
|
||||
import { DockerManager } from "./DockerManager";
|
||||
import DockerApp from "./DockerApp";
|
||||
import { HostDockerTab } from "./HostDockerTab";
|
||||
import { dockerWidget } from "./DockerWidget";
|
||||
import { dockerEnabled, toDockerHost } from "./types";
|
||||
|
||||
function DockerTab({ sshHost, label, isVisible }: TabProps) {
|
||||
function DockerTab({ host, sshHost, label, isVisible }: TabProps) {
|
||||
const record = (host ?? sshHost) as Record<string, unknown> | undefined;
|
||||
return (
|
||||
<DockerManager
|
||||
hostConfig={sshHost as unknown as SSHHost}
|
||||
host={record ? toDockerHost(record) : undefined}
|
||||
title={label}
|
||||
isVisible={isVisible}
|
||||
isTopbarOpen={false}
|
||||
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
|
||||
return <DockerApp hostId={hostId} />;
|
||||
}
|
||||
|
||||
function DockerHostSection({ form, setField }: HostEditorSectionProps) {
|
||||
return (
|
||||
<HostDockerTab
|
||||
form={form}
|
||||
setField={setField as Parameters<typeof HostDockerTab>[0]["setField"]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
// Host actions filter synchronously, so the permission is read once here.
|
||||
let canUse = true;
|
||||
void app.hasPermission("use").then((allowed) => {
|
||||
canUse = allowed;
|
||||
});
|
||||
|
||||
app.registerTab("docker", DockerTab, {
|
||||
icon: Box,
|
||||
titleKey: "nav.docker",
|
||||
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
|
||||
order: 30,
|
||||
tabType: "docker",
|
||||
copyUrlView: "docker",
|
||||
when: (host) => !!host.enableSsh && !!host.enableDocker,
|
||||
});
|
||||
|
||||
app.registerHostEditorSection({
|
||||
id: "docker",
|
||||
group: "ssh",
|
||||
titleKey: "hosts.tabDocker",
|
||||
icon: Box,
|
||||
order: 30,
|
||||
component: DockerHostSection,
|
||||
when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
|
||||
});
|
||||
|
||||
app.registerHomepageWidget(
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
export interface DockerContainer {
|
||||
id: string;
|
||||
name: string;
|
||||
image: string;
|
||||
status: string;
|
||||
state:
|
||||
| "created"
|
||||
| "running"
|
||||
| "paused"
|
||||
| "restarting"
|
||||
| "removing"
|
||||
| "exited"
|
||||
| "dead";
|
||||
ports: string;
|
||||
created: string;
|
||||
command?: string;
|
||||
labels?: Record<string, string>;
|
||||
networks?: string[];
|
||||
mounts?: string[];
|
||||
}
|
||||
|
||||
export interface DockerStats {
|
||||
cpu: string;
|
||||
memoryUsed: string;
|
||||
memoryLimit: string;
|
||||
memoryPercent: string;
|
||||
netInput: string;
|
||||
netOutput: string;
|
||||
blockRead: string;
|
||||
blockWrite: string;
|
||||
pids?: string;
|
||||
}
|
||||
|
||||
export interface DockerLogOptions {
|
||||
tail?: number;
|
||||
timestamps?: boolean;
|
||||
since?: string;
|
||||
until?: string;
|
||||
follow?: boolean;
|
||||
}
|
||||
|
||||
export interface DockerValidation {
|
||||
available: boolean;
|
||||
version?: string;
|
||||
runtime?: "docker" | "podman";
|
||||
error?: string;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
/** What the Docker views read from a host. */
|
||||
export interface DockerHost {
|
||||
id: number;
|
||||
name?: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username?: string;
|
||||
syncId?: string | null;
|
||||
connectionOrigin?: "local" | "remote" | null;
|
||||
terminalConfig?: Record<string, unknown> | null;
|
||||
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||
}
|
||||
|
||||
/** Any host record the shell or an API hands over, as a DockerHost. */
|
||||
export function toDockerHost(record: Record<string, unknown>): DockerHost {
|
||||
return {
|
||||
...(record as unknown as DockerHost),
|
||||
id: Number(record.id),
|
||||
};
|
||||
}
|
||||
|
||||
function dockerSettings(
|
||||
host: object | null | undefined,
|
||||
): Record<string, unknown> {
|
||||
const all = (host as { pluginSettings?: unknown } | null | undefined)
|
||||
?.pluginSettings as
|
||||
Record<string, Record<string, unknown> | undefined> | undefined;
|
||||
return all?.docker ?? {};
|
||||
}
|
||||
|
||||
export function dockerEnabled(host: object | null | undefined): boolean {
|
||||
return dockerSettings(host).enableDocker === true;
|
||||
}
|
||||
|
||||
export function hostTitle(host: DockerHost): string {
|
||||
return host.name || `${host.username ?? ""}@${host.ip}`;
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import express from "express";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { manifest, sshHost } from "./server";
|
||||
import { FakeClient, FakeStream } from "./fake-ssh";
|
||||
|
||||
/** A ws WebSocket as far as the console uses one. */
|
||||
class FakeSocket extends EventEmitter {
|
||||
readyState = 1;
|
||||
sent: Array<Record<string, unknown>> = [];
|
||||
closedWith: [number, string] | null = null;
|
||||
|
||||
send(data: string) {
|
||||
this.sent.push(JSON.parse(data));
|
||||
}
|
||||
|
||||
ping() {}
|
||||
|
||||
close(code: number, reason: string) {
|
||||
if (this.readyState !== 1) return;
|
||||
this.readyState = 3;
|
||||
this.closedWith = [code, reason];
|
||||
this.emit("close");
|
||||
}
|
||||
|
||||
message(payload: unknown) {
|
||||
this.emit("message", Buffer.from(JSON.stringify(payload)));
|
||||
}
|
||||
}
|
||||
|
||||
function consoleClient() {
|
||||
const shells: FakeStream[] = [];
|
||||
const client = new FakeClient([
|
||||
[/which bash/, { stdout: "/bin/bash\n" }],
|
||||
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
|
||||
]);
|
||||
return { client, shells };
|
||||
}
|
||||
|
||||
async function activated(client: FakeClient): Promise<MockPluginContext> {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => express.Router(),
|
||||
permissions: ["docker.use"],
|
||||
sshHosts: [sshHost(7)],
|
||||
sshClient: client,
|
||||
});
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
// Like the runtime, disposing a connection ends its client.
|
||||
const connect = mock.ctx.ssh.connect;
|
||||
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
|
||||
...(await connect(...args)),
|
||||
dispose: () => client.end(),
|
||||
})) as typeof connect;
|
||||
await activate(mock.ctx);
|
||||
return mock;
|
||||
}
|
||||
|
||||
async function openConsole(mock: MockPluginContext, userId = "user-1") {
|
||||
const route = mock.wsRoutes.find((r) => r.path === "/console");
|
||||
expect(route?.options).toEqual({ public: true, optionalAuth: true });
|
||||
const socket = new FakeSocket();
|
||||
mock.setActor(userId || undefined);
|
||||
await route!.handler!({
|
||||
userId,
|
||||
request: {},
|
||||
socket,
|
||||
clientIp: "127.0.0.1",
|
||||
requestOrigin: "http://localhost",
|
||||
isDataUnlocked: () => true,
|
||||
} as PluginWebSocketConnection);
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function until(check: () => boolean) {
|
||||
for (let i = 0; i < 50 && !check(); i++) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
expect(check()).toBe(true);
|
||||
}
|
||||
|
||||
let disposals: Array<() => void | Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dispose of disposals.reverse()) await dispose();
|
||||
disposals = [];
|
||||
});
|
||||
|
||||
describe("docker console", () => {
|
||||
it("refuses a socket without a user", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock, "");
|
||||
expect(socket.closedWith?.[0]).toBe(1008);
|
||||
});
|
||||
|
||||
it("closes an open console on disable, and the next enable works", async () => {
|
||||
const first = consoleClient();
|
||||
const mock = await activated(first.client);
|
||||
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: {
|
||||
hostConfig: { id: 7, ip: "10.0.0.7" },
|
||||
containerId: "abc123",
|
||||
shell: "bash",
|
||||
cols: 100,
|
||||
rows: 30,
|
||||
},
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "connected"));
|
||||
socket.message({ type: "input", data: "ls\n" });
|
||||
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
|
||||
|
||||
// Disable: the runtime empties the disposable bag.
|
||||
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||
expect(first.shells[0].ended).toBe(true);
|
||||
expect(first.client.ended).toBe(true);
|
||||
expect(socket.closedWith?.[0]).toBe(1001);
|
||||
|
||||
// Enable again: a fresh activate on the same module serves a new console.
|
||||
const second = consoleClient();
|
||||
const again = await activated(second.client);
|
||||
disposals.push(...again.disposals);
|
||||
const next = await openConsole(again);
|
||||
next.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => next.sent.some((m) => m.type === "connected"));
|
||||
second.shells[0].emit("data", Buffer.from("hello"));
|
||||
await until(() =>
|
||||
next.sent.some((m) => m.type === "output" && m.data === "hello"),
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses a host id that resolves to another machine", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
const mock = await activated(consoleClient().client);
|
||||
disposals.push(...mock.disposals);
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", false);
|
||||
const socket = await openConsole(mock);
|
||||
socket.message({
|
||||
type: "connect",
|
||||
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||
});
|
||||
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
|
||||
export class FakeStream extends EventEmitter {
|
||||
stderr = new EventEmitter();
|
||||
written: string[] = [];
|
||||
ended = false;
|
||||
window: [number, number] | null = null;
|
||||
|
||||
write(data: string) {
|
||||
this.written.push(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
end() {
|
||||
this.ended = true;
|
||||
}
|
||||
|
||||
setWindow(rows: number, cols: number) {
|
||||
this.window = [rows, cols];
|
||||
}
|
||||
|
||||
/** Emits output and exits, on the next tick like a real channel. */
|
||||
finish(stdout: string, code = 0, stderr = "") {
|
||||
setImmediate(() => {
|
||||
if (stdout) this.emit("data", Buffer.from(stdout));
|
||||
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
|
||||
this.emit("close", code);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export type ExecReply =
|
||||
| { stdout?: string; stderr?: string; code?: number }
|
||||
| ((stream: FakeStream) => void);
|
||||
|
||||
/**
|
||||
* An ssh2 Client that answers exec by the first matching pattern. A reply
|
||||
* may be a function for a long-lived channel such as a console.
|
||||
*/
|
||||
export class FakeClient extends EventEmitter {
|
||||
commands: string[] = [];
|
||||
streams: FakeStream[] = [];
|
||||
ended = false;
|
||||
|
||||
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
|
||||
super();
|
||||
}
|
||||
|
||||
reply(pattern: RegExp, reply: ExecReply) {
|
||||
this.replies.unshift([pattern, reply]);
|
||||
}
|
||||
|
||||
exec(
|
||||
command: string,
|
||||
optionsOrCallback: unknown,
|
||||
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
|
||||
) {
|
||||
const callback = (
|
||||
typeof optionsOrCallback === "function"
|
||||
? optionsOrCallback
|
||||
: maybeCallback
|
||||
) as (err: Error | undefined, stream: FakeStream) => void;
|
||||
this.commands.push(command);
|
||||
const stream = new FakeStream();
|
||||
this.streams.push(stream);
|
||||
const match = this.replies.find(([pattern]) => pattern.test(command));
|
||||
callback(undefined, stream);
|
||||
if (!match) {
|
||||
stream.finish("");
|
||||
return this;
|
||||
}
|
||||
const reply = match[1];
|
||||
if (typeof reply === "function") reply(stream);
|
||||
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
|
||||
return this;
|
||||
}
|
||||
|
||||
end() {
|
||||
if (this.ended) return;
|
||||
this.ended = true;
|
||||
this.emit("close");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import express from "express";
|
||||
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import {
|
||||
normalizeImportedHost,
|
||||
readDockerHostSettings,
|
||||
} from "../../src/backend/host-settings.js";
|
||||
import {
|
||||
diffContainerStates,
|
||||
parseContainerStates,
|
||||
} from "../../src/backend/container-state.js";
|
||||
import { manifest, startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
describe("docker activate", () => {
|
||||
it("fails closed without network:serve", async () => {
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "network:serve",
|
||||
),
|
||||
router: () => express.Router(),
|
||||
});
|
||||
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||
PluginCapabilityError,
|
||||
);
|
||||
});
|
||||
|
||||
it("cannot reach a host without ssh:connect", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
capabilities: manifest.capabilities.filter(
|
||||
(cap) => cap !== "ssh:connect",
|
||||
),
|
||||
},
|
||||
});
|
||||
const response = await server.request("POST", "/ssh/connect", {
|
||||
body: { sessionId: "s1", hostId: 7 },
|
||||
});
|
||||
expect(response.status).toBe(500);
|
||||
});
|
||||
|
||||
it("registers its import normalizer", async () => {
|
||||
server = await startServer();
|
||||
expect(
|
||||
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
|
||||
).toBe(normalizeImportedHost);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker host settings", () => {
|
||||
it("reads the switch and runtime with defaults", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: false,
|
||||
runtime: "docker",
|
||||
});
|
||||
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||
enabled: true,
|
||||
runtime: "podman",
|
||||
});
|
||||
});
|
||||
|
||||
it("takes an export's nested settings over the old flat fields", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: false,
|
||||
pluginSettings: {
|
||||
docker: { enableDocker: true, containerRuntime: "podman" },
|
||||
},
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
});
|
||||
|
||||
it("reads 2.8 flat fields and proxmox-created rows", () => {
|
||||
expect(
|
||||
normalizeImportedHost({
|
||||
enableDocker: 1,
|
||||
dockerConfig: '{"runtime":"podman"}',
|
||||
}),
|
||||
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
|
||||
enableDocker: true,
|
||||
});
|
||||
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The polling side needs SSH, so what is tested here is the pure part:
|
||||
* turning `ps` output into states, and two snapshots into events.
|
||||
*/
|
||||
describe("container state", () => {
|
||||
it("reads name, state and health out of the ps output", () => {
|
||||
const states = parseContainerStates(
|
||||
[
|
||||
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||
"",
|
||||
"not json at all",
|
||||
'{"state":"running"}',
|
||||
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||
].join("\n"),
|
||||
);
|
||||
expect([...states.keys()]).toEqual(["web", "api"]);
|
||||
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||
});
|
||||
|
||||
it("reports transitions and skips containers seen for the first time", () => {
|
||||
const running = { state: "running", unhealthy: false };
|
||||
const exited = { state: "exited", unhealthy: false };
|
||||
const restarting = { state: "restarting", unhealthy: false };
|
||||
expect(
|
||||
diffContainerStates(
|
||||
new Map([
|
||||
["web", running],
|
||||
["db", exited],
|
||||
["cache", running],
|
||||
]),
|
||||
new Map([
|
||||
["web", exited],
|
||||
["db", running],
|
||||
["cache", restarting],
|
||||
["new", running],
|
||||
]),
|
||||
),
|
||||
).toEqual([
|
||||
{ container: "web", event: "exited" },
|
||||
{ container: "db", event: "started" },
|
||||
{ container: "cache", event: "restarting" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type {
|
||||
PluginSshConnectOptions,
|
||||
PluginSshHost,
|
||||
} from "@termix/plugin-sdk/backend";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
|
||||
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
|
||||
}
|
||||
|
||||
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
|
||||
function promptingConnect(
|
||||
s: TestServer,
|
||||
flow: (
|
||||
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
|
||||
) => Promise<void>,
|
||||
) {
|
||||
s.mock.ctx.ssh.connect = (async (
|
||||
host: PluginSshHost,
|
||||
options?: PluginSshConnectOptions,
|
||||
) => {
|
||||
await flow(options!.prompt!.ask);
|
||||
return {
|
||||
client: s.client as never,
|
||||
jumpClient: null,
|
||||
host,
|
||||
dispose: () => s.client.end(),
|
||||
};
|
||||
}) as never;
|
||||
}
|
||||
|
||||
describe("docker routes", () => {
|
||||
it("refuses every route without docker.use", async () => {
|
||||
server = await startServer({ permissions: [] });
|
||||
const calls: Array<[string, string]> = [
|
||||
["POST", "/ssh/connect"],
|
||||
["POST", "/ssh/connect-totp"],
|
||||
["POST", "/ssh/connect-warpgate"],
|
||||
["POST", "/ssh/disconnect"],
|
||||
["POST", "/ssh/keepalive"],
|
||||
["GET", "/ssh/status?sessionId=s1"],
|
||||
["GET", "/validate/s1"],
|
||||
["GET", "/containers/s1"],
|
||||
["GET", "/containers/s1/abc"],
|
||||
["POST", "/containers/s1/abc/start"],
|
||||
["DELETE", "/containers/s1/abc/remove"],
|
||||
["GET", "/containers/s1/abc/logs"],
|
||||
["GET", "/containers/s1/abc/stats"],
|
||||
];
|
||||
for (const [method, path] of calls) {
|
||||
const response = await server.request(method, path, {
|
||||
body: method === "GET" ? undefined : {},
|
||||
});
|
||||
expect(response.status, `${method} ${path}`).toBe(403);
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a host with Docker switched off", async () => {
|
||||
server = await startServer({ dockerOn: false });
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(403);
|
||||
expect(response.body.code).toBe("DOCKER_DISABLED");
|
||||
});
|
||||
|
||||
it("answers 404 for a host the user cannot reach", async () => {
|
||||
server = await startServer();
|
||||
expect((await connect(server, "s1", 99)).status).toBe(404);
|
||||
});
|
||||
|
||||
it("connects, validates and manages containers on the session", async () => {
|
||||
server = await startServer();
|
||||
const connected = await connect(server);
|
||||
expect(connected.status).toBe(200);
|
||||
expect(connected.body.success).toBe(true);
|
||||
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
|
||||
|
||||
const validation = await server.request("GET", "/validate/s1");
|
||||
expect(validation.body).toEqual({
|
||||
available: true,
|
||||
version: "27.1.1",
|
||||
runtime: "docker",
|
||||
});
|
||||
|
||||
const list = await server.request("GET", "/containers/s1");
|
||||
expect(list.body).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
|
||||
]);
|
||||
|
||||
server.client.reply(/ start abc123$/, { stdout: "abc123" });
|
||||
const started = await server.request("POST", "/containers/s1/abc123/start");
|
||||
expect(started.status).toBe(200);
|
||||
expect(
|
||||
server.client.commands.some((c) => c.endsWith("docker start abc123")),
|
||||
).toBe(true);
|
||||
|
||||
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
|
||||
const logs = await server.request(
|
||||
"GET",
|
||||
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
|
||||
);
|
||||
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
|
||||
expect(server.client.commands.at(-1)).toContain(
|
||||
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
|
||||
);
|
||||
|
||||
server.client.reply(/ stats abc123/, {
|
||||
stdout:
|
||||
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||
});
|
||||
const stats = await server.request("GET", "/containers/s1/abc123/stats");
|
||||
expect(stats.body).toMatchObject({
|
||||
cpu: "1.5%",
|
||||
memoryUsed: "10MiB",
|
||||
memoryLimit: "1GiB",
|
||||
netInput: "1kB",
|
||||
netOutput: "2kB",
|
||||
});
|
||||
|
||||
server.client.reply(/ rm abc123/, {
|
||||
code: 1,
|
||||
stderr: "Error: No such container: abc123",
|
||||
});
|
||||
const removed = await server.request(
|
||||
"DELETE",
|
||||
"/containers/s1/abc123/remove",
|
||||
);
|
||||
expect(removed.status).toBe(404);
|
||||
|
||||
expect(
|
||||
(await server.request("POST", "/containers/s1/abc123/explode")).status,
|
||||
).toBe(404);
|
||||
expect(
|
||||
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
|
||||
).toBe(400);
|
||||
|
||||
await server.request("POST", "/ssh/disconnect", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1");
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("uses Podman when the host says so", async () => {
|
||||
server = await startServer();
|
||||
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||
await connect(server);
|
||||
await server.request("GET", "/containers/s1");
|
||||
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
|
||||
});
|
||||
|
||||
it("keeps another user's session to its owner", async () => {
|
||||
server = await startServer();
|
||||
await connect(server);
|
||||
const response = await server.request("GET", "/containers/s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(response.status).toBe(400);
|
||||
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
|
||||
user: "user-2",
|
||||
});
|
||||
expect(status.body.connected).toBe(false);
|
||||
});
|
||||
|
||||
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
|
||||
while (code !== "123456") {
|
||||
if (code === null) throw new Error("Cancelled");
|
||||
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
|
||||
}
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
|
||||
|
||||
const pending = await server.request("GET", "/containers/s1");
|
||||
expect(pending.body.code).toBe("AUTH_PENDING");
|
||||
|
||||
const wrong = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "000000" },
|
||||
});
|
||||
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
|
||||
|
||||
const stranger = await server.request("POST", "/ssh/connect-totp", {
|
||||
user: "user-2",
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(stranger.status).toBe(404);
|
||||
|
||||
const right = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "123456" },
|
||||
});
|
||||
expect(right.body).toMatchObject({ success: true, status: "success" });
|
||||
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
|
||||
});
|
||||
|
||||
it("continues a Warpgate sign-in and refuses a code for it", async () => {
|
||||
server = await startServer();
|
||||
promptingConnect(server, async (ask) => {
|
||||
await ask({
|
||||
kind: "warpgate",
|
||||
url: "https://warpgate.example/login",
|
||||
securityKey: "KEY",
|
||||
instructions: "",
|
||||
});
|
||||
});
|
||||
|
||||
const first = await connect(server);
|
||||
expect(first.body).toMatchObject({
|
||||
requires_warpgate: true,
|
||||
url: "https://warpgate.example/login",
|
||||
securityKey: "KEY",
|
||||
});
|
||||
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
|
||||
body: { sessionId: "s1", totpCode: "1" },
|
||||
});
|
||||
expect(wrongKind.status).toBe(400);
|
||||
|
||||
const done = await server.request("POST", "/ssh/connect-warpgate", {
|
||||
body: { sessionId: "s1" },
|
||||
});
|
||||
expect(done.body.success).toBe(true);
|
||||
});
|
||||
|
||||
it("asks for credentials when a host with no secret gets a password prompt", async () => {
|
||||
server = await startServer({
|
||||
mock: {
|
||||
sshHosts: [
|
||||
{
|
||||
id: 7,
|
||||
userId: "user-1",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "none",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
promptingConnect(server, async (ask) => {
|
||||
const answer = await ask({
|
||||
kind: "input",
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
isPush: false,
|
||||
});
|
||||
if (answer === null)
|
||||
throw new Error("All configured authentication methods failed");
|
||||
});
|
||||
|
||||
const response = await connect(server);
|
||||
expect(response.body).toEqual({
|
||||
status: "auth_required",
|
||||
reason: "no_keyboard",
|
||||
});
|
||||
});
|
||||
|
||||
it("reports a failed login to core", async () => {
|
||||
const client = new FakeClient();
|
||||
server = await startServer({ client });
|
||||
server.mock.ctx.ssh.connect = (async () => {
|
||||
throw new Error("All configured authentication methods failed");
|
||||
}) as never;
|
||||
const response = await connect(server);
|
||||
expect(response.status).toBe(500);
|
||||
expect(server.mock.statusReports).toContainEqual({
|
||||
hostId: 7,
|
||||
ok: false,
|
||||
hostKeyChanged: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import express, { type Router } from "express";
|
||||
import {
|
||||
createMockCtx,
|
||||
type MockContextOptions,
|
||||
type MockPluginContext,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import { activate } from "../../src/backend/index.js";
|
||||
import { FakeClient } from "./fake-ssh";
|
||||
|
||||
export const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
export const PS_LINE =
|
||||
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
|
||||
|
||||
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
|
||||
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id,
|
||||
userId: "user-1",
|
||||
name: `host-${id}`,
|
||||
ip: `10.0.0.${id}`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
password: "pw",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
/** A client that looks like a Linux host with Docker running. */
|
||||
export function dockerClient(): FakeClient {
|
||||
return new FakeClient([
|
||||
[/^ver$/, { stdout: "" }],
|
||||
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
|
||||
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
|
||||
[/ ps$/, { stdout: "" }],
|
||||
]);
|
||||
}
|
||||
|
||||
export interface TestServer {
|
||||
mock: MockPluginContext;
|
||||
client: FakeClient;
|
||||
request: (
|
||||
method: string,
|
||||
path: string,
|
||||
options?: { user?: string; body?: unknown },
|
||||
// Route bodies vary per test; asserting on them is the point.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
) => Promise<{ status: number; body: any }>;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
/** The plugin activated with its router served the way core mounts it. */
|
||||
export async function startServer(
|
||||
options: {
|
||||
permissions?: string[];
|
||||
dockerOn?: boolean;
|
||||
client?: FakeClient;
|
||||
mock?: Partial<MockContextOptions>;
|
||||
} = {},
|
||||
): Promise<TestServer> {
|
||||
const client = options.client ?? dockerClient();
|
||||
let router: Router | null = null;
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
manifest,
|
||||
capabilities: manifest.capabilities,
|
||||
router: () => (router = express.Router()),
|
||||
permissions: options.permissions ?? ["docker.use"],
|
||||
sshHosts: [sshHost(7), sshHost(8)],
|
||||
sshClient: client,
|
||||
...options.mock,
|
||||
});
|
||||
if (options.dockerOn !== false) {
|
||||
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||
}
|
||||
|
||||
await activate(mock.ctx);
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||
next();
|
||||
});
|
||||
app.use((req, res, next) => router!(req, res, next));
|
||||
|
||||
const server = http.createServer(app);
|
||||
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||
const { port } = server.address() as AddressInfo;
|
||||
|
||||
return {
|
||||
mock,
|
||||
client,
|
||||
async request(method, path, { user = "user-1", body } = {}) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
"x-test-user": user,
|
||||
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||
},
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const text = await response.text();
|
||||
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||
},
|
||||
async close() {
|
||||
for (const dispose of mock.disposals.reverse()) await dispose();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type {
|
||||
DockerEvent,
|
||||
DockerEventsV1,
|
||||
DockerServiceV1,
|
||||
} from "../../src/backend/index.js";
|
||||
import { startServer, type TestServer } from "./server";
|
||||
|
||||
let server: TestServer | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
await server?.close();
|
||||
server = null;
|
||||
});
|
||||
|
||||
const psState = (state: string, status = "Up") =>
|
||||
`{"name":"web","state":"${state}","status":"${status}"}\n`;
|
||||
|
||||
describe("docker.containers service", () => {
|
||||
it("lists containers and runs actions as the caller", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
|
||||
const containers = await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.listContainers(7),
|
||||
);
|
||||
expect(containers).toEqual([
|
||||
expect.objectContaining({ id: "abc123", name: "web" }),
|
||||
]);
|
||||
|
||||
await server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web", "restart"),
|
||||
);
|
||||
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
|
||||
expect(server.mock.sshConnections.at(-1)).toMatchObject({
|
||||
host: 7,
|
||||
pool: "docker",
|
||||
});
|
||||
|
||||
await expect(
|
||||
server.mock.ctx.asUser("user-1", () =>
|
||||
docker.action(7, "web; rm -rf /", "stop"),
|
||||
),
|
||||
).rejects.toThrow(/Invalid container name/);
|
||||
});
|
||||
|
||||
it("needs a calling user", async () => {
|
||||
server = await startServer();
|
||||
const docker = server.mock.services.get(
|
||||
"docker.containers",
|
||||
) as DockerServiceV1;
|
||||
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker.events service", () => {
|
||||
it("reports state changes between polls to every subscriber", async () => {
|
||||
vi.useFakeTimers({ toFake: ["Date"] });
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
const seen: DockerEvent[] = [];
|
||||
const listener = (event: DockerEvent) => seen.push(event);
|
||||
|
||||
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
|
||||
events.subscribe(7, listener),
|
||||
);
|
||||
// Subscribing the same listener again is a no-op.
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
|
||||
|
||||
const poll = async (output: string) => {
|
||||
server!.client.reply(/ ps -a --format/, { stdout: output });
|
||||
vi.setSystemTime(Date.now() + 61_000);
|
||||
await server!.mock.runScheduled();
|
||||
};
|
||||
|
||||
await poll(psState("running"));
|
||||
expect(seen).toEqual([]);
|
||||
|
||||
await poll(psState("exited", "Exited (1)"));
|
||||
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
|
||||
|
||||
await poll(psState("running", "Up 1 second (unhealthy)"));
|
||||
expect(seen.slice(1)).toEqual([
|
||||
{ hostId: 7, container: "web", event: "started" },
|
||||
{ hostId: 7, container: "web", event: "unhealthy" },
|
||||
]);
|
||||
|
||||
unsubscribe();
|
||||
const before = server.client.commands.length;
|
||||
await poll(psState("exited"));
|
||||
expect(server.client.commands.length).toBe(before);
|
||||
expect(seen).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("stops polling once the plugin is disposed", async () => {
|
||||
server = await startServer();
|
||||
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
|
||||
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
|
||||
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { screen, waitFor } from "@testing-library/react";
|
||||
import {
|
||||
renderWithApp,
|
||||
type RenderedPluginApp,
|
||||
} from "@termix/plugin-sdk/testing";
|
||||
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||
import * as plugin from "../../src/frontend/index";
|
||||
import manifestJson from "../../manifest.json";
|
||||
import locales from "../../locales/en.json";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
let rendered: RenderedPluginApp | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
await rendered?.deactivate();
|
||||
rendered = null;
|
||||
});
|
||||
|
||||
function host(enableDocker: boolean) {
|
||||
return {
|
||||
id: "7",
|
||||
name: "box",
|
||||
ip: "10.0.0.7",
|
||||
port: 22,
|
||||
username: "root",
|
||||
enableSsh: true,
|
||||
pluginSettings: { docker: { enableDocker } },
|
||||
};
|
||||
}
|
||||
|
||||
function stubApi(connectResult: Record<string, unknown>) {
|
||||
const post = vi.fn(async (url: string) => {
|
||||
if (url === "/ssh/connect") return { data: connectResult };
|
||||
return { data: { success: true } };
|
||||
});
|
||||
const get = vi.fn(async (url: string) => {
|
||||
if (url.startsWith("/validate/")) {
|
||||
return {
|
||||
data: { available: true, version: "27.1.1", runtime: "docker" },
|
||||
};
|
||||
}
|
||||
if (url.startsWith("/containers/")) {
|
||||
return {
|
||||
data: [
|
||||
{
|
||||
id: "abc123",
|
||||
name: "web",
|
||||
image: "nginx",
|
||||
status: "Up",
|
||||
state: "running",
|
||||
ports: "",
|
||||
created: "",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return { data: {} };
|
||||
});
|
||||
return {
|
||||
post,
|
||||
get,
|
||||
delete: vi.fn(),
|
||||
put: vi.fn(),
|
||||
patch: vi.fn(),
|
||||
} as unknown as {
|
||||
post: typeof post;
|
||||
get: typeof get;
|
||||
} & PluginApiClient;
|
||||
}
|
||||
|
||||
describe("DockerManager", () => {
|
||||
it("says Docker is off for a host without the switch", async () => {
|
||||
const api = stubApi({ success: true });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(false)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(false),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(
|
||||
await screen.findByText("Docker is not enabled for this host"),
|
||||
).toBeTruthy();
|
||||
expect(api.post).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("connects through the plugin api and lists containers", async () => {
|
||||
const api = stubApi({ success: true, status: "success" });
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
await waitFor(() =>
|
||||
expect(api.post).toHaveBeenCalledWith(
|
||||
"/ssh/connect",
|
||||
expect.objectContaining({ hostId: 7 }),
|
||||
),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^\/validate\//),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it("opens the code prompt when the host asks for one", async () => {
|
||||
const api = stubApi({
|
||||
requires_totp: true,
|
||||
sessionId: "s1",
|
||||
prompt: "Verification code:",
|
||||
});
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
hosts: [host(true)],
|
||||
api,
|
||||
});
|
||||
rendered.renderTab("docker", {
|
||||
host: host(true),
|
||||
label: "box",
|
||||
isVisible: true,
|
||||
});
|
||||
expect(await screen.findByText("Verification code:")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -36,6 +36,11 @@ describe(`${manifest.id} activate`, () => {
|
||||
for (const id of rendered.registered.dashboardCards()) {
|
||||
expect(cards).toContain(id);
|
||||
}
|
||||
// Host settings render from the manifest; there is no editor tab.
|
||||
expect(rendered.registered.hostEditorSections()).toEqual([]);
|
||||
expect(rendered.registered.hostActions()).toEqual([
|
||||
expect.objectContaining({ id: "docker", tabType: "docker" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/types/*": ["../../src/types/*"],
|
||||
"@/*": ["../../src/ui/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
|
||||
}
|
||||
Loaded 100 of 167 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user