feat: finish the docker plugin (B15)

This commit is contained in:
LukeGus committed 2026-09-24 11:58:20 -05:00
1 parent 0a3f05d99d
commit 993a91c899
167 files changed
+26299 -5136

No files matched your search

+3 -1
View File
@@ -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
+1 -1
View File
@@ -161,7 +161,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+1 -1
View File
@@ -142,7 +142,7 @@ http {
root /app/html; root /app/html;
index index.html index.htm; index index.html index.htm;
expires off; 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 X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always; add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
+5
View File
@@ -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
+7
View File
@@ -316,6 +316,13 @@
"when": 1790266402855, "when": 1790266402855,
"tag": "0044_jittery_thunderball", "tag": "0044_jittery_thunderball",
"breakpoints": true "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
+7
View File
@@ -323,6 +323,13 @@
"when": 1790266402403, "when": 1790266402403,
"tag": "0045_thankful_shinobi_shaw", "tag": "0045_thankful_shinobi_shaw",
"breakpoints": true "breakpoints": true
},
{
"idx": 46,
"version": "7",
"when": 1790268465907,
"tag": "0046_small_white_tiger",
"breakpoints": true
} }
] ]
} }
+5
View File
@@ -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
+7
View File
@@ -295,6 +295,13 @@
"when": 1790266401908, "when": 1790266401908,
"tag": "0041_nosy_mystique", "tag": "0041_nosy_mystique",
"breakpoints": true "breakpoints": true
},
{
"idx": 42,
"version": "6",
"when": 1790268465325,
"tag": "0042_many_old_lace",
"breakpoints": true
} }
] ]
} }
+48 -11
View File
@@ -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. authenticated it, for a library that insists on owning its own WebSocketServer.
guacamole-lite is why it exists. Three routes are public because they 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 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 Guacamole display (a single-use encrypted connection token in the query, minted
by an authenticated route). 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 **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. 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 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 `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`, the `docker` runtime name, which Phase B moves). **B14** removed the `rdp`,
`vnc` and `telnet` entries: the shell learns those protocols from `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 #### The loader
@@ -581,7 +591,10 @@ stopgap in the right place: D1 turns them into typed bridge members.
**B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the **B16** added `LineChart`, `useAdaptivePolling`, `useAreaPreferences` (the
Appearance density and chart options), and the homepage widget pieces Appearance density and chart options), and the homepage widget pieces
`WidgetTitle` and `runVisibleInterval`, for host-metrics's cards and its `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. status for a host, from the shell's own polling, or null outside the shell.
**B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the **B14** added `buildOriginWsUrl`, `getBasePath`, `resolveRemoteHostId` and the
`ConnectionStage` type, for remote desktop's display socket and its remote `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 under `ctx.files.dataDir()/session_logs/<user>/<session>.cast`. Retention
(`retentionDays`, an admin setting) runs a sweep at boot and every 24 hours. (`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, 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 who has no user, cannot make one. Guest link resolution is therefore
published on `ctx.registry` as `sessions.sharing.guests` instead: the token is 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 before), so `provide`/`consume`/`revoke` round-trip the way the real registry
does. `plugins/workspaces/tests/backend/helpers.ts` is the worked example. 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()`), `renderWithApp` also takes `api` (a stub for `app.api` and `usePluginApi()`),
`layout` (what `app.tabs.getLayout()` returns) and `ready` (fire `layout` (what `app.tabs.getLayout()` returns) and `ready` (fire
`app.tabs.onReady`). `app.tabs.applyLayout` is recorded in `shellCalls` and runs `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. a chain has no single resolved host of its own.
- `prepare(host, { client, purpose })`, `openTransport`, - `prepare(host, { client, purpose })`, `openTransport`,
`classifyKeyboardInteractive` and `autoResponses` are the lower level for a `classifyKeyboardInteractive` and `autoResponses` are the lower level for a
transport with its own prompt flow (docker's console, host metrics, **B6**'s transport with its own prompt flow (**B6**'s interactive file-manager
interactive file-manager connect with its TOTP/Warpgate parking flow). 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 - `requiresSecret(authType)` and `supportsBackground(authType)` ask the
provider. provider.
- **B6** added `"file-manager"` and `"file-transfer"` to `PluginSshPurpose` - **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 ## Legacy core imports: the debt D1 removes
The bundled plugins predate the SDK, apart from workspaces (A9), snippets The bundled plugins predate the SDK, apart from workspaces (A9), snippets
(B2), remote-desktop (B14) and host-metrics (B16), which import nothing from (B2), remote-desktop (B14), docker (B15) and host-metrics (B16), which import
core. The others still reach core by relative nothing from core. The others still reach core by relative
path (`../../../../src/backend/...`), which an esbuild plugin, path (`../../../../src/backend/...`), which an esbuild plugin,
`packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle `packages/plugin-sdk/cli/lib/legacy-core-imports.mjs`, keeps out of the bundle
and rewrites to the compiled output path (`../../../backend/backend/...`, or 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 | - | | Core importing a plugin backend | **Error** | 0 | - |
| A plugin backend importing frontend code or `@/` | **Error** | 0 | - | | A plugin backend importing frontend code or `@/` | **Error** | 0 | - |
| The shell importing plugin code | **Error** | 0 | - | | The shell importing plugin code | **Error** | 0 | - |
| A plugin frontend importing core through `@/` | Warning | 84 files | D1 | | A plugin frontend importing core through `@/` | Warning | 70 files | D1 |
| A plugin importing core by relative path | Warning | 29 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 plugin importing another plugin's source | Warning | 1 file | B18 |
A warning does not fail a build, so the counts are held by 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. command history panel into ssh-terminal, which imports nothing from core.
The file manager's terminal window renders the `terminal.view` component The file manager's terminal window renders the `terminal.view` component
instead of importing the core terminal. instead of importing the core terminal.
- Host data columns (`enableDocker` and so on) stay in core types until - Host data columns (`enableFileManager` and so on) stay in core types until
Phase B moves them. Only UI branches on them left the shell. **B14** moved 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, remote desktop's (`enableRdp`, `enableVnc`, `enableTelnet`, the three ports,
`rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings; `rdpSecurity`, `rdpIgnoreCert`, `guacamoleConfig`) into its host settings;
the protocol logins stay core host fields. the protocol logins stay core host fields.
+38 -5
View File
@@ -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 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 "Tunnel" filter and the bulk enable/disable entries were removed, and the
export's "Tunnels" group became "Proxy" (SOCKS5 fields only) since 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 core to read and write host-scope plugin settings generically (keyed off
`contributes.settings.host` or `hostCapability`) rather than per column. `contributes.settings.host` or `hostCapability`) rather than per column.
Owner: D1. 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 `logActivity` is a core `@/main-axios` call and the plugin imports nothing
from `@/`. Old "tunnel" entries still reopen the tab through the plugin's from `@/`. Old "tunnel" entries still reopen the tab through the plugin's
`activityTypes`. An `app.logActivity` (or equivalent bridge member) would `activityTypes`. An `app.logActivity` (or equivalent bridge member) would
restore it for this plugin and let docker and file-manager drop their own restore it for this plugin and let file-manager drop its own
`@/main-axios` import for it too (host-metrics uses the `logActivity` the `@/main-axios` import for it too (host-metrics and docker use the
ui entry exports). Owner: D1. `logActivity` the ui entry exports). Owner: D1.
- **B7 (tunnels):** on the desktop app, tunnel statuses from a connected - **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 server are no longer merged into the tab: the old code polled the
remote tunnel service through `getRemoteTunnelApi()`, and the SDK has no 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 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 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 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` - **B14, found in B5 (proxmox):** `drizzle/postgres/0036_redundant_ender_wiggin.sql`
and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four and `drizzle/mysql/0035_clumsy_leech.sql` really `DROP COLUMN` the four
proxmox host columns. On Postgres and MySQL that runs at boot before 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 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 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. 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 ## 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 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 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. 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.
-1
View File
@@ -487,7 +487,6 @@ export interface PluginHostRecord {
enableTerminal?: boolean | null; enableTerminal?: boolean | null;
enableFileManager?: boolean | null; enableFileManager?: boolean | null;
enableTunnel?: boolean | null; enableTunnel?: boolean | null;
enableDocker?: boolean | null;
createdAt?: string | null; createdAt?: string | null;
updatedAt?: string | null; updatedAt?: string | null;
[key: string]: unknown; [key: string]: unknown;
+33 -8
View File
@@ -17,7 +17,24 @@ import type {
import type { TermixApp } from "./frontend.js"; import type { TermixApp } from "./frontend.js";
import type { PluginManifest } from "./manifest.js"; import type { PluginManifest } from "./manifest.js";
import type { PluginTableDefinition } from "./db.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 { import type {
PluginLoginMethod, PluginLoginMethod,
PluginSecondFactor, PluginSecondFactor,
@@ -115,8 +132,11 @@ export interface FakePluginContext {
syncEntities: SyncEntityRegistration[]; syncEntities: SyncEntityRegistration[];
/** Tombstones recorded through ctx.sync.recordTombstone, in order. */ /** Tombstones recorded through ctx.sync.recordTombstone, in order. */
tombstones: Array<{ userId: string; entityType: string; syncId: string }>; 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. */ /** Router options passed to ctx.http.router, in order. */
httpRouters: Array<PluginRouterOptions | undefined>; httpRouters: Array<PluginRouterOptions | undefined>;
/** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */ /** Backing store behind ctx.settings, keyed "<scope>:<scopeId>:<key>". */
@@ -237,7 +257,7 @@ export function createFakeContext(
entityType: string; entityType: string;
syncId: string; syncId: string;
}> = []; }> = [];
const wsRoutes: Array<{ path: string; raw: boolean }> = []; const wsRoutes: FakeWsRoute[] = [];
const httpRouters: Array<PluginRouterOptions | undefined> = []; const httpRouters: Array<PluginRouterOptions | undefined> = [];
const listeners = new Map<string, Set<(payload: unknown) => void>>(); const listeners = new Map<string, Set<(payload: unknown) => void>>();
const settings = new Map<string, unknown>(); const settings = new Map<string, unknown>();
@@ -466,11 +486,16 @@ export function createFakeContext(
}, },
ws: { ws: {
route: (path) => { route: (path, handler, wsOptions) => {
wsRoutes.push({ path, raw: false }); wsRoutes.push({ path, raw: false, handler, options: wsOptions });
}, },
upgrade: (path) => { upgrade: (path, handler, wsOptions) => {
wsRoutes.push({ path, raw: true }); wsRoutes.push({
path,
raw: true,
upgrade: handler,
options: wsOptions,
});
}, },
}, },
+12 -1
View File
@@ -29,7 +29,8 @@
"optionalDependencies": { "optionalDependencies": {
"snippets": "^1.0.0", "snippets": "^1.0.0",
"fleets": "^1.0.0", "fleets": "^1.0.0",
"tunnels": "^1.0.0" "tunnels": "^1.0.0",
"docker": "^1.0.0"
}, },
"requires": [ "requires": [
{ {
@@ -46,6 +47,16 @@
"service": "tunnels.access", "service": "tunnels.access",
"versionRange": "^1.0.0", "versionRange": "^1.0.0",
"optional": true "optional": true
},
{
"service": "docker.containers",
"versionRange": "^1.0.0",
"optional": true
},
{
"service": "docker.events",
"versionRange": "^1.0.0",
"optional": true
} }
], ],
"contributes": { "contributes": {
@@ -2,14 +2,11 @@ import {
DEFAULT_STEP_TIMEOUT_MS, DEFAULT_STEP_TIMEOUT_MS,
type Step, type Step,
} from "../../../../../src/types/automations.js"; } from "../../../../../src/types/automations.js";
import { import { execCommand, execElevated } from "@termix/plugin-sdk/host-commands";
execCommand,
execElevated,
shellSingleQuote,
} from "@termix/plugin-sdk/host-commands";
import { withSshConnection } from "../ssh.js"; import { withSshConnection } from "../ssh.js";
import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js"; import { createCurrentNotificationChannelRepository } from "../../../../../src/backend/database/repositories/factory.js";
import { getSnippet, resolveSnippetCommandFor } from "../snippets.js"; import { getSnippet, resolveSnippetCommandFor } from "../snippets.js";
import { runDockerAction } from "../docker.js";
import { runTunnelAction } from "../tunnels.js"; import { runTunnelAction } from "../tunnels.js";
import { sendAutomationNotification } from "../notify.js"; import { sendAutomationNotification } from "../notify.js";
import { automationFetch } from "../http.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 const result = await runDockerAction(
// equivalent command over the same pooled SSH connection everything else context.userId,
// uses. The container name is quoted because it comes from a template. target.id,
const command = `docker ${step.action} ${shellSingleQuote(container)}`; container,
return execOnHost(target.host, command, false, context, step.timeoutMs); step.action,
);
return result.ok
? { output: `Container ${container}: ${step.action} done` }
: { output: "", error: result.error };
}); });
} }
+59 -178
View File
@@ -1,41 +1,29 @@
import type { AutomationDefinition } from "../../../../src/types/automations.js"; import type { AutomationDefinition } from "../../../../src/types/automations.js";
import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js"; import { createCurrentAutomationRepository } from "../../../../src/backend/database/repositories/factory.js";
import { resolveHostById } from "../../../../src/backend/hosts/host-resolver.js"; import { subscribeDockerEvents, type DockerEvent } from "./docker.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 { onDockerEvent } from "./triggers.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 * Subscribing is idempotent on the docker side, so every watched host is
* session that only exists while somebody has the UI open, so a trigger built * subscribed again once a minute. That is what picks the subscriptions back
* on it would only ever fire while being watched. This polls over the same * up after the docker plugin is disabled and enabled again.
* 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.
*/ */
const POLL_INTERVAL_MS = 60_000; const RESUBSCRIBE_MS = 60_000;
const EXEC_TIMEOUT_MS = 15_000;
interface ContainerState { interface Watch {
/** Docker's own state word: running, exited, restarting, ... */ listener: (event: DockerEvent) => void;
state: string; unsubscribe: (() => void) | null;
/** Health from the status text, when the image declares a healthcheck. */ subscribedAt: number;
unhealthy: boolean;
} }
/** Last snapshot per host, so transitions can be spotted. */ const watches = new Map<string, Watch>();
const snapshots = new Map<number, Map<string, ContainerState>>();
const lastPolledAt = new Map<number, number>(); const keyOf = (hostId: number, userId: string) => `${userId}:${hostId}`;
/** Hosts named by an enabled docker_event trigger, with the owning user. */ /** Hosts named by an enabled docker_event trigger, with the owning user. */
export async function listDockerWatchedHosts(): Promise<Map<number, string>> { 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") { } else if (selector?.kind === "hosts") {
for (const hostId of selector.hostIds) watched.set(hostId, row.userId); for (const hostId of selector.hostIds) watched.set(hostId, row.userId);
} }
// Fleet and "all" selectors are deliberately not expanded: polling every // Fleet and "all" selectors are deliberately not expanded: watching
// host a user owns for container state is far too costly to do blindly. // every host a user owns for container state is far too costly.
} }
} catch { } catch {
return watched; return watched;
@@ -70,165 +58,58 @@ export async function listDockerWatchedHosts(): Promise<Map<number, string>> {
return watched; return watched;
} }
/** /** Brings the subscriptions in line with the watched hosts. */
* Parses `docker ps -a` output. One JSON object per line, matching the format export async function reconcileDockerWatch(
* string the container routes use. now: number = Date.now(),
*/ watched?: Map<number, string>,
export function parseContainerStates( ): Promise<void> {
output: string, const wanted = watched ?? (await listDockerWatchedHosts());
): Map<string, ContainerState> { const wantedKeys = new Set(
const states = new Map<string, ContainerState>(); [...wanted].map(([hostId, userId]) => keyOf(hostId, userId)),
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,
),
); );
if (result.code !== 0 && result.code !== null) { for (const [key, watch] of watches) {
// Docker missing or not permitted on this host. Drop the snapshot so a if (wantedKeys.has(key)) continue;
// later success is treated as a first observation rather than a diff. watch.unsubscribe?.();
snapshots.delete(hostId); watches.delete(key);
return;
} }
const current = parseContainerStates(result.stdout); for (const [hostId, userId] of wanted) {
const previous = snapshots.get(hostId); const key = keyOf(hostId, userId);
snapshots.set(hostId, current); let watch = watches.get(key);
if (!watch) {
if (!previous) return; watch = {
listener: (event) => {
for (const { container, event } of diffContainerStates(previous, current)) { void onDockerEvent({
await onDockerEvent({ hostId: event.hostId,
hostId, ownerUserId: userId,
ownerUserId: userId, container: event.container,
container, event: event.event,
event, }).catch(() => undefined);
}).catch(() => undefined); },
} unsubscribe: null,
} subscribedAt: 0,
};
/** watches.set(key, watch);
* Polls every watched host whose interval has elapsed. Called from the }
* automation scheduler tick rather than owning a timer of its own. if (watch.subscribedAt && now - watch.subscribedAt < RESUBSCRIBE_MS) {
*/ continue;
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 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 { try {
await pollHost(hostId, userId); watch.unsubscribe = await subscribeDockerEvents(
} catch (error) { userId,
statsLogger.warn("Docker event poll failed", {
operation: "automation_docker_poll_error",
hostId, 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 { /** Drops every subscription, for shutdown and tests. */
try {
return DataCrypto.canUserAccessData(userId);
} catch {
return false;
}
}
/** Clears cached state, for shutdown and tests. */
export function resetDockerWatcher(): void { export function resetDockerWatcher(): void {
snapshots.clear(); for (const watch of watches.values()) watch.unsubscribe?.();
lastPolledAt.clear(); watches.clear();
} }
+85
View File
@@ -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);
}
+3
View File
@@ -2,6 +2,7 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js"; import { setPluginSsh } from "./ssh.js";
import { setPluginServices } from "./snippets.js"; import { setPluginServices } from "./snippets.js";
import { setTunnelServices } from "./tunnels.js"; import { setTunnelServices } from "./tunnels.js";
import { setDockerServices } from "./docker.js";
import { startAutomationsService, stopAutomationsService } from "./routes.js"; import { startAutomationsService, stopAutomationsService } from "./routes.js";
import { import {
startAutomationScheduler, startAutomationScheduler,
@@ -23,6 +24,8 @@ export async function activate(ctx: PluginContext) {
ctx.disposables.add(() => setPluginServices(null)); ctx.disposables.add(() => setPluginServices(null));
setTunnelServices(ctx.services); setTunnelServices(ctx.services);
ctx.disposables.add(() => setTunnelServices(null)); 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 // Nothing arrives here while the tunnels plugin is off, which is the whole
// of the optional dependency. // of the optional dependency.
+3 -2
View File
@@ -4,7 +4,7 @@ import { DataCrypto } from "../../../../src/backend/utils/data-crypto.js";
import { statsLogger } from "../../../../src/backend/utils/logger.js"; import { statsLogger } from "../../../../src/backend/utils/logger.js";
import { computeNextDueAt } from "./cron.js"; import { computeNextDueAt } from "./cron.js";
import { hasDwelled, isCoolingDown } from "./conditions.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 { AutomationEngine } from "./engine.js";
import { reconcileHeadlessViewers } from "./headless-viewer.js"; import { reconcileHeadlessViewers } from "./headless-viewer.js";
import { import {
@@ -57,6 +57,7 @@ export function stopAutomationScheduler(): void {
tickTimer = null; tickTimer = null;
startupTimer = null; startupTimer = null;
unsubscribeAutomationTriggers(); unsubscribeAutomationTriggers();
resetDockerWatcher();
} }
/** Exposed for tests; the interval calls this. */ /** 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 reconcileHeadlessViewers().catch(() => undefined);
await runDueSchedules(now); await runDueSchedules(now);
await recheckOpenBreaches(now); await recheckOpenBreaches(now);
await pollDockerEvents(now.getTime()).catch(() => undefined); await reconcileDockerWatch(now.getTime()).catch(() => undefined);
await pruneIfDue(now); await pruneIfDue(now);
} catch (error) { } catch (error) {
statsLogger.warn("Automation scheduler tick failed", { 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 { import {
diffContainerStates, reconcileDockerWatch,
parseContainerStates, resetDockerWatcher,
} from "../../src/backend/docker-watcher.js"; } from "../../src/backend/docker-watcher.js";
import {
setDockerServices,
runDockerAction,
} from "../../src/backend/docker.js";
/** type Listener = (event: {
* The polling side needs SSH, so what is tested here is the pure part: turning hostId: number;
* `docker ps` output into states, and turning two snapshots into events. container: string;
*/ event: string;
}) => void;
describe("parseContainerStates", () => { /** A docker.events provider that records who subscribed to what. */
it("reads name, state and health out of the ps output", () => { function fakeEvents() {
const output = [ const subscriptions = new Map<string, Set<Listener>>();
'{"name":"web","state":"running","status":"Up 2 hours"}', const calls: Array<{ userId?: string; hostId: number }> = [];
'{"name":"db","state":"exited","status":"Exited (0) 5 minutes ago"}', const services = {
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}', provide: () => {},
].join("\n"); 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); beforeEach(() => {
fired.length = 0;
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);
});
}); });
describe("diffContainerStates", () => { afterEach(() => {
const running = { state: "running", unhealthy: false }; resetDockerWatcher();
const exited = { state: "exited", unhealthy: false }; setDockerServices(null);
});
it("reports a container that stopped", () => { describe("docker watcher", () => {
const events = diffContainerStates( it("subscribes each watched host as its owner and fires the trigger", async () => {
new Map([["web", running]]), const events = fakeEvents();
new Map([["web", exited]]), setDockerServices(events.services);
);
expect(events).toEqual([{ container: "web", event: "exited" }]); 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", () => { it("re-subscribes once a minute and drops hosts no longer watched", async () => {
const events = diffContainerStates( const events = fakeEvents();
new Map([["web", exited]]), setDockerServices(events.services);
new Map([["web", running]]), const watched = new Map([[7, "user-1"]]);
);
expect(events).toEqual([{ container: "web", event: "started" }]); 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", () => { it("does nothing while the docker plugin is off, then picks it up", async () => {
const events = diffContainerStates( setDockerServices({
new Map([["web", running]]), get: () => ({}),
new Map([["web", { state: "restarting", unhealthy: false }]]), } as unknown as PluginServices);
); await expect(
expect(events).toEqual([{ container: "web", event: "restarting" }]); 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", () => { it("fails a docker step cleanly while the plugin is off", async () => {
const events = diffContainerStates( setDockerServices({ get: () => ({}) } as unknown as PluginServices);
new Map([["web", running]]), expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
new Map([["web", { state: "running", unhealthy: true }]]), ok: false,
); unavailable: true,
expect(events).toEqual([{ container: "web", event: "unhealthy" }]); error: "The docker plugin is not available",
});
}); });
it("stays quiet when nothing changed", () => { it("runs a docker step through the docker service", async () => {
const events = diffContainerStates( const action = vi.fn(async () => {});
new Map([["web", running]]), setDockerServices({
new Map([["web", running]]), get: (name: string) => (name === "docker.containers" ? { action } : {}),
); } as unknown as PluginServices);
expect(events).toEqual([]); expect(await runDockerAction("user-1", 7, "web", "restart")).toEqual({
}); ok: true,
});
it("does not re-announce a container that is still unhealthy", () => { expect(action).toHaveBeenCalledWith(7, "web", "restart");
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([]);
}); });
}); });
+17
View File
@@ -3,3 +3,20 @@
## 1.0.0 ## 1.0.0
Bundled with Termix 2.9.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.
+12
View File
@@ -4,6 +4,18 @@ Container management over SSH: list, inspect, start, stop and view logs for Dock
Bundled with Termix and enabled by default. 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 ## Layout
manifest.json id, capabilities and what this plugin contributes manifest.json id, capabilities and what this plugin contributes
+22 -11
View File
@@ -100,26 +100,37 @@
"stats": "Stats", "stats": "Stats",
"consoleTab": "Console", "consoleTab": "Console",
"startContainerToAccess": "Start the container to access the 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": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "See containers, read logs and watch stats on any host running Docker." "feature_docker_desc": "See containers, read logs and watch stats on any host running Docker."
}, },
"terminal": { "terminal": {
"retry": "Retry" "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Sien houers, lees logs en kyk na statistieke op enige gasheer wat Docker gebruik." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "تكامل Docker",
"enableDockerMonitor": "تمكين Docker",
"enableDockerMonitorDesc": "مراقبة وإدارة الحاويات على هذا المضيف عبر Docker",
"containerRuntime": "بيئة تشغيل الحاويات",
"containerRuntimeDesc": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "عامل ميناء", "feature_docker": "عامل ميناء",
"feature_docker_desc": "اطلع على الحاويات، واقرأ السجلات، وراقب الإحصائيات على أي مضيف يعمل بنظام Docker." "feature_docker_desc": "اطلع على الحاويات، واقرأ السجلات، وراقب الإحصائيات على أي مضيف يعمل بنظام Docker."
},
"settings": {
"host": {
"enable": {
"label": "تمكين Docker"
},
"containerRuntime": {
"label": "بيئة تشغيل الحاويات",
"description": "اختر واجهة سطر الأوامر المستخدمة لإدارة الحاويات على هذا المضيف",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Докер" "docker": "Докер"
}, },
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Интеграция на Docker",
"enableDockerMonitor": "Активиране на Докер",
"enableDockerMonitorDesc": "Наблюдавайте и управлявайте контейнери на този хост чрез Docker",
"containerRuntime": "Изпълнение на контейнера",
"containerRuntimeDesc": "Изберете CLI, използван за управление на контейнери на този хост",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": { "onboarding": {
"feature_docker": "Докер", "feature_docker": "Докер",
"feature_docker_desc": "Вижте контейнери, прочетете лог файлове и наблюдавайте статистики на всеки хост, на който работи Docker." "feature_docker_desc": "Вижте контейнери, прочетете лог файлове и наблюдавайте статистики на всеки хост, на който работи Docker."
},
"settings": {
"host": {
"enable": {
"label": "Активиране на Докер"
},
"containerRuntime": {
"label": "Изпълнение на контейнера",
"description": "Изберете CLI, използван за управление на контейнери на този хост",
"docker": "Докер",
"podman": "Подман"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "ডকার" "docker": "ডকার"
}, },
"hosts": {
"tabDocker": "ডকার",
"dockerIntegration": "ডকার ইন্টিগ্রেশন",
"enableDockerMonitor": "ডকার সক্রিয় করুন",
"enableDockerMonitorDesc": "ডকারের মাধ্যমে এই হোস্টে কন্টেইনারগুলি নিরীক্ষণ ও পরিচালনা করুন।",
"containerRuntime": "কন্টেইনার রানটাইম",
"containerRuntimeDesc": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
"containerRuntimeDocker": "ডকার",
"containerRuntimePodman": "পডম্যান"
},
"onboarding": { "onboarding": {
"feature_docker": "ডকার", "feature_docker": "ডকার",
"feature_docker_desc": "ডকার চালিত যেকোনো হোস্টে কন্টেইনার দেখুন, লগ পড়ুন এবং পরিসংখ্যান পর্যবেক্ষণ করুন।" "feature_docker_desc": "ডকার চালিত যেকোনো হোস্টে কন্টেইনার দেখুন, লগ পড়ুন এবং পরিসংখ্যান পর্যবেক্ষণ করুন।"
},
"settings": {
"host": {
"enable": {
"label": "ডকার সক্রিয় করুন"
},
"containerRuntime": {
"label": "কন্টেইনার রানটাইম",
"description": "এই হোস্টে কন্টেইনার ব্যবস্থাপনার জন্য ব্যবহৃত CLI নির্বাচন করুন",
"docker": "ডকার",
"podman": "পডম্যান"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Veure contenidors, llegir registres i observar estadístiques de qualsevol host que executi 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Přístavní dělník" "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": { "onboarding": {
"feature_docker": "Přístavní dělník", "feature_docker": "Přístavní dělník",
"feature_docker_desc": "Zobrazujte kontejnery, čtěte protokoly a sledujte statistiky na jakémkoli hostiteli s Dockerem." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Se containere, læs logfiler og se statistikker på enhver vært, der kører 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Container anzeigen, Protokolle lesen und Statistiken auf jedem Host, auf dem Docker ausgeführt wird, einsehen." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Λιμενεργάτης" "docker": "Λιμενεργάτης"
}, },
"hosts": {
"tabDocker": "Λιμενεργάτης",
"dockerIntegration": "Ενσωμάτωση Docker",
"enableDockerMonitor": "Ενεργοποίηση Docker",
"enableDockerMonitorDesc": "Παρακολούθηση και διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή μέσω του Docker",
"containerRuntime": "Χρόνος εκτέλεσης κοντέινερ",
"containerRuntimeDesc": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
"containerRuntimeDocker": "Λιμενεργάτης",
"containerRuntimePodman": "Πόντμαν"
},
"onboarding": { "onboarding": {
"feature_docker": "Λιμενεργάτης", "feature_docker": "Λιμενεργάτης",
"feature_docker_desc": "Δείτε κοντέινερ, διαβάστε αρχεία καταγραφής και παρακολουθήστε στατιστικά στοιχεία σε οποιονδήποτε κεντρικό υπολογιστή που εκτελεί Docker." "feature_docker_desc": "Δείτε κοντέινερ, διαβάστε αρχεία καταγραφής και παρακολουθήστε στατιστικά στοιχεία σε οποιονδήποτε κεντρικό υπολογιστή που εκτελεί Docker."
},
"settings": {
"host": {
"enable": {
"label": "Ενεργοποίηση Docker"
},
"containerRuntime": {
"label": "Χρόνος εκτέλεσης κοντέινερ",
"description": "Επιλέξτε το CLI που χρησιμοποιείται για τη διαχείριση κοντέινερ σε αυτόν τον κεντρικό υπολογιστή",
"docker": "Λιμενεργάτης",
"podman": "Πόντμαν"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Estibador", "feature_docker": "Estibador",
"feature_docker_desc": "Vea los contenedores, lea los registros y observe las estadísticas en cualquier host que ejecute Docker." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Satamatyöläinen" "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": { "onboarding": {
"feature_docker": "Satamatyöläinen", "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ä." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "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." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "דוקר" "docker": "דוקר"
}, },
"hosts": {
"tabDocker": "דוקר",
"dockerIntegration": "אינטגרציה עם Docker",
"enableDockerMonitor": "הפעל את Docker",
"enableDockerMonitorDesc": "ניטור וניהול של מכולות במארח זה באמצעות Docker",
"containerRuntime": "זמן ריצה של המכולה",
"containerRuntimeDesc": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
"containerRuntimeDocker": "דוקר",
"containerRuntimePodman": "פודמן"
},
"onboarding": { "onboarding": {
"feature_docker": "דוקר", "feature_docker": "דוקר",
"feature_docker_desc": "ראה קונטיינרים, קרא יומני רישום וצפה בסטטיסטיקות על כל מארח שמפעיל את Docker." "feature_docker_desc": "ראה קונטיינרים, קרא יומני רישום וצפה בסטטיסטיקות על כל מארח שמפעיל את Docker."
},
"settings": {
"host": {
"enable": {
"label": "הפעל את Docker"
},
"containerRuntime": {
"label": "זמן ריצה של המכולה",
"description": "בחר את ממשק שורת הפקודה (CLI) המשמש לניהול מכולות במארח זה",
"docker": "דוקר",
"podman": "פודמן"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker एकीकरण",
"enableDockerMonitor": "Docker सक्षम करें",
"enableDockerMonitorDesc": "इस होस्ट पर Docker के माध्यम से कंटेनरों की निगरानी और प्रबंधन करें",
"containerRuntime": "कंटेनर रनटाइम",
"containerRuntimeDesc": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "डाक में काम करनेवाला मज़दूर", "feature_docker": "डाक में काम करनेवाला मज़दूर",
"feature_docker_desc": "डॉकर चलाने वाले किसी भी होस्ट पर कंटेनर देखें, लॉग पढ़ें और आंकड़े देखें।" "feature_docker_desc": "डॉकर चलाने वाले किसी भी होस्ट पर कंटेनर देखें, लॉग पढ़ें और आंकड़े देखें।"
},
"settings": {
"host": {
"enable": {
"label": "Docker सक्षम करें"
},
"containerRuntime": {
"label": "कंटेनर रनटाइम",
"description": "इस होस्ट पर कंटेनर प्रबंधन के लिए उपयोग किए जाने वाले CLI को चुनें",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Dokkmunkás" "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": { "onboarding": {
"feature_docker": "Dokkmunkás", "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." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Buruh pelabuhan" "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": { "onboarding": {
"feature_docker": "Buruh pelabuhan", "feature_docker": "Buruh pelabuhan",
"feature_docker_desc": "Lihat kontainer, baca log, dan pantau statistik pada host mana pun yang menjalankan Docker." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Visualizza i container, leggi i log e controlla le statistiche su qualsiasi host che esegue 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker統合",
"enableDockerMonitor": "Docker有効化",
"enableDockerMonitorDesc": "Docker経由でこのホスト上のコンテナを監視・管理します。",
"containerRuntime": "コンテナランタイム",
"containerRuntimeDesc": "このホストのコンテナ管理に使用するCLIを選択します。",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "ドッカー", "feature_docker": "ドッカー",
"feature_docker_desc": "Dockerを実行している任意のホスト上で、コンテナの表示、ログの読み取り、統計情報の監視が可能です。" "feature_docker_desc": "Dockerを実行している任意のホスト上で、コンテナの表示、ログの読み取り、統計情報の監視が可能です。"
},
"settings": {
"host": {
"enable": {
"label": "Docker有効化"
},
"containerRuntime": {
"label": "コンテナランタイム",
"description": "このホストのコンテナ管理に使用するCLIを選択します。",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 통합",
"enableDockerMonitor": "Docker 활성화",
"enableDockerMonitorDesc": "Docker를 통해 이 호스트의 컨테이너를 모니터링하고 관리합니다.",
"containerRuntime": "컨테이너 런타임",
"containerRuntimeDesc": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "도커", "feature_docker": "도커",
"feature_docker_desc": "Docker가 실행 중인 모든 호스트에서 컨테이너를 확인하고, 로그를 읽고, 통계를 볼 수 있습니다." "feature_docker_desc": "Docker가 실행 중인 모든 호스트에서 컨테이너를 확인하고, 로그를 읽고, 통계를 볼 수 있습니다."
},
"settings": {
"host": {
"enable": {
"label": "Docker 활성화"
},
"containerRuntime": {
"label": "컨테이너 런타임",
"description": "이 호스트에서 컨테이너 관리에 사용할 CLI를 선택합니다.",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Bekijk containers, lees logboeken en bekijk statistieken op elke host waarop Docker draait." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Dokker" "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": { "onboarding": {
"feature_docker": "Dokker", "feature_docker": "Dokker",
"feature_docker_desc": "Se containere, les logger og se statistikk på alle verter som kjører Docker." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Przeglądaj kontenery, czytaj logi i śledź statystyki na dowolnym hoście, na którym działa 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Visualize contêineres, leia registros e acompanhe as estatísticas em qualquer host que execute o 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Visualize contentores, leia registos e acompanhe as estatísticas em qualquer host que execute o 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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docher" "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": { "onboarding": {
"feature_docker": "Docher", "feature_docker": "Docher",
"feature_docker_desc": "Vizualizați containere, citiți jurnale și urmăriți statistici pe orice gazdă care rulează Docker." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Интеграция Docker",
"enableDockerMonitor": "Включить Docker",
"enableDockerMonitorDesc": "Отслеживать и управлять контейнерами на этом хосте через Docker",
"containerRuntime": "Среда выполнения контейнеров",
"containerRuntimeDesc": "Выберите CLI, используемый для управления контейнерами на этом хосте",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "Просматривайте контейнеры, читайте журналы и отслеживайте статистику на любом хосте, где запущен Docker." "feature_docker_desc": "Просматривайте контейнеры, читайте журналы и отслеживайте статистику на любом хосте, где запущен Docker."
},
"settings": {
"host": {
"enable": {
"label": "Включить Docker"
},
"containerRuntime": {
"label": "Среда выполнения контейнеров",
"description": "Выберите CLI, используемый для управления контейнерами на этом хосте",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Докер" "docker": "Докер"
}, },
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Интеграција Докера",
"enableDockerMonitor": "Омогући Докер",
"enableDockerMonitorDesc": "Пратите и управљајте контејнерима на овом хосту путем Докера",
"containerRuntime": "Време извршавања контејнера",
"containerRuntimeDesc": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": { "onboarding": {
"feature_docker": "Докер", "feature_docker": "Докер",
"feature_docker_desc": "Погледајте контејнере, читајте логове и пратите статистику на било ком хосту који користи Docker." "feature_docker_desc": "Погледајте контејнере, читајте логове и пратите статистику на било ком хосту који користи Docker."
},
"settings": {
"host": {
"enable": {
"label": "Омогући Докер"
},
"containerRuntime": {
"label": "Време извршавања контејнера",
"description": "Изаберите CLI који се користи за управљање контејнерима на овом хосту",
"docker": "Докер",
"podman": "Подман"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Hamnarbetare" "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": { "onboarding": {
"feature_docker": "Hamnarbetare", "feature_docker": "Hamnarbetare",
"feature_docker_desc": "Se containrar, läs loggar och titta på statistik för alla värdar som kör Docker." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "ด็อกเกอร์" "docker": "ด็อกเกอร์"
}, },
"hosts": {
"tabDocker": "ด็อกเกอร์",
"dockerIntegration": "การผสานรวม Docker",
"enableDockerMonitor": "เปิดใช้งาน Docker",
"enableDockerMonitorDesc": "ตรวจสอบและจัดการคอนเทนเนอร์บนโฮสต์นี้ผ่าน Docker",
"containerRuntime": "รันไทม์คอนเทนเนอร์",
"containerRuntimeDesc": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
"containerRuntimeDocker": "ด็อกเกอร์",
"containerRuntimePodman": "พอดแมน"
},
"onboarding": { "onboarding": {
"feature_docker": "ด็อกเกอร์", "feature_docker": "ด็อกเกอร์",
"feature_docker_desc": "ดูคอนเทนเนอร์ อ่านบันทึก และตรวจสอบสถิติบนโฮสต์ใดก็ได้ที่ใช้งาน Docker" "feature_docker_desc": "ดูคอนเทนเนอร์ อ่านบันทึก และตรวจสอบสถิติบนโฮสต์ใดก็ได้ที่ใช้งาน Docker"
},
"settings": {
"host": {
"enable": {
"label": "เปิดใช้งาน Docker"
},
"containerRuntime": {
"label": "รันไทม์คอนเทนเนอร์",
"description": "เลือก CLI ที่ใช้สำหรับการจัดการคอนเทนเนอร์บนโฮสต์นี้",
"docker": "ด็อกเกอร์",
"podman": "พอดแมน"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Liman işçisi", "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." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Докер" "docker": "Докер"
}, },
"hosts": {
"tabDocker": "Докер",
"dockerIntegration": "Інтеграція Docker",
"enableDockerMonitor": "Увімкнути Докер",
"enableDockerMonitorDesc": "Моніторинг та керування контейнерами на цьому хості через Docker",
"containerRuntime": "Середовище виконання контейнера",
"containerRuntimeDesc": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
"containerRuntimeDocker": "Докер",
"containerRuntimePodman": "Подман"
},
"onboarding": { "onboarding": {
"feature_docker": "Докер", "feature_docker": "Докер",
"feature_docker_desc": "Переглядайте контейнери, читайте журнали та переглядайте статистику на будь-якому хості, на якому працює Docker." "feature_docker_desc": "Переглядайте контейнери, читайте журнали та переглядайте статистику на будь-якому хості, на якому працює Docker."
},
"settings": {
"host": {
"enable": {
"label": "Увімкнути Докер"
},
"containerRuntime": {
"label": "Середовище виконання контейнера",
"description": "Виберіть інтерфейс командного рядка, який використовується для керування контейнерами на цьому хості",
"docker": "Докер",
"podman": "Подман"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "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": { "onboarding": {
"feature_docker": "Docker", "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." "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"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 集成",
"enableDockerMonitor": "启用 Docker",
"enableDockerMonitorDesc": "通过 Docker 监控和管理此主机上的容器",
"containerRuntime": "容器运行时",
"containerRuntimeDesc": "选择此主机上用于容器管理的 CLI",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "在任何运行 Docker 的主机上查看容器、读取日志及监控运行状态。" "feature_docker_desc": "在任何运行 Docker 的主机上查看容器、读取日志及监控运行状态。"
},
"settings": {
"host": {
"enable": {
"label": "启用 Docker"
},
"containerRuntime": {
"label": "容器运行时",
"description": "选择此主机上用于容器管理的 CLI",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+13 -10
View File
@@ -99,18 +99,21 @@
"nav": { "nav": {
"docker": "Docker" "docker": "Docker"
}, },
"hosts": {
"tabDocker": "Docker",
"dockerIntegration": "Docker 整合",
"enableDockerMonitor": "啟用 Docker",
"enableDockerMonitorDesc": "透過 Docker 監控及管理此主機上的容器",
"containerRuntime": "容器執行環境",
"containerRuntimeDesc": "選擇用於此主機容器管理的 CLI",
"containerRuntimeDocker": "Docker",
"containerRuntimePodman": "Podman"
},
"onboarding": { "onboarding": {
"feature_docker": "Docker", "feature_docker": "Docker",
"feature_docker_desc": "查看執行 Docker 的任何主機上的容器、讀取日誌和查看統計資料。" "feature_docker_desc": "查看執行 Docker 的任何主機上的容器、讀取日誌和查看統計資料。"
},
"settings": {
"host": {
"enable": {
"label": "啟用 Docker"
},
"containerRuntime": {
"label": "容器執行環境",
"description": "選擇用於此主機容器管理的 CLI",
"docker": "Docker",
"podman": "Podman"
}
}
} }
} }
+45 -5
View File
@@ -19,9 +19,20 @@
"credentials:use", "credentials:use",
"ssh:connect", "ssh:connect",
"network:serve", "network:serve",
"audit:read",
"ui:surface" "ui:surface"
], ],
"provides": [
{
"service": "docker.containers",
"version": "1.0.0",
"permission": "docker.use"
},
{
"service": "docker.events",
"version": "1.0.0",
"permission": "docker.use"
}
],
"contributes": { "contributes": {
"tabs": [ "tabs": [
{ {
@@ -31,10 +42,39 @@
"openFrom": ["host-context-menu", "palette"] "openFrom": ["host-context-menu", "palette"]
} }
], ],
"hostCapability": { "permissions": [
"key": "enableDocker", {
"labelKey": "hosts.dockerIntegration", "name": "use",
"editorTab": "docker" "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", "locales": "locales",
+404
View File
@@ -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();
}
+297 -606
View File
@@ -1,652 +1,343 @@
// Core imports below point at TypeScript source so tsc can type-check them. import { AsyncResource } from "node:async_hooks";
// The plugin build rewrites the prefix to the compiled output path; see import { StringDecoder } from "node:string_decoder";
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs. import type { Client, ClientChannel } from "ssh2";
import { getErrorMessage } from "../../../../src/backend/utils/error-message.js"; import { WebSocket, type RawData } from "ws";
import { StringDecoder } from "string_decoder"; import type {
import type { Client as SSHClient } from "ssh2"; PluginContext,
import type { PluginSshHost } from "@termix/plugin-sdk/backend"; PluginWebSocketConnection,
import { connectSsh } from "./ssh.js"; } from "@termix/plugin-sdk/backend";
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 { import {
containerCommand, containerCommand,
getContainerRuntimeConfig,
type ContainerRuntime, type ContainerRuntime,
} from "./container-runtime.js"; } from "./container-runtime.js";
import { readDockerHostSettings } from "./host-settings.js";
import { import {
hostAddressMismatch, CONTAINER_ID_RE,
HOST_ADDRESS_MISMATCH_MESSAGE, HOST_ADDRESS_MISMATCH_MESSAGE,
HOST_NOT_ON_THIS_SERVER_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, asObject,
asString, asString,
MAX_WS_MESSAGE_BYTES, getErrorMessage,
hostAddressMismatch,
parseWsMessage, parseWsMessage,
toTerminalDimension, 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 { interface ConsoleSession {
client: SSHClient; client: Client;
stream: import("ssh2").ClientChannel | null; dispose: () => void;
isConnected: boolean; stream: ClientChannel | null;
containerId?: string; hostId: number;
shell?: string; containerId: string;
hostId?: number; runtime: ContainerRuntime;
containerRuntime?: ContainerRuntime;
} }
const activeSessions = new Map<string, SSHSession>(); function shellExists(
client: Client,
/** runtime: ContainerRuntime,
* 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,
containerId: string, containerId: string,
): Promise<string> { shell: string,
const shells = ["bash", "sh", "ash"]; ): Promise<boolean> {
return new Promise((resolve) => {
for (const shell of shells) { client.exec(
try { containerCommand(runtime, `exec ${containerId} which ${shell}`),
await new Promise<void>((resolve, reject) => { (err, stream) => {
session.client.exec( if (err) return resolve(false);
containerCommand( let output = "";
session.containerRuntime, stream.on("data", (data: Buffer) => {
`exec ${containerId} which ${shell}`, output += data.toString();
), });
(err, stream) => { stream.on("close", (code: number) =>
if (err) return reject(err); resolve(code === 0 && output.trim().length > 0),
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);
});
},
); );
}); stream.stderr.on("data", () => {});
stream.stderr.on("error", () => {});
stream.on("error", () => resolve(false));
},
);
});
}
return shell; async function pickShell(
} catch { client: Client,
continue; 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"; return "sh";
} }
async function onConsoleConnection( /**
ws: WebSocket, * The container console at /plugin-ws/docker/console.
req: import("http").IncomingMessage, *
) { * Public with optionalAuth: core still resolves the caller's token, and the
const token = extractWebSocketToken(req); * handler refuses a socket without a user or without docker.use. Every live
* console is closed when the plugin deactivates.
if (!token) { */
ws.close(1008, "Authentication required"); export function registerConsole(ctx: PluginContext, log: DockerLogger): void {
return; const live = new Set<() => void>();
} ctx.disposables.add(() => {
for (const end of [...live]) end();
const authManagerInstance = AuthManager.getInstance(); live.clear();
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,
}); });
let sshSession: SSHSession | null = null; const onConnection = async (connection: PluginWebSocketConnection) => {
const ws = connection.socket as WebSocket;
const wsPingInterval = setInterval(() => { const userId = connection.userId;
if (ws.readyState === WebSocket.OPEN) { if (!userId || !(await ctx.rbac.hasFor(userId, "use"))) {
ws.ping(); ws.close(1008, "Authentication required");
return;
} }
}, 30000);
const cleanup = () => { let session: ConsoleSession | null = null;
clearInterval(wsPingInterval); const send = (message: Record<string, unknown>) => {
if (!sshSession) return; if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(message));
sshSession.stream?.end(); };
sshSession.client.end();
activeSessions.delete(sessionId);
sshSession = null;
};
ws.on("message", async (data) => { const endSession = () => {
try { if (!session) return;
const message = parseWsMessage(data); const current = session;
session = null;
current.stream?.end();
current.dispose();
};
switch (message.type) { const pingTimer = setInterval(() => {
case "connect": { if (ws.readyState === WebSocket.OPEN) ws.ping();
const connectData = asObject(message.data); }, PING_MS);
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 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) { const connect = async (data: unknown) => {
ws.send( if (!connection.isDataUnlocked()) {
JSON.stringify({ send({
type: "error", type: "error",
message: "Host configuration and container ID are required", message: "Session expired - please log in again",
}), });
); return;
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)) { if (!hostId || !containerId) {
ws.send( send({
JSON.stringify({ type: "error",
type: "error", message: "Host configuration and container ID are required",
message: "Invalid container ID", });
}), return;
); }
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"]; endSession();
if (shell && !allowedShells.includes(shell)) {
ws.send(
JSON.stringify({
type: "error",
message: "Invalid shell",
}),
);
return;
}
if (!hostConfig.enableDocker) { try {
ws.send( // syncId names the host on both sides of a sync pair; the numeric
JSON.stringify({ // id only names it in the database the client is displaying.
type: "error", const host = await ctx.ssh.resolveHost(hostId, { syncId });
message: "Docker is not enabled on this host", if (!host) {
}), send({
); type: "error",
return; message: syncId
} ? HOST_NOT_ON_THIS_SERVER_MESSAGE
: "Host not found",
try { });
// Resolve host with credentials server-side return;
const { resolveHostById, resolveHostBySyncId } = }
await import("../../../../src/backend/hosts/host-resolver.js"); if (!syncId && hostAddressMismatch(hostConfig.ip, host.ip)) {
// syncId names the host on both sides of a sync pair; the numeric log.error("Refusing Docker console: host id resolves elsewhere", {
// id only names it in the database the client is displaying. hostId,
const hostSyncId = hostConfig?.syncId; userId,
const resolvedHost = hostSyncId });
? await resolveHostBySyncId(hostSyncId, userId) send({ type: "error", message: HOST_ADDRESS_MISMATCH_MESSAGE });
: await resolveHostById(hostId, userId); return;
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;
} }
case "input": { const settings = await readDockerHostSettings(ctx, host.id);
if (sshSession && sshSession.stream) { if (!settings.enabled) {
const input = asString(message.data); send({
if (input) sshSession.stream.write(input); type: "error",
} message:
break; "Docker is not enabled for this host. Enable it in Host Settings.",
});
return;
} }
case "resize": { const { client, dispose } = await ctx.ssh.connect<Client>(host, {
if (sshSession && sshSession.stream) { purpose: "docker-console",
const dimensions = asObject(message.data); timeoutMs: 65_000,
const cols = toTerminalDimension(dimensions.cols); });
const rows = toTerminalDimension(dimensions.rows); 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) if (cols && rows)
sshSession.stream.setWindow(rows, cols, rows, cols); session?.stream?.setWindow(rows, cols, rows, cols);
break;
} }
break; case "disconnect":
} if (session) {
endSession();
case "disconnect": { send({
if (sshSession) {
if (sshSession.stream) {
sshSession.stream.end();
}
sshSession.client.end();
activeSessions.delete(sessionId);
ws.send(
JSON.stringify({
type: "disconnected", type: "disconnected",
message: "Disconnected from container", message: "Disconnected from container",
}), });
); }
} break;
break; case "ping":
send({ type: "pong" });
break;
default:
log.warn("Unknown docker console message", { type });
} }
}),
);
case "ping": { ws.on("close", cleanup);
if (ws.readyState === WebSocket.OPEN) { ws.on("error", (error: Error) => {
ws.send(JSON.stringify({ type: "pong" })); log.error("Docker console socket error", error);
} cleanup();
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,
}); });
cleanup(); };
});
ws.on("error", (error) => { ctx.ws.route("/console", onConnection, { public: true, optionalAuth: true });
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()));
} }
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"; 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, * Non-interactive SSH shells don't source ~/.zprofile or ~/.bash_profile,
* so PATH additions from installers like Homebrew or OrbStack are missing. * 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;
}
+154
View File
@@ -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;
}
+22 -71
View File
@@ -1,82 +1,33 @@
import express, { type Router } from "express"; import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend"; import type { PluginContext } from "@termix/plugin-sdk/backend";
import { setPluginSsh } from "./ssh.js"; import { createLogger } from "./helpers.js";
import { logger } from "../../../../src/backend/utils/logger.js"; import { createDockerSessions } from "./sessions.js";
import { AuthManager } from "../../../../src/backend/utils/auth-manager.js"; import { registerRoutes } from "./routes.js";
import { registerDockerContainerRoutes } from "./container-routes.js"; import { registerConsole } from "./console.js";
import { import { registerServices } from "./services.js";
sshSessions, import { normalizeImportedHost } from "./host-settings.js";
pendingTOTPSessions,
executeDockerCommand,
} from "./session-manager.js";
import {
DOCKER_TIMESTAMP_RE,
getRequestUserId,
registerDockerSshRoutes,
} from "./routes.js";
import {
closeConsoleServer,
handleConsoleUpgrade,
startConsoleServer,
} from "./console.js";
let authManagerInstance: ReturnType<typeof AuthManager.getInstance> | null = export type {
null; DockerContainerAction,
DockerContainerSummary,
DockerEvent,
DockerEventsV1,
DockerServiceV1,
} from "./services.js";
export async function activate(ctx: PluginContext) { export async function activate(ctx: PluginContext) {
setPluginSsh(ctx.ssh); const log = createLogger(ctx.log);
ctx.disposables.add(() => setPluginSsh(null)); const sessions = createDockerSessions(ctx.schedule, log);
// An express app rather than the router directly, so the existing ctx.disposables.add(() => sessions.closeAll());
// 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();
});
registerDockerSshRoutes(app); registerRoutes(ctx.http.router<Router>(), { ctx, sessions, log });
registerDockerContainerRoutes(app, { registerConsole(ctx, log);
sshSessions, registerServices(ctx, log);
pendingTOTPSessions, ctx.registry.provide("docker.hostImportNormalizer", normalizeImportedHost);
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 },
);
ctx.log.info( ctx.log.info(
"Docker mounted at /plugin-api/docker and /plugin-ws/docker/console", "Docker mounted at /plugin-api/docker and /plugin-ws/docker/console",
); );
} }
export async function deactivate() { export async function deactivate() {}
await closeConsoleServer();
authManagerInstance = null;
}
File diff suppressed because it is too large. Load diff
+256
View File
@@ -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);
});
});
});
}
+183
View File
@@ -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();
},
};
}
-36
View File
@@ -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);
}
+5 -3
View File
@@ -1,8 +1,8 @@
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
import React from "react"; import React from "react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { DockerManager } from "./DockerManager.tsx"; import { DockerManager } from "./DockerManager.tsx";
import { FullScreenAppWrapper } from "@/features/FullScreenAppWrapper.tsx"; import { toDockerHost } from "./types";
import { ConnectionScreen } from "@/components/connection/ConnectionScreen.tsx";
interface DockerAppProps { interface DockerAppProps {
hostId?: string; hostId?: string;
@@ -37,7 +37,9 @@ const DockerApp: React.FC<DockerAppProps> = ({ hostId }) => {
return ( return (
<DockerManager <DockerManager
hostConfig={hostConfig} host={toDockerHost(
hostConfig as unknown as Record<string, unknown>,
)}
title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`} title={hostConfig.name || `${hostConfig.username}@${hostConfig.ip}`}
isVisible={true} isVisible={true}
isTopbarOpen={false} isTopbarOpen={false}
+258 -433
View File
@@ -1,10 +1,5 @@
/* eslint-disable react-hooks/exhaustive-deps */ /* eslint-disable react-hooks/exhaustive-deps */
import React from "react"; 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 { import {
AlertCircle, AlertCircle,
Box, Box,
@@ -14,44 +9,46 @@ import {
RefreshCw, RefreshCw,
Search, Search,
} from "lucide-react"; } from "lucide-react";
import { import {
useTranslation,
useConnectionRetry, useConnectionRetry,
useHost,
useTranslation,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import type { SSHHost, DockerContainer, DockerValidation } from "@/types";
import { logActivity, getSSHHosts } from "@/main-axios";
import { import {
connectDockerSession, Alert,
disconnectDockerSession, AlertDescription,
listDockerContainers, Button,
validateDockerAvailability, Card,
keepaliveDockerSession, ConnectionLogProvider,
verifyDockerTOTP, ConnectionScreen,
verifyDockerWarpgate, Input,
} from "./docker-api"; 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 { ContainerList } from "./components/ContainerList.tsx";
import { ContainerTable } from "./components/ContainerTable.tsx"; import { ContainerTable } from "./components/ContainerTable.tsx";
import { ContainerDetail } from "./components/ContainerDetail.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 { interface DockerManagerProps {
hostConfig?: SSHHost; host?: DockerHost;
title?: string; title?: string;
isVisible?: boolean; isVisible?: boolean;
isTopbarOpen?: boolean; isTopbarOpen?: boolean;
@@ -59,15 +56,21 @@ interface DockerManagerProps {
onClose?: () => void; 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 { function newSessionId(): string {
message?: string; return `docker-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
connectionLogs?: ConnectionLogInput[];
} }
function DockerManagerInner({ function DockerManagerInner({
hostConfig, host,
title, title,
isVisible = true, isVisible = true,
isTopbarOpen = true, isTopbarOpen = true,
@@ -75,11 +78,24 @@ function DockerManagerInner({
onClose, onClose,
}: DockerManagerProps): React.ReactElement { }: DockerManagerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const docker = useDockerApi();
const { addLog, setLogs, clearLogs } = useConnectionLog(); const { addLog, setLogs, clearLogs } = useConnectionLog();
const { currentTab, removeTab } = useTabsSafe(); const { currentTab, removeTab } = useTabsSafe();
const dockerPrefs = useAreaPreferences("docker"); const dockerPrefs = useAreaPreferences("docker");
const uiPrefsCtx = useUiPreferencesContext(); 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 [sessionId, setSessionId] = React.useState<string | null>(null);
const [containers, setContainers] = React.useState<DockerContainer[]>([]); const [containers, setContainers] = React.useState<DockerContainer[]>([]);
const containersSignatureRef = React.useRef(""); const containersSignatureRef = React.useRef("");
@@ -105,23 +121,14 @@ function DockerManagerInner({
React.useState<DockerValidation | null>(null); React.useState<DockerValidation | null>(null);
const [isValidating, setIsValidating] = React.useState(false); const [isValidating, setIsValidating] = React.useState(false);
// Initial view follows the interface preset; switching it afterwards is a // 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">( const [viewMode, setViewMode] = React.useState<"list" | "detail">(
dockerPrefs.viewMode, dockerPrefs.viewMode,
); );
const [isLoadingContainers, setIsLoadingContainers] = React.useState(false); const [isLoadingContainers, setIsLoadingContainers] = React.useState(false);
const [hasLoadedContainersOnce, setHasLoadedContainersOnce] = const [hasLoadedContainersOnce, setHasLoadedContainersOnce] =
React.useState(false); React.useState(false);
const [totpRequired, setTotpRequired] = React.useState(false); const [prompt, setPrompt] = React.useState<PromptState | null>(null);
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 [showAuthDialog, setShowAuthDialog] = React.useState(false); const [showAuthDialog, setShowAuthDialog] = React.useState(false);
const [authReason, setAuthReason] = React.useState< const [authReason, setAuthReason] = React.useState<
"no_keyboard" | "auth_failed" | "timeout" "no_keyboard" | "auth_failed" | "timeout"
@@ -131,214 +138,168 @@ function DockerManagerInner({
const [retryCount, setRetryCount] = React.useState(0); const [retryCount, setRetryCount] = React.useState(0);
const activityLoggedRef = React.useRef(false); const activityLoggedRef = React.useRef(false);
const activityLoggingRef = React.useRef(false);
const logDockerActivity = async () => { const logDockerActivity = () => {
if ( if (!currentHost?.id || activityLoggedRef.current) return;
!currentHostConfig?.id ||
activityLoggedRef.current ||
activityLoggingRef.current
) {
return;
}
activityLoggingRef.current = true;
activityLoggedRef.current = true; activityLoggedRef.current = true;
logActivity("docker", currentHost.id, hostTitle(currentHost)).catch(() => {
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);
activityLoggedRef.current = false; activityLoggedRef.current = false;
} finally { });
activityLoggingRef.current = false;
}
}; };
React.useEffect(() => { React.useEffect(() => {
if (hostConfig?.id !== currentHostConfig?.id) { setContainers([]);
setCurrentHostConfig(hostConfig); setSelectedContainer(null);
setContainers([]); setSessionId(null);
setSelectedContainer(null); setDockerValidation(null);
setSessionId(null); setViewMode("list");
setDockerValidation(null); }, [host?.id]);
setViewMode("list");
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(() => { /** Acts on one step of a connect, whichever request it answered. */
const fetchLatestHostConfig = async () => { const applyConnectResult = async (
if (hostConfig?.id) { sid: string,
try { result: ConnectResult,
const hosts = await getSSHHosts(); clearAfter: boolean,
const updatedHost = hosts.find((h) => h.id === hostConfig.id); ) => {
if (updatedHost) { if (result.connectionLogs) setLogs(result.connectionLogs);
setCurrentHostConfig(updatedHost);
} if (result.requires_warpgate) {
} catch { setPrompt({
// Silently handle error 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 () => { const connect = async (credentials?: {
if (hostConfig?.id) { userProvidedPassword?: string;
try { userProvidedSshKey?: string;
const hosts = await getSSHHosts(); userProvidedKeyPassword?: string;
const updatedHost = hosts.find((h) => h.id === hostConfig.id); }) => {
if (updatedHost) { if (!currentHost?.id) return;
setCurrentHostConfig(updatedHost); const sid = newSessionId();
} setIsConnecting(true);
} catch { try {
// Silently handle error const result = await docker.connect(sid, currentHost.id, credentials);
} await applyConnectResult(sid, result, !credentials);
} } catch (error) {
}; reportFailure(error, t("docker.connectionFailed"));
} finally {
window.addEventListener("ssh-hosts:changed", handleHostsChanged); setIsConnecting(false);
return () => }
window.removeEventListener("ssh-hosts:changed", handleHostsChanged); };
}, [hostConfig?.id]);
const initializingRef = React.useRef(false); const initializingRef = React.useRef(false);
React.useEffect(() => { React.useEffect(() => {
const initSession = async () => { if (!currentHost?.id || !enabled) return;
if (!currentHostConfig?.id || !currentHostConfig.enableDocker) { if (initializingRef.current || sessionId) return;
return; initializingRef.current = true;
} clearLogs();
void connect();
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();
return () => { return () => {
initializingRef.current = false; 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(() => { React.useEffect(() => {
if (!sessionId || !isVisible) return; if (!sessionId || !isVisible) return;
const keepalive = setInterval( const keepalive = setInterval(
() => { () => {
keepaliveDockerSession(sessionId).catch(() => { docker.keepalive(sessionId).catch(() => {});
// Silently handle keepalive errors
});
}, },
10 * 60 * 1000, 10 * 60 * 1000,
); );
return () => clearInterval(keepalive); return () => clearInterval(keepalive);
}, [sessionId, isVisible]); }, [sessionId, isVisible]);
const refreshContainers = React.useCallback(async () => { const refreshContainers = React.useCallback(async () => {
if (!sessionId) return; if (!sessionId) return;
try { try {
const data = await listDockerContainers(sessionId, true); setContainers(await docker.listContainers(sessionId, true));
setContainers(data);
} catch { } catch {
// Silently handle polling errors // the next poll tries again
} }
}, [sessionId]); }, [sessionId, docker]);
React.useEffect(() => { React.useEffect(() => {
setHasLoadedContainersOnce(false); setHasLoadedContainersOnce(false);
@@ -350,7 +311,7 @@ function DockerManagerInner({
setIsLoadingContainers((loading) => setIsLoadingContainers((loading) =>
hasLoadedContainersOnce ? loading : true, hasLoadedContainersOnce ? loading : true,
); );
const data = await listDockerContainers(sessionId, true); const data = await docker.listContainers(sessionId, true);
const signature = JSON.stringify(data); const signature = JSON.stringify(data);
const changed = signature !== containersSignatureRef.current; const changed = signature !== containersSignatureRef.current;
containersSignatureRef.current = signature; containersSignatureRef.current = signature;
@@ -378,124 +339,48 @@ function DockerManagerInner({
setSelectedContainer(null); setSelectedContainer(null);
}, []); }, []);
const handleTotpSubmit = async (code: string) => { const closeTab = () => {
if (!totpSessionId || !code) return; if (currentTab !== null) removeTab(currentTab);
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 handleTotpCancel = () => { const handleTotpSubmit = async (code: string) => {
setTotpRequired(false); if (!prompt || prompt.kind !== "totp" || !code) return;
setTotpSessionId(null); const sid = prompt.sessionId;
setTotpPrompt(""); setPrompt(null);
setIsConnecting(false); setIsConnecting(true);
if (currentTab !== null) { try {
removeTab(currentTab); await applyConnectResult(sid, await docker.answerTotp(sid, code), false);
} catch (error) {
reportFailure(error, t("docker.totpVerificationFailed"));
} finally {
setIsConnecting(false);
} }
}; };
const handleWarpgateContinue = async () => { const handleWarpgateContinue = async () => {
if (!warpgateSessionId) return; if (!prompt || prompt.kind !== "warpgate") return;
const sid = prompt.sessionId;
setPrompt(null);
setIsConnecting(true);
try { try {
setIsConnecting(true); await applyConnectResult(sid, await docker.continueWarpgate(sid), false);
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();
}
}
} catch (error) { } catch (error) {
console.error("Warpgate verification failed:", error); reportFailure(error, t("docker.warpgateVerificationFailed"));
addLog({
type: "error",
stage: "auth",
message: t("docker.warpgateVerificationFailed"),
});
dockerConnectRetryRef.current.markFailed();
} finally { } finally {
setIsConnecting(false); setIsConnecting(false);
} }
}; };
const handleWarpgateCancel = () => { const handlePromptCancel = () => {
setWarpgateRequired(false); const sid = prompt?.sessionId;
setWarpgateSessionId(null); setPrompt(null);
setWarpgateUrl("");
setWarpgateSecurityKey("");
setIsConnecting(false); setIsConnecting(false);
if (currentTab !== null) { if (sid) docker.disconnect(sid).catch(() => {});
removeTab(currentTab); closeTab();
}
}; };
const handleWarpgateOpenUrl = () => { const handleWarpgateOpenUrl = () => {
if (warpgateUrl) { if (prompt?.url) window.open(prompt.url, "_blank", "noopener,noreferrer");
window.open(warpgateUrl, "_blank", "noopener,noreferrer");
}
}; };
const handleAuthSubmit = async (credentials: { const handleAuthSubmit = async (credentials: {
@@ -503,75 +388,12 @@ function DockerManagerInner({
sshKey?: string; sshKey?: string;
keyPassword?: string; keyPassword?: string;
}) => { }) => {
if (!currentHostConfig?.id) return;
setShowAuthDialog(false); setShowAuthDialog(false);
setIsConnecting(true); await connect({
userProvidedPassword: credentials.password,
const sid = `docker-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; userProvidedSshKey: credentials.sshKey,
userProvidedKeyPassword: credentials.keyPassword,
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);
}
}; };
const handleAuthCancel = () => { const handleAuthCancel = () => {
@@ -592,15 +414,10 @@ function DockerManagerInner({
connect: () => { connect: () => {
handleRetry(); handleRetry();
}, },
enabled: enabled: enabled && !prompt && !showAuthDialog,
!!currentHostConfig?.enableDocker &&
!totpRequired &&
!warpgateRequired &&
!showAuthDialog,
autoStart: false, autoStart: false,
}); });
const dockerConnectRetryRef = React.useRef(dockerConnectRetry); retryRef.current = dockerConnectRetry;
dockerConnectRetryRef.current = dockerConnectRetry;
const topMarginPx = isTopbarOpen ? 74 : 16; const topMarginPx = isTopbarOpen ? 74 : 16;
const leftMarginPx = 8; const leftMarginPx = 8;
@@ -619,9 +436,9 @@ function DockerManagerInner({
const containerClass = embedded const containerClass = embedded
? "h-full w-full text-foreground overflow-hidden bg-transparent" ? "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 ( return (
<div style={wrapperStyle} className={`${containerClass} relative`}> <div style={wrapperStyle} className={`${containerClass} relative`}>
<div className="h-full w-full flex flex-col flex-1 min-h-0 overflow-hidden"> <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) { if (isConnecting || isValidating) {
return ( return (
<div style={wrapperStyle} className={`${containerClass} relative`}> <div style={wrapperStyle} className={`${containerClass} relative`}>
@@ -662,6 +513,7 @@ function DockerManagerInner({
nextRetryInMs={dockerConnectRetry.nextRetryInMs} nextRetryInMs={dockerConnectRetry.nextRetryInMs}
onManualRetry={dockerConnectRetry.retryNow} onManualRetry={dockerConnectRetry.retryNow}
/> />
{dialogs}
</div> </div>
); );
} }
@@ -689,12 +541,12 @@ function DockerManagerInner({
{viewMode === "detail" && {viewMode === "detail" &&
sessionId && sessionId &&
selectedContainer && selectedContainer &&
currentHostConfig ? ( currentHost ? (
<ContainerDetail <ContainerDetail
sessionId={sessionId} sessionId={sessionId}
containerId={selectedContainer} containerId={selectedContainer}
containers={containers} containers={containers}
hostConfig={currentHostConfig} hostConfig={currentHost}
onBack={handleBack} onBack={handleBack}
initialTab={detailInitialTab} initialTab={detailInitialTab}
/> />
@@ -833,34 +685,7 @@ function DockerManagerInner({
</div> </div>
)} )}
</div> </div>
<TOTPDialog {dialogs}
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,
}}
/>
)}
<ConnectionScreen <ConnectionScreen
status={dockerConnectRetry.status} status={dockerConnectRetry.status}
message={t("docker.connecting")} message={t("docker.connecting")}
+15 -50
View File
@@ -1,44 +1,24 @@
import { useState, useEffect } from "react";
import { Box } from "lucide-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 { 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 { DockerManager } from "./DockerManager";
import type { SSHHost } from "@/types/index"; import {
import { WidgetTitle } from "@/features/homepage/widgets/WidgetTitle"; GRID_SIZE,
type DockerWidgetConfig,
type WidgetComponentProps,
type WidgetDefinition,
} from "./homepage";
import { dockerEnabled, hostTitle, toDockerHost } from "./types";
function DockerWidget({ function DockerWidget({
widget, widget,
config, config,
}: WidgetComponentProps<DockerWidgetConfig>) { }: WidgetComponentProps<DockerWidgetConfig>) {
const { t } = useTranslation(); const { t } = useTranslation();
const [host, setHost] = useState<SSHHostWithStatus | null>(null); const record = useHost(config.hostId || undefined);
const [loading, setLoading] = useState(true);
useEffect(() => { if (!config.hostId || !record || !dockerEnabled(record)) {
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) {
return ( return (
<div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60"> <div className="flex flex-col items-center justify-center w-full h-full gap-2 text-muted-foreground/60">
<Box size={20} /> <Box size={20} />
@@ -47,22 +27,7 @@ function DockerWidget({
); );
} }
if (loading) { const host = toDockerHost(record as unknown as Record<string, unknown>);
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>
);
}
return ( return (
<div className="flex flex-col w-full h-full overflow-hidden"> <div className="flex flex-col w-full h-full overflow-hidden">
<WidgetTitle title={widget.title} icon={<Box size={11} />} /> <WidgetTitle title={widget.title} icon={<Box size={11} />} />
@@ -71,8 +36,8 @@ function DockerWidget({
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
<DockerManager <DockerManager
hostConfig={host as unknown as SSHHost} host={host}
title={host.name || host.ip} title={hostTitle(host)}
isVisible={true} isVisible={true}
isTopbarOpen={false} isTopbarOpen={false}
embedded={true} embedded={true}
@@ -83,7 +48,7 @@ function DockerWidget({
} }
/** Registered by the plugin while it runs. */ /** Registered by the plugin while it runs. */
export const dockerWidget: WidgetTypeDefinition<DockerWidgetConfig> = { export const dockerWidget: WidgetDefinition<DockerWidgetConfig> = {
id: "docker_widget", id: "docker_widget",
name: "Docker Manager", name: "Docker Manager",
description: "Embedded Docker container manager for a configured host", description: "Embedded Docker container manager for a configured host",
@@ -1,18 +1,37 @@
import type { import { useHosts, useTranslation } from "@termix/plugin-sdk/frontend";
DockerWidgetConfig, import { Select2 } from "@termix/plugin-sdk/ui";
WidgetEditFormProps, import type { DockerWidgetConfig, WidgetEditFormProps } from "./homepage";
} from "@/types/homepage-types"; import { dockerEnabled } from "./types";
import { SingleHostEditForm } from "@/features/homepage/dialogs/SingleHostEditForm";
export function DockerWidgetEditForm({ export function DockerWidgetEditForm({
config, config,
onChange, onChange,
}: WidgetEditFormProps<DockerWidgetConfig>) { }: WidgetEditFormProps<DockerWidgetConfig>) {
const { t } = useTranslation();
const { hosts } = useHosts();
const options = hosts.filter(
(host) => host.enableSsh !== false && dockerEnabled(host),
);
return ( return (
<SingleHostEditForm <div className="flex flex-col gap-1">
hostId={config.hostId} <label className="text-xs font-medium text-muted-foreground">
onChange={(hostId) => onChange({ ...config, hostId })} {t("homepage.host")}
filter={(h) => !!(h.enableSsh && h.enableDocker)} </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 React from "react";
import { useXTerm } from "react-xtermjs"; import { useXTerm } from "react-xtermjs";
import { FitAddon } from "@xterm/addon-fit"; import { FitAddon } from "@xterm/addon-fit";
import { ClipboardAddon } from "@xterm/addon-clipboard"; 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 { 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 { Terminal as TerminalIcon, Power, PowerOff } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import type { SSHHost } from "@/types";
import { isElectron } from "@/main-axios.ts";
import { import {
useTranslation, useTranslation,
useConnectionRetry, useConnectionRetry,
} from "@termix/plugin-sdk/frontend"; } 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 { interface ConsoleTerminalProps {
containerId: string; containerId: string;
containerName: string; containerName: string;
containerState: string; containerState: string;
hostConfig: SSHHost; hostConfig: DockerHost;
} }
export function ConsoleTerminal( export function ConsoleTerminal(
@@ -58,7 +61,12 @@ function ConsoleTerminalInner({
const { addLog, clearLogs } = useConnectionLog(); const { addLog, clearLogs } = useConnectionLog();
const terminalConfig = React.useMemo( const terminalConfig = React.useMemo(
() => ({ ...DEFAULT_TERMINAL_CONFIG, ...hostConfig.terminalConfig }), () => ({
...DEFAULT_TERMINAL_CONFIG,
...(hostConfig.terminalConfig as Partial<
typeof DEFAULT_TERMINAL_CONFIG
> | null),
}),
[hostConfig.terminalConfig], [hostConfig.terminalConfig],
); );
@@ -321,7 +329,11 @@ function ConsoleTerminalInner({
JSON.stringify({ JSON.stringify({
type: "connect", type: "connect",
data: { data: {
hostConfig, hostConfig: {
id: hostConfig.id,
syncId: hostConfig.syncId ?? null,
ip: hostConfig.ip,
},
containerId, containerId,
shell: selectedShell, shell: selectedShell,
cols, 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 React from "react";
import { Card } from "@/components/card.tsx";
import { Button } from "@/components/button.tsx";
import { import {
Box, Box,
Play, Play,
@@ -14,16 +14,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types"; import { useDockerApi } from "../docker-api";
import {
startDockerContainer,
stopDockerContainer,
restartDockerContainer,
pauseDockerContainer,
unpauseDockerContainer,
removeDockerContainer,
} from "../docker-api";
import { useConfirmation } from "@/hooks/use-confirmation.ts";
interface ContainerCardProps { interface ContainerCardProps {
container: DockerContainer; container: DockerContainer;
@@ -61,6 +52,7 @@ export function ContainerCard({
}: ContainerCardProps): React.ReactElement { }: ContainerCardProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const { confirmWithToast } = useConfirmation(); const { confirmWithToast } = useConfirmation();
const docker = useDockerApi();
const [isStarting, setIsStarting] = React.useState(false); const [isStarting, setIsStarting] = React.useState(false);
const [isStopping, setIsStopping] = React.useState(false); const [isStopping, setIsStopping] = React.useState(false);
const [isRestarting, setIsRestarting] = React.useState(false); const [isRestarting, setIsRestarting] = React.useState(false);
@@ -82,7 +74,7 @@ export function ContainerCard({
e.stopPropagation(); e.stopPropagation();
setIsStarting(true); setIsStarting(true);
try { try {
await startDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName })); toast.success(t("docker.containerStarted", { name: containerName }));
onRefresh?.(); onRefresh?.();
} catch (err) { } catch (err) {
@@ -100,7 +92,7 @@ export function ContainerCard({
e.stopPropagation(); e.stopPropagation();
setIsStopping(true); setIsStopping(true);
try { try {
await stopDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName })); toast.success(t("docker.containerStopped", { name: containerName }));
onRefresh?.(); onRefresh?.();
} catch (err) { } catch (err) {
@@ -118,7 +110,7 @@ export function ContainerCard({
e.stopPropagation(); e.stopPropagation();
setIsRestarting(true); setIsRestarting(true);
try { try {
await restartDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "restart");
toast.success(t("docker.containerRestarted", { name: containerName })); toast.success(t("docker.containerRestarted", { name: containerName }));
onRefresh?.(); onRefresh?.();
} catch (err) { } catch (err) {
@@ -137,10 +129,10 @@ export function ContainerCard({
setIsPausing(true); setIsPausing(true);
try { try {
if (container.state === "paused") { if (container.state === "paused") {
await unpauseDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "unpause");
toast.success(t("docker.containerUnpaused", { name: containerName })); toast.success(t("docker.containerUnpaused", { name: containerName }));
} else { } else {
await pauseDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "pause");
toast.success(t("docker.containerPaused", { name: containerName })); toast.success(t("docker.containerPaused", { name: containerName }));
} }
onRefresh?.(); onRefresh?.();
@@ -166,7 +158,7 @@ export function ContainerCard({
async () => { async () => {
setIsRemoving(true); setIsRemoving(true);
try { try {
await removeDockerContainer( await docker.removeContainer(
sessionId, sessionId,
container.id, container.id,
container.state === "running", 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 React from "react";
import { Button } from "@/components/button.tsx";
import { Card } from "@/components/card.tsx";
import { Separator } from "@/components/separator.tsx";
import { import {
Activity, Activity,
ArrowLeft, ArrowLeft,
@@ -11,7 +10,6 @@ import {
Terminal, Terminal,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer, SSHHost } from "@/types";
import { LogViewer } from "./LogViewer.tsx"; import { LogViewer } from "./LogViewer.tsx";
import { ContainerStats } from "./ContainerStats.tsx"; import { ContainerStats } from "./ContainerStats.tsx";
import { ConsoleTerminal } from "./ConsoleTerminal.tsx"; import { ConsoleTerminal } from "./ConsoleTerminal.tsx";
@@ -23,7 +21,7 @@ interface ContainerDetailProps {
sessionId: string; sessionId: string;
containerId: string; containerId: string;
containers: DockerContainer[]; containers: DockerContainer[];
hostConfig: SSHHost; hostConfig: DockerHost;
onBack: () => void; onBack: () => void;
initialTab?: DetailTab; initialTab?: DetailTab;
} }
@@ -1,7 +1,7 @@
import type { DockerContainer } from "../types";
import React from "react"; import React from "react";
import { Box } from "lucide-react"; import { Box } from "lucide-react";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerContainer } from "@/types";
import { ContainerCard } from "./ContainerCard.tsx"; import { ContainerCard } from "./ContainerCard.tsx";
interface ContainerListProps { 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 React from "react";
import { import {
Activity, Activity,
@@ -9,12 +11,9 @@ import {
Network, Network,
RefreshCw, RefreshCw,
} from "lucide-react"; } from "lucide-react";
import type { DockerStats } from "@/types"; import { useDockerApi } from "../docker-api";
import { getContainerStats } from "../docker-api";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { SectionCard } from "@/components/section-card";
import { DockerBadge } from "./ContainerCard.tsx"; import { DockerBadge } from "./ContainerCard.tsx";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
interface ContainerStatsProps { interface ContainerStatsProps {
sessionId: string; sessionId: string;
@@ -30,6 +29,7 @@ export function ContainerStats({
containerState, containerState,
}: ContainerStatsProps): React.ReactElement { }: ContainerStatsProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const docker = useDockerApi();
const [stats, setStats] = React.useState<DockerStats | null>(null); const [stats, setStats] = React.useState<DockerStats | null>(null);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -41,7 +41,7 @@ export function ContainerStats({
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const data = await getContainerStats(sessionId, containerId); const data = await docker.stats(sessionId, containerId);
const previous = statsRef.current; const previous = statsRef.current;
const changed = const changed =
!previous || !previous ||
@@ -58,7 +58,7 @@ export function ContainerStats({
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [sessionId, containerId, containerState, t]); }, [sessionId, containerId, containerState, t, docker]);
useAdaptivePolling( useAdaptivePolling(
fetchStats, fetchStats,
@@ -246,7 +246,7 @@ export function ContainerStats({
</span> </span>
<DockerBadge <DockerBadge
state={ state={
containerState as import("@/types").DockerContainer["state"] containerState as import("../types").DockerContainer["state"]
} }
/> />
</div> </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 React from "react";
import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react"; import { Box, List, Play, RefreshCw, Square, Terminal } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import { Button } from "@/components/button.tsx"; import { useDockerApi } from "../docker-api";
import type { DockerContainer } from "@/types";
import { startDockerContainer, stopDockerContainer } from "../docker-api";
import { DockerBadge } from "./ContainerCard.tsx"; import { DockerBadge } from "./ContainerCard.tsx";
type DetailTab = "logs" | "stats" | "console"; type DetailTab = "logs" | "stats" | "console";
@@ -30,6 +30,7 @@ export function ContainerTable({
statusFilter = "all", statusFilter = "all",
}: ContainerTableProps): React.ReactElement { }: ContainerTableProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const docker = useDockerApi();
const [pendingId, setPendingId] = React.useState<string | null>(null); const [pendingId, setPendingId] = React.useState<string | null>(null);
const filtered = React.useMemo(() => { const filtered = React.useMemo(() => {
@@ -56,10 +57,10 @@ export function ContainerTable({
setPendingId(container.id); setPendingId(container.id);
try { try {
if (container.state === "running") { if (container.state === "running") {
await stopDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "stop");
toast.success(t("docker.containerStopped", { name: containerName })); toast.success(t("docker.containerStopped", { name: containerName }));
} else { } else {
await startDockerContainer(sessionId, container.id); await docker.containerAction(sessionId, container.id, "start");
toast.success(t("docker.containerStarted", { name: containerName })); toast.success(t("docker.containerStarted", { name: containerName }));
} }
onRefresh?.(); 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 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 { Download, RefreshCw, Search, Trash2 } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { useTranslation } from "@termix/plugin-sdk/frontend"; import { useTranslation } from "@termix/plugin-sdk/frontend";
import type { DockerLogOptions } from "@/types"; import { useDockerApi } from "../docker-api";
import { getContainerLogs, downloadContainerLogs } from "../docker-api";
import { useAdaptivePolling } from "@/hooks/use-adaptive-polling.ts";
import { Select2 } from "@/components/select2";
interface LogViewerProps { interface LogViewerProps {
sessionId: string; sessionId: string;
@@ -60,6 +62,7 @@ export function LogViewer({
containerName, containerName,
}: LogViewerProps): React.ReactElement { }: LogViewerProps): React.ReactElement {
const { t } = useTranslation(); const { t } = useTranslation();
const docker = useDockerApi();
const [rawLogs, setRawLogs] = React.useState<string[]>([]); const [rawLogs, setRawLogs] = React.useState<string[]>([]);
const [isLoading, setIsLoading] = React.useState(false); const [isLoading, setIsLoading] = React.useState(false);
const [isDownloading, setIsDownloading] = React.useState(false); const [isDownloading, setIsDownloading] = React.useState(false);
@@ -78,7 +81,7 @@ export function LogViewer({
tail: tailLines === "all" ? undefined : parseInt(tailLines, 10), tail: tailLines === "all" ? undefined : parseInt(tailLines, 10),
timestamps: showTimestamps, 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 next = data.logs.split("\n").filter(Boolean);
const changed = const changed =
next.length !== rawLogsRef.current.length || next.length !== rawLogsRef.current.length ||
@@ -89,11 +92,13 @@ export function LogViewer({
} }
return changed; return changed;
} catch (error) { } catch (error) {
toast.error(`Failed to fetch logs: ${getErrorMessage(error)}`); toast.error(
t("docker.failedToFetchLogs", { error: getErrorMessage(error) }),
);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [sessionId, containerId, tailLines, showTimestamps]); }, [sessionId, containerId, tailLines, showTimestamps, docker, t]);
React.useEffect(() => { React.useEffect(() => {
fetchLogs(); fetchLogs();
@@ -120,9 +125,10 @@ export function LogViewer({
const handleDownload = async () => { const handleDownload = async () => {
setIsDownloading(true); setIsDownloading(true);
try { try {
const blob = await downloadContainerLogs(sessionId, containerId, { const data = await docker.logs(sessionId, containerId, {
timestamps: showTimestamps, timestamps: showTimestamps,
}); });
const blob = new Blob([data.logs], { type: "text/plain" });
const url = window.URL.createObjectURL(blob); const url = window.URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = url;
@@ -133,7 +139,9 @@ export function LogViewer({
document.body.removeChild(a); document.body.removeChild(a);
toast.success(t("docker.logsDownloaded")); toast.success(t("docker.logsDownloaded"));
} catch (error) { } catch (error) {
toast.error(`Failed to download logs: ${getErrorMessage(error)}`); toast.error(
t("docker.failedToDownloadLogs", { error: getErrorMessage(error) }),
);
} finally { } finally {
setIsDownloading(false); setIsDownloading(false);
} }
+139 -314
View File
@@ -1,345 +1,170 @@
import axios from "axios"; import { useMemo } from "react";
import { dockerApi, handleApiError } from "@/main-axios"; import {
usePluginApi,
type PluginApiClient,
} from "@termix/plugin-sdk/frontend";
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
import type { import type {
DockerContainer, DockerContainer,
DockerLogOptions, DockerLogOptions,
DockerStats, DockerStats,
DockerValidation, DockerValidation,
} from "@/types/index"; } from "./types";
type ApiConnectionLog = { export interface ApiConnectionLog {
type: "info" | "success" | "warning" | "error"; type: "info" | "success" | "warning" | "error";
stage: string; stage: ConnectionStage;
message: string; message: string;
details?: Record<string, unknown>; details?: string;
}; }
type ConnectErrorResponse = { /** One step of a connect: connected, a prompt to answer, or auth needed. */
error?: string; export interface ConnectResult {
message?: string; success?: boolean;
connectionLogs?: ApiConnectionLog[];
requires_totp?: boolean;
requires_warpgate?: boolean;
sessionId?: string;
prompt?: string;
url?: string;
securityKey?: string;
status?: string; status?: string;
reason?: 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; message?: string;
requires_totp?: boolean; requires_totp?: boolean;
prompt?: string; prompt?: string;
isPassword?: boolean; isPassword?: boolean;
status?: string; retry?: boolean;
reason?: string;
connectionLogs?: ApiConnectionLog[];
requires_warpgate?: boolean; requires_warpgate?: boolean;
url?: string; url?: string | null;
securityKey?: string; securityKey?: string;
}> { connectionLogs?: ApiConnectionLog[];
try { }
const response = await dockerApi.post("/ssh/connect", {
sessionId, /** An API failure carrying the server's message and connection logs. */
hostId, export class DockerApiError extends Error {
...config, constructor(
}); message: string,
return response.data; readonly status?: number,
} catch (error: unknown) { readonly connectionLogs?: ApiConnectionLog[],
if ( ) {
axios.isAxiosError<ConnectErrorResponse>(error) && super(message);
error.response?.data?.status === "auth_required" this.name = "DockerApiError";
) { }
return error.response.data; }
function apiError(error: unknown, action: string): DockerApiError {
const response = (
error as {
response?: {
status?: number;
data?: {
error?: string;
message?: string;
connectionLogs?: ApiConnectionLog[];
};
};
} }
if ( )?.response;
axios.isAxiosError<ConnectErrorResponse>(error) && const data = response?.data;
error.response?.data?.requires_totp const message =
) { data?.error ||
return error.response.data; data?.message ||
} (error instanceof Error ? error.message : `Failed to ${action}`);
if ( return new DockerApiError(message, response?.status, data?.connectionLogs);
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");
}
} }
export async function verifyDockerTOTP( async function call<T>(
sessionId: string, action: string,
totpCode: string, request: () => Promise<{ data: T }>,
): Promise<{ status: string; message: string }> { ): Promise<T> {
try { try {
const response = await dockerApi.post("/ssh/connect-totp", { return (await request()).data;
sessionId,
totpCode,
});
return response.data;
} catch (error) { } catch (error) {
throw handleApiError(error, "verify Docker TOTP"); throw apiError(error, action);
} }
} }
export async function verifyDockerWarpgate( export type ContainerAction =
sessionId: string, "start" | "stop" | "restart" | "pause" | "unpause";
): 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 async function disconnectDockerSession( export function createDockerApi(api: PluginApiClient) {
sessionId: string, return {
): Promise<{ success: boolean; message: string }> { connect: (
try { sessionId: string,
const response = await dockerApi.post("/ssh/disconnect", { hostId: number,
sessionId, credentials?: {
}); userProvidedPassword?: string;
return response.data; userProvidedSshKey?: string;
} catch (error) { userProvidedKeyPassword?: string;
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 },
}, },
); ) =>
return response.data; call<ConnectResult>("connect to Docker", () =>
} catch (error) { api.post("/ssh/connect", { sessionId, hostId, ...credentials }),
throw handleApiError(error, "remove Docker container"); ),
}
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( export type DockerApi = ReturnType<typeof createDockerApi>;
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 async function downloadContainerLogs( /** The Docker API on this plugin's own client. */
sessionId: string, export function useDockerApi(): DockerApi {
containerId: string, const api = usePluginApi();
options?: DockerLogOptions, return useMemo(() => createDockerApi(api), [api]);
): 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");
}
} }
@@ -0,0 +1,6 @@
export function getErrorMessage(
error: unknown,
fallback = "Unknown error",
): string {
return error instanceof Error ? error.message : fallback;
}
+24
View File
@@ -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 };
+11 -24
View File
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
import { Box } from "lucide-react"; import { Box } from "lucide-react";
import type { import type {
HomepageWidgetContribution, HomepageWidgetContribution,
HostEditorSectionProps,
StandaloneViewProps, StandaloneViewProps,
TabProps, TabProps,
TermixApp, TermixApp,
} from "@termix/plugin-sdk/frontend"; } from "@termix/plugin-sdk/frontend";
import type { SSHHost } from "@/types";
import { DockerManager } from "./DockerManager"; import { DockerManager } from "./DockerManager";
import DockerApp from "./DockerApp"; import DockerApp from "./DockerApp";
import { HostDockerTab } from "./HostDockerTab";
import { dockerWidget } from "./DockerWidget"; 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 ( return (
<DockerManager <DockerManager
hostConfig={sshHost as unknown as SSHHost} host={record ? toDockerHost(record) : undefined}
title={label} title={label}
isVisible={isVisible} isVisible={isVisible}
isTopbarOpen={false} isTopbarOpen={false}
@@ -29,16 +28,13 @@ function DockerStandalone({ hostId }: StandaloneViewProps) {
return <DockerApp hostId={hostId} />; 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 { 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, { app.registerTab("docker", DockerTab, {
icon: Box, icon: Box,
titleKey: "nav.docker", titleKey: "nav.docker",
@@ -58,16 +54,7 @@ export function activate(app: TermixApp): void {
order: 30, order: 30,
tabType: "docker", tabType: "docker",
copyUrlView: "docker", copyUrlView: "docker",
when: (host) => !!host.enableSsh && !!host.enableDocker, when: (host) => canUse && !!host.enableSsh && dockerEnabled(host),
});
app.registerHostEditorSection({
id: "docker",
group: "ssh",
titleKey: "hosts.tabDocker",
icon: Box,
order: 30,
component: DockerHostSection,
}); });
app.registerHomepageWidget( app.registerHomepageWidget(
+86
View File
@@ -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/);
});
});
+84
View File
@@ -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" },
]);
});
});
+282
View File
@@ -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,
});
});
});
+116
View File
@@ -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()) { for (const id of rendered.registered.dashboardCards()) {
expect(cards).toContain(id); 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 () => { it("removes everything it registered on deactivate", async () => {
-6
View File
@@ -1,10 +1,4 @@
{ {
"extends": "@termix/plugin-sdk/tsconfig.plugin.json", "extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"compilerOptions": {
"paths": {
"@/types/*": ["../../src/types/*"],
"@/*": ["../../src/ui/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"] "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