mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
feat: finish the docker plugin (B15)
This commit is contained in:
1 parent
0a3f05d99d
commit
993a91c899
167 files changed
+26299
-5136
No files matched your search
+3
-1
@@ -1 +1,3 @@
|
|||||||
npx lint-staged
|
# The .cmd shims on Windows add their own paths to each command, so keep
|
||||||
|
# lint-staged's chunks well under cmd.exe's 8191 character limit.
|
||||||
|
npx lint-staged --max-arg-length 4000
|
||||||
@@ -161,7 +161,7 @@ http {
|
|||||||
root /app/html;
|
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
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- ssh_data.enable_docker and docker_config moved to the docker plugin's host
|
||||||
|
-- settings. The copy runs at boot, after this migration, so a real DROP
|
||||||
|
-- COLUMN here would delete the data first. The columns stay in place,
|
||||||
|
-- unused, until 3.0.0.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -316,6 +316,13 @@
|
|||||||
"when": 1790266402855,
|
"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
@@ -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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- ssh_data.enable_docker and docker_config moved to the docker plugin's host
|
||||||
|
-- settings. The copy runs at boot, after this migration, so a real DROP
|
||||||
|
-- COLUMN here would delete the data first. The columns stay in place,
|
||||||
|
-- unused, until 3.0.0.
|
||||||
|
SELECT 1;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -295,6 +295,13 @@
|
|||||||
"when": 1790266401908,
|
"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
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -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 };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type { PluginServices } from "@termix/plugin-sdk/backend";
|
||||||
|
|
||||||
|
let current: PluginServices | null = null;
|
||||||
|
|
||||||
|
/** Set in activate, cleared on deactivate. */
|
||||||
|
export function setDockerServices(services: PluginServices | null): void {
|
||||||
|
current = services;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DockerEventName = "exited" | "started" | "unhealthy" | "restarting";
|
||||||
|
|
||||||
|
export interface DockerEvent {
|
||||||
|
hostId: number;
|
||||||
|
container: string;
|
||||||
|
event: DockerEventName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The docker plugin's "docker.containers" service, version 1. */
|
||||||
|
interface DockerService {
|
||||||
|
action: (
|
||||||
|
hostId: number,
|
||||||
|
container: string,
|
||||||
|
action: "start" | "stop" | "restart",
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The docker plugin's "docker.events" service, version 1. */
|
||||||
|
interface DockerEventsService {
|
||||||
|
subscribe: (
|
||||||
|
hostId: number,
|
||||||
|
listener: (event: DockerEvent) => void,
|
||||||
|
) => Promise<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UNAVAILABLE = "The docker plugin is not available";
|
||||||
|
|
||||||
|
function service<T extends object>(
|
||||||
|
name: string,
|
||||||
|
userId: string,
|
||||||
|
): Partial<T> | null {
|
||||||
|
try {
|
||||||
|
return current?.get<T>(name, { userId }) ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts, stops or restarts a container as userId. Optional: without the
|
||||||
|
* docker plugin the step fails with a clear message.
|
||||||
|
*/
|
||||||
|
export async function runDockerAction(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
container: string,
|
||||||
|
action: "start" | "stop" | "restart",
|
||||||
|
): Promise<{ ok: boolean; unavailable?: boolean; error?: string }> {
|
||||||
|
const docker = service<DockerService>("docker.containers", userId);
|
||||||
|
if (!docker || typeof docker.action !== "function") {
|
||||||
|
return { ok: false, unavailable: true, error: UNAVAILABLE };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await docker.action(hostId, container, action);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribes `listener` to container changes on a host as userId, or
|
||||||
|
* resolves null while the docker plugin is off.
|
||||||
|
*/
|
||||||
|
export async function subscribeDockerEvents(
|
||||||
|
userId: string,
|
||||||
|
hostId: number,
|
||||||
|
listener: (event: DockerEvent) => void,
|
||||||
|
): Promise<(() => void) | null> {
|
||||||
|
const events = service<DockerEventsService>("docker.events", userId);
|
||||||
|
if (!events || typeof events.subscribe !== "function") return null;
|
||||||
|
return events.subscribe(hostId, listener);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import type { PluginContext } from "@termix/plugin-sdk/backend";
|
|||||||
import { setPluginSsh } from "./ssh.js";
|
import { 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.
|
||||||
|
|||||||
@@ -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([]);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Подман"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "পডম্যান"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Πόντμαν"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "פודמן"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Подман"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "พอดแมน"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Подман"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -0,0 +1,404 @@
|
|||||||
|
/**
|
||||||
|
* The Docker panel's interactive connect.
|
||||||
|
*
|
||||||
|
* Core's pipeline does the handshake; this only bridges its prompt channel to
|
||||||
|
* HTTP. A connect that needs a person (a TOTP code, a Warpgate sign-in, a
|
||||||
|
* password the host does not store) answers the request that is waiting with
|
||||||
|
* what to ask, and the next request (connect-totp, connect-warpgate) carries
|
||||||
|
* the answer back and waits for whatever comes after it: the session, another
|
||||||
|
* prompt, or an error.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type {
|
||||||
|
PluginContext,
|
||||||
|
PluginSshHost,
|
||||||
|
PluginSshPromptRequest,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import type { ContainerRuntime } from "./container-runtime.js";
|
||||||
|
import type { DockerSessions } from "./sessions.js";
|
||||||
|
import {
|
||||||
|
connectionLog,
|
||||||
|
getErrorMessage,
|
||||||
|
type ConnectionLogLine,
|
||||||
|
type DockerLogger,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
/** What one HTTP request answers with. */
|
||||||
|
export interface ConnectStep {
|
||||||
|
status: number;
|
||||||
|
body: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOTP_WAIT_MS = 3 * 60 * 1000;
|
||||||
|
const WARPGATE_WAIT_MS = 5 * 60 * 1000;
|
||||||
|
const STEP_WAIT_MS = 90 * 1000;
|
||||||
|
// Covers the longest a person may take on a prompt, plus the handshake.
|
||||||
|
const CONNECT_TIMEOUT_MS = WARPGATE_WAIT_MS + 30 * 1000;
|
||||||
|
|
||||||
|
const PASSWORD_PROMPT = /password/i;
|
||||||
|
|
||||||
|
/** Hands each step to whichever request is waiting for one. */
|
||||||
|
export class PendingConnect {
|
||||||
|
constructor(
|
||||||
|
readonly userId: string,
|
||||||
|
readonly hostId: number,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
expiresAt = Date.now() + TOTP_WAIT_MS;
|
||||||
|
/** What the open prompt expects, or null when nothing is being asked. */
|
||||||
|
awaiting: "totp" | "warpgate" | null = null;
|
||||||
|
abandoned = false;
|
||||||
|
private waiter: ((step: ConnectStep) => void) | null = null;
|
||||||
|
private queued: ConnectStep | null = null;
|
||||||
|
private answer: ((value: string | null) => void) | null = null;
|
||||||
|
|
||||||
|
emit(step: ConnectStep): void {
|
||||||
|
if (this.waiter) {
|
||||||
|
const waiter = this.waiter;
|
||||||
|
this.waiter = null;
|
||||||
|
waiter(step);
|
||||||
|
} else {
|
||||||
|
this.queued = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next(timeoutMs = STEP_WAIT_MS): Promise<ConnectStep> {
|
||||||
|
if (this.queued) {
|
||||||
|
const step = this.queued;
|
||||||
|
this.queued = null;
|
||||||
|
return Promise.resolve(step);
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (this.waiter !== settle) return;
|
||||||
|
this.waiter = null;
|
||||||
|
this.cancel();
|
||||||
|
resolve({
|
||||||
|
status: 408,
|
||||||
|
body: { error: "Connection timed out. Please reconnect." },
|
||||||
|
});
|
||||||
|
}, timeoutMs);
|
||||||
|
const settle = (step: ConnectStep) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
resolve(step);
|
||||||
|
};
|
||||||
|
this.waiter = settle;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ask(kind: "totp" | "warpgate", waitMs: number): Promise<string | null> {
|
||||||
|
this.awaiting = kind;
|
||||||
|
this.expiresAt = Date.now() + waitMs;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
this.answer = resolve;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
respond(value: string | null): void {
|
||||||
|
const answer = this.answer;
|
||||||
|
this.answer = null;
|
||||||
|
this.awaiting = null;
|
||||||
|
answer?.(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nobody will read the result: give up the prompt and drop the client. */
|
||||||
|
cancel(): void {
|
||||||
|
this.abandoned = true;
|
||||||
|
this.respond(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyConnectError(
|
||||||
|
message: string,
|
||||||
|
logs: ConnectionLogLine[],
|
||||||
|
): { hostKeyChanged: boolean; authFailed: boolean } {
|
||||||
|
if (message.includes("ENOTFOUND") || message.includes("getaddrinfo")) {
|
||||||
|
logs.push(
|
||||||
|
connectionLog("error", "dns", `DNS resolution failed: ${message}`),
|
||||||
|
);
|
||||||
|
} else if (
|
||||||
|
message.includes("ECONNREFUSED") ||
|
||||||
|
message.includes("ETIMEDOUT")
|
||||||
|
) {
|
||||||
|
logs.push(
|
||||||
|
connectionLog("error", "tcp", `TCP connection failed: ${message}`),
|
||||||
|
);
|
||||||
|
} else if (message.includes("verification failed")) {
|
||||||
|
logs.push(
|
||||||
|
connectionLog(
|
||||||
|
"error",
|
||||||
|
"handshake",
|
||||||
|
"SSH host key has changed. For security, please open a Terminal connection to this host first to verify and accept the new key fingerprint.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return { hostKeyChanged: true, authFailed: false };
|
||||||
|
} else if (
|
||||||
|
message.includes("handshake") ||
|
||||||
|
message.includes("key exchange")
|
||||||
|
) {
|
||||||
|
logs.push(
|
||||||
|
connectionLog("error", "handshake", `SSH handshake failed: ${message}`),
|
||||||
|
);
|
||||||
|
} else if (/authentication/i.test(message)) {
|
||||||
|
logs.push(
|
||||||
|
connectionLog("error", "auth", `Authentication failed: ${message}`),
|
||||||
|
);
|
||||||
|
return { hostKeyChanged: false, authFailed: true };
|
||||||
|
} else {
|
||||||
|
logs.push(
|
||||||
|
connectionLog("error", "error", `SSH connection failed: ${message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { hostKeyChanged: false, authFailed: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StartConnectInput {
|
||||||
|
sessionId: string;
|
||||||
|
userId: string;
|
||||||
|
host: PluginSshHost;
|
||||||
|
runtime: ContainerRuntime;
|
||||||
|
logs: ConnectionLogLine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts a connect and returns the first step for the request that asked.
|
||||||
|
* The pending entry lives in `sessions` until the connect settles, so the
|
||||||
|
* answer routes can find it.
|
||||||
|
*/
|
||||||
|
export async function startConnect(
|
||||||
|
ctx: PluginContext,
|
||||||
|
sessions: DockerSessions,
|
||||||
|
log: DockerLogger,
|
||||||
|
input: StartConnectInput,
|
||||||
|
): Promise<ConnectStep> {
|
||||||
|
const { sessionId, userId, host, runtime, logs } = input;
|
||||||
|
const hostId = host.id;
|
||||||
|
const authType = (host.authType as string) || "none";
|
||||||
|
const credentialless = !ctx.ssh.requiresSecret(authType);
|
||||||
|
|
||||||
|
const pending = new PendingConnect(userId, hostId);
|
||||||
|
sessions.setPending(sessionId, pending);
|
||||||
|
|
||||||
|
const parkTotp = (prompt: string, extra: Record<string, unknown>) => {
|
||||||
|
logs.push(connectionLog("info", "docker_auth", "Verification required"));
|
||||||
|
const answer = pending.ask("totp", TOTP_WAIT_MS);
|
||||||
|
pending.emit({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
requires_totp: true,
|
||||||
|
sessionId,
|
||||||
|
prompt,
|
||||||
|
connectionLogs: logs,
|
||||||
|
...extra,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return answer;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ask = async (
|
||||||
|
request: PluginSshPromptRequest,
|
||||||
|
): Promise<string | null> => {
|
||||||
|
if (pending.abandoned) return null;
|
||||||
|
switch (request.kind) {
|
||||||
|
case "totp":
|
||||||
|
return parkTotp(request.prompt, request.retry ? { retry: true } : {});
|
||||||
|
case "warpgate": {
|
||||||
|
logs.push(
|
||||||
|
connectionLog(
|
||||||
|
"info",
|
||||||
|
"docker_auth",
|
||||||
|
"Warpgate authentication required",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const answer = pending.ask("warpgate", WARPGATE_WAIT_MS);
|
||||||
|
pending.emit({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
requires_warpgate: true,
|
||||||
|
sessionId,
|
||||||
|
url: request.url,
|
||||||
|
securityKey: request.securityKey,
|
||||||
|
connectionLogs: logs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return answer;
|
||||||
|
}
|
||||||
|
case "input": {
|
||||||
|
const isPassword = PASSWORD_PROMPT.test(request.prompt);
|
||||||
|
if (isPassword && credentialless) {
|
||||||
|
pending.abandoned = true;
|
||||||
|
pending.emit({
|
||||||
|
status: 200,
|
||||||
|
body: { status: "auth_required", reason: "no_keyboard" },
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!isPassword && !request.isPush) return "";
|
||||||
|
return parkTotp(request.prompt, isPassword ? { isPassword: true } : {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
logs.push(connectionLog("info", "dns", `Resolving DNS for ${host.ip}`));
|
||||||
|
logs.push(
|
||||||
|
connectionLog("info", "tcp", `Connecting to ${host.ip}:${host.port}`),
|
||||||
|
);
|
||||||
|
logs.push(connectionLog("info", "handshake", "Initiating SSH handshake"));
|
||||||
|
|
||||||
|
ctx.ssh
|
||||||
|
.connect<Client>(host, {
|
||||||
|
purpose: "docker",
|
||||||
|
timeoutMs: CONNECT_TIMEOUT_MS,
|
||||||
|
prompt: { ask },
|
||||||
|
})
|
||||||
|
.then(
|
||||||
|
(connection) => {
|
||||||
|
sessions.clearPending(sessionId);
|
||||||
|
ctx.hosts.status.reportLogin(hostId, { ok: true });
|
||||||
|
if (pending.abandoned) {
|
||||||
|
connection.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = {
|
||||||
|
id: sessionId,
|
||||||
|
client: connection.client,
|
||||||
|
dispose: connection.dispose,
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
runtime,
|
||||||
|
isWindows: false,
|
||||||
|
lastActive: Date.now(),
|
||||||
|
activeOperations: 0,
|
||||||
|
};
|
||||||
|
sessions.add(session);
|
||||||
|
|
||||||
|
connection.client.exec("ver", (err, stream) => {
|
||||||
|
if (err || !stream) return;
|
||||||
|
let output = "";
|
||||||
|
stream.on("data", (d: Buffer) => {
|
||||||
|
output += d.toString();
|
||||||
|
});
|
||||||
|
stream.on("close", () => {
|
||||||
|
if (output.toLowerCase().includes("windows")) {
|
||||||
|
session.isWindows = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.stderr.on("data", () => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
void ctx.audit.record({
|
||||||
|
action: "docker_connect",
|
||||||
|
resourceType: "host",
|
||||||
|
resourceId: String(hostId),
|
||||||
|
resourceName: (host.name as string) || undefined,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
logs.push(
|
||||||
|
connectionLog(
|
||||||
|
"success",
|
||||||
|
"connected",
|
||||||
|
"SSH connection established successfully",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
pending.emit({
|
||||||
|
status: 200,
|
||||||
|
body: { success: true, status: "success", connectionLogs: logs },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
sessions.clearPending(sessionId);
|
||||||
|
const message = getErrorMessage(error, "SSH connection failed");
|
||||||
|
if (pending.abandoned) return;
|
||||||
|
|
||||||
|
const outcome = (error as { outcome?: Record<string, unknown> })
|
||||||
|
.outcome;
|
||||||
|
if (outcome?.status === "interaction-required") {
|
||||||
|
logs.push(connectionLog("error", "docker_auth", message));
|
||||||
|
pending.emit({
|
||||||
|
status: 401,
|
||||||
|
body: {
|
||||||
|
error: message,
|
||||||
|
requiresAuthInteraction: outcome.interaction,
|
||||||
|
...(typeof outcome.flag === "string"
|
||||||
|
? { [outcome.flag]: true }
|
||||||
|
: {}),
|
||||||
|
connectionLogs: logs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (outcome?.status === "error") {
|
||||||
|
logs.push(connectionLog("error", "docker_auth", message));
|
||||||
|
pending.emit({
|
||||||
|
status: 400,
|
||||||
|
body: { error: message, connectionLogs: logs },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { hostKeyChanged, authFailed } = classifyConnectError(
|
||||||
|
message,
|
||||||
|
logs,
|
||||||
|
);
|
||||||
|
if (hostKeyChanged || authFailed) {
|
||||||
|
ctx.hosts.status.reportLogin(hostId, { ok: false, hostKeyChanged });
|
||||||
|
}
|
||||||
|
log.error("Docker SSH connection failed", {
|
||||||
|
hostId,
|
||||||
|
userId,
|
||||||
|
error: message,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (credentialless && authFailed) {
|
||||||
|
pending.emit({
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
status: "auth_required",
|
||||||
|
reason: "no_keyboard",
|
||||||
|
connectionLogs: logs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.emit({
|
||||||
|
status: 500,
|
||||||
|
body: { success: false, message, connectionLogs: logs },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return pending.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Carries a person's answer to a parked connect and waits for what's next. */
|
||||||
|
export async function answerConnect(
|
||||||
|
sessions: DockerSessions,
|
||||||
|
sessionId: string,
|
||||||
|
userId: string,
|
||||||
|
kind: "totp" | "warpgate",
|
||||||
|
value: string,
|
||||||
|
): Promise<ConnectStep> {
|
||||||
|
const pending = sessions.getPending(sessionId);
|
||||||
|
if (!(pending instanceof PendingConnect) || pending.userId !== userId) {
|
||||||
|
return {
|
||||||
|
status: 404,
|
||||||
|
body: { error: "Authentication session expired. Please reconnect." },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pending.awaiting !== kind) {
|
||||||
|
return {
|
||||||
|
status: 400,
|
||||||
|
body: {
|
||||||
|
error:
|
||||||
|
kind === "warpgate"
|
||||||
|
? "Session is not a Warpgate session"
|
||||||
|
: "Session is not waiting for a code",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
pending.respond(value);
|
||||||
|
return pending.next();
|
||||||
|
}
|
||||||
@@ -1,652 +1,343 @@
|
|||||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import type { RawData } from "ws";
|
||||||
|
import type { PluginLogger } from "@termix/plugin-sdk/backend";
|
||||||
|
|
||||||
|
export function getErrorMessage(
|
||||||
|
error: unknown,
|
||||||
|
fallback = "Unknown error",
|
||||||
|
): string {
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Meta = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface DockerLogger {
|
||||||
|
info: (message: string, meta?: Meta) => void;
|
||||||
|
warn: (message: string, meta?: Meta) => void;
|
||||||
|
/** An error, or structured context, or both. */
|
||||||
|
error: (message: string, errorOrMeta?: unknown, meta?: Meta) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withMeta(message: string, meta?: Meta): string {
|
||||||
|
if (!meta || Object.keys(meta).length === 0) return message;
|
||||||
|
try {
|
||||||
|
return `${message} ${JSON.stringify(meta)}`;
|
||||||
|
} catch {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ctx.log with the structured context the old core logger carried. */
|
||||||
|
export function createLogger(log: PluginLogger): DockerLogger {
|
||||||
|
return {
|
||||||
|
info: (message, meta) => log.info(withMeta(message, meta)),
|
||||||
|
warn: (message, meta) => log.warn(withMeta(message, meta)),
|
||||||
|
error: (message, errorOrMeta, meta) => {
|
||||||
|
if (errorOrMeta instanceof Error) {
|
||||||
|
log.error(withMeta(message, meta), errorOrMeta);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
log.error(
|
||||||
|
withMeta(message, {
|
||||||
|
...((errorOrMeta as Meta | undefined) ?? {}),
|
||||||
|
...meta,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogLevel = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
/** One line of the connection log the panel shows while connecting. */
|
||||||
|
export interface ConnectionLogLine {
|
||||||
|
type: LogLevel;
|
||||||
|
stage: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function connectionLog(
|
||||||
|
type: LogLevel,
|
||||||
|
stage: string,
|
||||||
|
message: string,
|
||||||
|
): ConnectionLogLine {
|
||||||
|
return { type, stage, message };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CONTAINER_ID_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/;
|
||||||
|
export const DOCKER_TIMESTAMP_RE = /^[0-9T:.Z+-]+$/;
|
||||||
|
|
||||||
|
// Cap on a single decoded text frame. Control messages are tiny, and console
|
||||||
|
// input is bounded by what a user can type or paste.
|
||||||
|
export const MAX_WS_MESSAGE_BYTES = 1024 * 1024;
|
||||||
|
|
||||||
|
function rawByteLength(raw: RawData): number {
|
||||||
|
if (Buffer.isBuffer(raw)) return raw.length;
|
||||||
|
if (Array.isArray(raw))
|
||||||
|
return raw.reduce((sum, part) => sum + part.length, 0);
|
||||||
|
if (raw instanceof ArrayBuffer) return raw.byteLength;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a frame into `{ type, data }`, throwing on anything else. */
|
||||||
|
export function parseWsMessage(raw: RawData): { type: string; data: unknown } {
|
||||||
|
if (rawByteLength(raw) > MAX_WS_MESSAGE_BYTES) {
|
||||||
|
throw new Error("Message too large");
|
||||||
|
}
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(raw.toString());
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON");
|
||||||
|
}
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
throw new Error("Message must be a JSON object");
|
||||||
|
}
|
||||||
|
const { type, data } = parsed as { type?: unknown; data?: unknown };
|
||||||
|
if (typeof type !== "string") {
|
||||||
|
throw new Error("Message type must be a string");
|
||||||
|
}
|
||||||
|
return { type, data };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asObject(data: unknown): Record<string, unknown> {
|
||||||
|
return data !== null && typeof data === "object" && !Array.isArray(data)
|
||||||
|
? (data as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A sane integer width or height, or 0 when the value is unusable. */
|
||||||
|
export function toTerminalDimension(value: unknown): number {
|
||||||
|
const n = typeof value === "number" ? value : Number(value);
|
||||||
|
if (!Number.isFinite(n)) return 0;
|
||||||
|
const rounded = Math.floor(n);
|
||||||
|
if (rounded < 1) return 0;
|
||||||
|
return Math.min(rounded, 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeHostAddress(value: unknown): string {
|
||||||
|
if (typeof value !== "string") return "";
|
||||||
|
return value
|
||||||
|
.replace(/^\[|\]$/g, "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a numeric host id resolved to a different machine than the client
|
||||||
|
* meant. Ids from the desktop app and a sync server drift apart, and the
|
||||||
|
* resolved row supplies the credentials, so a mismatch must be refused.
|
||||||
|
*/
|
||||||
|
export function hostAddressMismatch(
|
||||||
|
clientAddress: unknown,
|
||||||
|
resolvedAddress: unknown,
|
||||||
|
): boolean {
|
||||||
|
const resolved = normalizeHostAddress(resolvedAddress);
|
||||||
|
if (!resolved) return false;
|
||||||
|
return resolved !== normalizeHostAddress(clientAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HOST_ADDRESS_MISMATCH_MESSAGE =
|
||||||
|
"Host mismatch: this server resolved the selected host to a different machine, so the connection was refused. " +
|
||||||
|
"The host ids on this device and on the sync server have drifted apart. " +
|
||||||
|
'Set the connection origin to "This device" for this host, or re-run a full sync, then try again.';
|
||||||
|
|
||||||
|
export const HOST_NOT_ON_THIS_SERVER_MESSAGE =
|
||||||
|
"This host does not exist on the sync server, so the connection was refused. " +
|
||||||
|
'Run a sync so the server knows about it, or set the connection origin to "This device" for this host.';
|
||||||
|
|
||||||
|
export function newSessionId(prefix: string): string {
|
||||||
|
return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
||||||
|
import {
|
||||||
|
normalizeContainerRuntime,
|
||||||
|
type ContainerRuntime,
|
||||||
|
} from "./container-runtime.js";
|
||||||
|
|
||||||
|
export const PLUGIN_ID = "docker";
|
||||||
|
|
||||||
|
export interface DockerHostSettings {
|
||||||
|
enabled: boolean;
|
||||||
|
runtime: ContainerRuntime;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readDockerHostSettings(
|
||||||
|
ctx: PluginContext,
|
||||||
|
hostId: number,
|
||||||
|
): Promise<DockerHostSettings> {
|
||||||
|
const values = await ctx.settings.getAll("host", hostId);
|
||||||
|
return {
|
||||||
|
enabled: values.enableDocker === true,
|
||||||
|
runtime: normalizeContainerRuntime(values.containerRuntime),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function asObject(value: unknown): Record<string, unknown> | undefined {
|
||||||
|
let parsed = value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
||||||
|
? (parsed as Record<string, unknown>)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asBool(value: unknown): boolean | undefined {
|
||||||
|
if (typeof value === "boolean") return value;
|
||||||
|
if (value === 1 || value === "1" || value === "true") return true;
|
||||||
|
if (value === 0 || value === "0" || value === "false") return false;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The docker host settings in an imported or created host row: the nested
|
||||||
|
* `pluginSettings.docker` of a Termix export first, then the flat
|
||||||
|
* `enableDocker` and `dockerConfig.runtime` hosts had before 2.9.0 (older
|
||||||
|
* exports, proxmox guests marked as Docker hosts).
|
||||||
|
*/
|
||||||
|
export function normalizeImportedHost(
|
||||||
|
raw: Record<string, unknown>,
|
||||||
|
): Record<string, unknown> | null {
|
||||||
|
const nested = asObject(asObject(raw.pluginSettings)?.[PLUGIN_ID]) ?? {};
|
||||||
|
const out: Record<string, unknown> = {};
|
||||||
|
|
||||||
|
const enabled = asBool(
|
||||||
|
nested.enableDocker !== undefined ? nested.enableDocker : raw.enableDocker,
|
||||||
|
);
|
||||||
|
if (enabled !== undefined) out.enableDocker = enabled;
|
||||||
|
|
||||||
|
const runtime =
|
||||||
|
nested.containerRuntime ?? asObject(raw.dockerConfig)?.runtime;
|
||||||
|
if (runtime === "docker" || runtime === "podman") {
|
||||||
|
out.containerRuntime = runtime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Object.keys(out).length > 0 ? out : null;
|
||||||
|
}
|
||||||
@@ -1,82 +1,33 @@
|
|||||||
import express, { type Router } from "express";
|
import type { Router } from "express";
|
||||||
import type { PluginContext } from "@termix/plugin-sdk/backend";
|
import 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;
|
|
||||||
}
|
|
||||||
+781
-1103
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,256 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { PluginContext, PluginSshHost } from "@termix/plugin-sdk/backend";
|
||||||
|
import { containerCommand } from "./container-runtime.js";
|
||||||
|
import {
|
||||||
|
PS_STATE_FORMAT,
|
||||||
|
diffContainerStates,
|
||||||
|
parseContainerStates,
|
||||||
|
type ContainerState,
|
||||||
|
type DockerEventName,
|
||||||
|
} from "./container-state.js";
|
||||||
|
import { readDockerHostSettings } from "./host-settings.js";
|
||||||
|
import { execOnClient } from "./sessions.js";
|
||||||
|
import { listFormat, parseContainerList } from "./routes.js";
|
||||||
|
import {
|
||||||
|
CONTAINER_ID_RE,
|
||||||
|
getErrorMessage,
|
||||||
|
type DockerLogger,
|
||||||
|
} from "./helpers.js";
|
||||||
|
|
||||||
|
export const POOL = "docker";
|
||||||
|
const TICK_MS = 15_000;
|
||||||
|
const POLL_INTERVAL_MS = 60_000;
|
||||||
|
const HOST_CACHE_MS = 5 * 60_000;
|
||||||
|
|
||||||
|
export interface DockerContainerSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
status: string;
|
||||||
|
state: string;
|
||||||
|
ports: string;
|
||||||
|
created: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DockerContainerAction =
|
||||||
|
"start" | "stop" | "restart" | "pause" | "unpause" | "remove";
|
||||||
|
|
||||||
|
const ACTIONS: DockerContainerAction[] = [
|
||||||
|
"start",
|
||||||
|
"stop",
|
||||||
|
"restart",
|
||||||
|
"pause",
|
||||||
|
"unpause",
|
||||||
|
"remove",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** The "docker.containers" service, version 1. Runs as the caller's user. */
|
||||||
|
export interface DockerServiceV1 {
|
||||||
|
listContainers: (
|
||||||
|
hostId: number,
|
||||||
|
options?: { all?: boolean },
|
||||||
|
) => Promise<DockerContainerSummary[]>;
|
||||||
|
action: (
|
||||||
|
hostId: number,
|
||||||
|
container: string,
|
||||||
|
action: DockerContainerAction,
|
||||||
|
) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerEvent {
|
||||||
|
hostId: number;
|
||||||
|
container: string;
|
||||||
|
event: DockerEventName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The "docker.events" service, version 1. Runs as the caller's user. */
|
||||||
|
export interface DockerEventsV1 {
|
||||||
|
/**
|
||||||
|
* Calls `listener` for container state changes on a host, polled about
|
||||||
|
* once a minute as the subscribing user. Subscribing the same listener
|
||||||
|
* again is a no-op, so a consumer can re-subscribe on a timer and pick up
|
||||||
|
* a restarted docker plugin. Resolves to an unsubscribe function.
|
||||||
|
*/
|
||||||
|
subscribe: (
|
||||||
|
hostId: number,
|
||||||
|
listener: (event: DockerEvent) => void,
|
||||||
|
) => Promise<() => void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function actorOf(ctx: PluginContext): string {
|
||||||
|
const userId = ctx.currentActor();
|
||||||
|
if (!userId) throw new Error("The docker service needs a calling user");
|
||||||
|
return userId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function registerServices(ctx: PluginContext, log: DockerLogger): void {
|
||||||
|
const runOnHost = async <T>(
|
||||||
|
hostId: number,
|
||||||
|
command: (runtime: "docker" | "podman") => string,
|
||||||
|
handle: (output: string) => T,
|
||||||
|
): Promise<T> => {
|
||||||
|
const { runtime } = await readDockerHostSettings(ctx, hostId);
|
||||||
|
const output = await ctx.ssh.withConnection<string, Client>(
|
||||||
|
hostId,
|
||||||
|
{ pool: POOL, purpose: "docker" },
|
||||||
|
(client) => execOnClient(client, command(runtime)),
|
||||||
|
);
|
||||||
|
return handle(output);
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.services.provide<DockerServiceV1>("docker.containers", {
|
||||||
|
listContainers: async (hostId, options) => {
|
||||||
|
actorOf(ctx);
|
||||||
|
const all = options?.all !== false;
|
||||||
|
return runOnHost(
|
||||||
|
hostId,
|
||||||
|
(runtime) =>
|
||||||
|
containerCommand(
|
||||||
|
runtime,
|
||||||
|
`ps ${all ? "-a " : ""}--format ${listFormat(false)}`,
|
||||||
|
),
|
||||||
|
(output) =>
|
||||||
|
parseContainerList(output) as unknown as DockerContainerSummary[],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
action: async (hostId, container, action) => {
|
||||||
|
actorOf(ctx);
|
||||||
|
if (!CONTAINER_ID_RE.test(container)) {
|
||||||
|
throw new Error("Invalid container name");
|
||||||
|
}
|
||||||
|
if (!ACTIONS.includes(action)) {
|
||||||
|
throw new Error(`Unknown container action: ${action}`);
|
||||||
|
}
|
||||||
|
const args =
|
||||||
|
action === "remove" ? `rm ${container}` : `${action} ${container}`;
|
||||||
|
await runOnHost(
|
||||||
|
hostId,
|
||||||
|
(runtime) => containerCommand(runtime, args),
|
||||||
|
() => undefined,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
interface Watch {
|
||||||
|
userId: string;
|
||||||
|
hostId: number;
|
||||||
|
listeners: Set<(event: DockerEvent) => void>;
|
||||||
|
snapshot?: Map<string, ContainerState>;
|
||||||
|
host?: { value: PluginSshHost; at: number };
|
||||||
|
lastPolledAt: number;
|
||||||
|
failing: boolean;
|
||||||
|
}
|
||||||
|
const watches = new Map<string, Watch>();
|
||||||
|
ctx.disposables.add(() => watches.clear());
|
||||||
|
|
||||||
|
const hostFor = async (watch: Watch): Promise<PluginSshHost | null> => {
|
||||||
|
if (watch.host && Date.now() - watch.host.at < HOST_CACHE_MS) {
|
||||||
|
return watch.host.value;
|
||||||
|
}
|
||||||
|
const host = await ctx.asUser(watch.userId, () =>
|
||||||
|
ctx.ssh.resolveHost(watch.hostId),
|
||||||
|
);
|
||||||
|
watch.host = host ? { value: host, at: Date.now() } : undefined;
|
||||||
|
return host;
|
||||||
|
};
|
||||||
|
|
||||||
|
const pollOne = async (watch: Watch) => {
|
||||||
|
try {
|
||||||
|
const host = await hostFor(watch);
|
||||||
|
if (!host) throw new Error("Host not found");
|
||||||
|
const { runtime } = await readDockerHostSettings(ctx, watch.hostId);
|
||||||
|
const output = await ctx.ssh.withConnection<string, Client>(
|
||||||
|
host,
|
||||||
|
{ pool: POOL, purpose: "docker" },
|
||||||
|
(client) =>
|
||||||
|
execOnClient(
|
||||||
|
client,
|
||||||
|
containerCommand(runtime, `ps -a --format ${PS_STATE_FORMAT}`),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const current = parseContainerStates(output);
|
||||||
|
const previous = watch.snapshot;
|
||||||
|
watch.snapshot = current;
|
||||||
|
watch.failing = false;
|
||||||
|
if (!previous) return;
|
||||||
|
for (const { container, event } of diffContainerStates(
|
||||||
|
previous,
|
||||||
|
current,
|
||||||
|
)) {
|
||||||
|
for (const listener of [...watch.listeners]) {
|
||||||
|
try {
|
||||||
|
listener({ hostId: watch.hostId, container, event });
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Docker event listener failed", {
|
||||||
|
hostId: watch.hostId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Docker missing, the host down or the owner's data locked. A later
|
||||||
|
// success is a first observation again rather than a diff.
|
||||||
|
watch.snapshot = undefined;
|
||||||
|
watch.host = undefined;
|
||||||
|
if (!watch.failing) {
|
||||||
|
log.warn("Docker event poll failed", {
|
||||||
|
hostId: watch.hostId,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
watch.failing = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ctx.schedule.every(TICK_MS, async () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const watch of [...watches.values()]) {
|
||||||
|
if (now - watch.lastPolledAt < POLL_INTERVAL_MS) continue;
|
||||||
|
watch.lastPolledAt = now;
|
||||||
|
await pollOne(watch);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.events.on("host.updated", (payload) => {
|
||||||
|
const { hostId } = payload as { hostId?: number };
|
||||||
|
if (!hostId) return;
|
||||||
|
ctx.ssh.dropPooled(POOL, hostId);
|
||||||
|
for (const watch of watches.values()) {
|
||||||
|
if (watch.hostId === hostId) watch.host = undefined;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
ctx.events.on("host.deleted", (payload) => {
|
||||||
|
const { hostId } = payload as { hostId?: number };
|
||||||
|
if (!hostId) return;
|
||||||
|
for (const [key, watch] of watches) {
|
||||||
|
if (watch.hostId === hostId) watches.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ctx.services.provide<DockerEventsV1>("docker.events", {
|
||||||
|
subscribe: async (hostId, listener) => {
|
||||||
|
const userId = actorOf(ctx);
|
||||||
|
const key = `${userId}:${hostId}`;
|
||||||
|
let watch = watches.get(key);
|
||||||
|
if (!watch) {
|
||||||
|
watch = {
|
||||||
|
userId,
|
||||||
|
hostId,
|
||||||
|
listeners: new Set(),
|
||||||
|
lastPolledAt: 0,
|
||||||
|
failing: false,
|
||||||
|
};
|
||||||
|
watches.set(key, watch);
|
||||||
|
}
|
||||||
|
watch.listeners.add(listener);
|
||||||
|
const current = watch;
|
||||||
|
return () => {
|
||||||
|
current.listeners.delete(listener);
|
||||||
|
if (current.listeners.size === 0 && watches.get(key) === current) {
|
||||||
|
watches.delete(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,174 +0,0 @@
|
|||||||
// Core imports below point at TypeScript source so tsc can type-check them.
|
|
||||||
// The plugin build rewrites the prefix to the compiled output path; see
|
|
||||||
// packages/plugin-sdk/cli/lib/legacy-core-imports.mjs.
|
|
||||||
import { Client as SSHClient } from "ssh2";
|
|
||||||
import { logger } from "../../../../src/backend/utils/logger.js";
|
|
||||||
import type { ContainerRuntime } from "./container-runtime.js";
|
|
||||||
|
|
||||||
const sshLogger = logger;
|
|
||||||
|
|
||||||
export interface SSHSession {
|
|
||||||
client: SSHClient;
|
|
||||||
isConnected: boolean;
|
|
||||||
lastActive: number;
|
|
||||||
timeout?: NodeJS.Timeout;
|
|
||||||
activeOperations: number;
|
|
||||||
hostId?: number;
|
|
||||||
userId?: string;
|
|
||||||
isWindows?: boolean;
|
|
||||||
containerRuntime?: ContainerRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PendingTOTPSession {
|
|
||||||
client: SSHClient;
|
|
||||||
finish: (responses: string[]) => void;
|
|
||||||
config: Record<string, unknown>;
|
|
||||||
createdAt: number;
|
|
||||||
sessionId: string;
|
|
||||||
hostId?: number;
|
|
||||||
ip?: string;
|
|
||||||
port?: number;
|
|
||||||
username?: string;
|
|
||||||
userId?: string;
|
|
||||||
prompts?: Array<{ prompt: string; echo: boolean }>;
|
|
||||||
totpPromptIndex?: number;
|
|
||||||
resolvedPassword?: string;
|
|
||||||
totpAttempts: number;
|
|
||||||
isWarpgate?: boolean;
|
|
||||||
containerRuntime?: ContainerRuntime;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const sshSessions: Record<string, SSHSession> = {};
|
|
||||||
export const pendingTOTPSessions: Record<string, PendingTOTPSession> = {};
|
|
||||||
|
|
||||||
const SESSION_IDLE_TIMEOUT = 60 * 60 * 1000;
|
|
||||||
|
|
||||||
setInterval(() => {
|
|
||||||
const now = Date.now();
|
|
||||||
Object.keys(pendingTOTPSessions).forEach((sessionId) => {
|
|
||||||
const session = pendingTOTPSessions[sessionId];
|
|
||||||
if (now - session.createdAt > 180000) {
|
|
||||||
try {
|
|
||||||
session.client.end();
|
|
||||||
} catch {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
delete pendingTOTPSessions[sessionId];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
export function cleanupSession(sessionId: string) {
|
|
||||||
const session = sshSessions[sessionId];
|
|
||||||
if (session) {
|
|
||||||
if (session.activeOperations > 0) {
|
|
||||||
sshLogger.warn(
|
|
||||||
`Deferring session cleanup for ${sessionId} - ${session.activeOperations} active operations`,
|
|
||||||
{
|
|
||||||
operation: "cleanup_deferred",
|
|
||||||
sessionId,
|
|
||||||
activeOperations: session.activeOperations,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
scheduleSessionCleanup(sessionId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
session.client.end();
|
|
||||||
} catch {
|
|
||||||
// expected
|
|
||||||
}
|
|
||||||
clearTimeout(session.timeout);
|
|
||||||
delete sshSessions[sessionId];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function scheduleSessionCleanup(sessionId: string) {
|
|
||||||
const session = sshSessions[sessionId];
|
|
||||||
if (session) {
|
|
||||||
if (session.timeout) clearTimeout(session.timeout);
|
|
||||||
|
|
||||||
session.timeout = setTimeout(() => {
|
|
||||||
cleanupSession(sessionId);
|
|
||||||
}, SESSION_IDLE_TIMEOUT);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function executeDockerCommand(
|
|
||||||
session: SSHSession,
|
|
||||||
command: string,
|
|
||||||
sessionId?: string,
|
|
||||||
userId?: string,
|
|
||||||
hostId?: number,
|
|
||||||
): Promise<string> {
|
|
||||||
const startTime = Date.now();
|
|
||||||
sshLogger.info("Executing Docker command", {
|
|
||||||
operation: "docker_command_exec",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
command: command.split(" ")[1],
|
|
||||||
});
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
session.client.exec(command, (err, stream) => {
|
|
||||||
if (err) {
|
|
||||||
sshLogger.error("Docker command execution error", err, {
|
|
||||||
operation: "execute_docker_command",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
command: command.split(" ")[1],
|
|
||||||
});
|
|
||||||
return reject(err);
|
|
||||||
}
|
|
||||||
|
|
||||||
let stdout = "";
|
|
||||||
let stderr = "";
|
|
||||||
|
|
||||||
stream.on("close", (code: number) => {
|
|
||||||
if (code !== 0) {
|
|
||||||
sshLogger.error("Docker command failed", undefined, {
|
|
||||||
operation: "execute_docker_command",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
command: command.split(" ")[1],
|
|
||||||
exitCode: code,
|
|
||||||
stderr,
|
|
||||||
});
|
|
||||||
reject(new Error(stderr || `Command exited with code ${code}`));
|
|
||||||
} else {
|
|
||||||
sshLogger.success("Docker command completed", {
|
|
||||||
operation: "docker_command_success",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
command: command.split(" ")[1],
|
|
||||||
duration: Date.now() - startTime,
|
|
||||||
});
|
|
||||||
resolve(stdout);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("data", (data: Buffer) => {
|
|
||||||
stdout += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.stderr.on("data", (data: Buffer) => {
|
|
||||||
stderr += data.toString();
|
|
||||||
});
|
|
||||||
|
|
||||||
stream.on("error", (streamErr: Error) => {
|
|
||||||
sshLogger.error("Docker command stream error", streamErr, {
|
|
||||||
operation: "execute_docker_command",
|
|
||||||
sessionId,
|
|
||||||
userId,
|
|
||||||
hostId,
|
|
||||||
command: command.split(" ")[1],
|
|
||||||
});
|
|
||||||
reject(streamErr);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import type { Client } from "ssh2";
|
||||||
|
import type { PluginSchedule } from "@termix/plugin-sdk/backend";
|
||||||
|
import type { ContainerRuntime } from "./container-runtime.js";
|
||||||
|
import type { DockerLogger } from "./helpers.js";
|
||||||
|
|
||||||
|
const SESSION_IDLE_MS = 60 * 60 * 1000;
|
||||||
|
const PENDING_SWEEP_MS = 60 * 1000;
|
||||||
|
|
||||||
|
/** A live SSH connection behind the Docker panel, owned by one user. */
|
||||||
|
export interface DockerSession {
|
||||||
|
id: string;
|
||||||
|
client: Client;
|
||||||
|
dispose: () => void;
|
||||||
|
hostId: number;
|
||||||
|
userId: string;
|
||||||
|
runtime: ContainerRuntime;
|
||||||
|
isWindows: boolean;
|
||||||
|
lastActive: number;
|
||||||
|
activeOperations: number;
|
||||||
|
cancelIdle?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A connect still waiting on a person (TOTP, Warpgate). */
|
||||||
|
export interface PendingEntry {
|
||||||
|
userId: string;
|
||||||
|
hostId: number;
|
||||||
|
expiresAt: number;
|
||||||
|
/** Gives up: answers the open prompt with nothing. */
|
||||||
|
cancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerSessions {
|
||||||
|
add: (session: DockerSession) => void;
|
||||||
|
/** The session when `userId` owns it. */
|
||||||
|
get: (id: string, userId: string) => DockerSession | undefined;
|
||||||
|
close: (id: string) => void;
|
||||||
|
touch: (session: DockerSession) => void;
|
||||||
|
/** Runs `fn` with the session marked busy, so idle cleanup waits. */
|
||||||
|
run: <T>(session: DockerSession, fn: () => Promise<T>) => Promise<T>;
|
||||||
|
exec: (session: DockerSession, command: string) => Promise<string>;
|
||||||
|
setPending: (id: string, entry: PendingEntry) => void;
|
||||||
|
getPending: (id: string) => PendingEntry | undefined;
|
||||||
|
clearPending: (id: string) => void;
|
||||||
|
closeAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Runs one command and resolves stdout, rejecting on a non-zero exit. */
|
||||||
|
export function execOnClient(client: Client, command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
client.exec(command, (err, stream) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
stream.on("close", (code: number) => {
|
||||||
|
if (code !== 0 && code !== null && code !== undefined) {
|
||||||
|
reject(new Error(stderr || `Command exited with code ${code}`));
|
||||||
|
} else {
|
||||||
|
resolve(stdout);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
stream.on("data", (data: Buffer) => {
|
||||||
|
stdout += data.toString();
|
||||||
|
});
|
||||||
|
stream.stderr.on("data", (data: Buffer) => {
|
||||||
|
stderr += data.toString();
|
||||||
|
});
|
||||||
|
stream.on("error", (streamErr: Error) => reject(streamErr));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createDockerSessions(
|
||||||
|
schedule: PluginSchedule,
|
||||||
|
log: DockerLogger,
|
||||||
|
): DockerSessions {
|
||||||
|
const sessions = new Map<string, DockerSession>();
|
||||||
|
const pending = new Map<string, PendingEntry>();
|
||||||
|
|
||||||
|
const end = (session: DockerSession) => {
|
||||||
|
session.cancelIdle?.();
|
||||||
|
sessions.delete(session.id);
|
||||||
|
session.dispose();
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleIdle = (session: DockerSession) => {
|
||||||
|
session.cancelIdle?.();
|
||||||
|
session.cancelIdle = schedule.after(SESSION_IDLE_MS, () => {
|
||||||
|
if (sessions.get(session.id) !== session) return;
|
||||||
|
if (session.activeOperations > 0) {
|
||||||
|
scheduleIdle(session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
end(session);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
schedule.every(PENDING_SWEEP_MS, () => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [id, entry] of pending) {
|
||||||
|
if (now < entry.expiresAt) continue;
|
||||||
|
pending.delete(id);
|
||||||
|
entry.cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
add: (session) => {
|
||||||
|
const existing = sessions.get(session.id);
|
||||||
|
if (existing) end(existing);
|
||||||
|
sessions.set(session.id, session);
|
||||||
|
session.client.once("close", () => {
|
||||||
|
if (sessions.get(session.id) === session) {
|
||||||
|
session.cancelIdle?.();
|
||||||
|
sessions.delete(session.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
scheduleIdle(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
get: (id, userId) => {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
return session && session.userId === userId ? session : undefined;
|
||||||
|
},
|
||||||
|
|
||||||
|
close: (id) => {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (session) end(session);
|
||||||
|
const entry = pending.get(id);
|
||||||
|
if (entry) {
|
||||||
|
pending.delete(id);
|
||||||
|
entry.cancel();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
touch: (session) => {
|
||||||
|
session.lastActive = Date.now();
|
||||||
|
scheduleIdle(session);
|
||||||
|
},
|
||||||
|
|
||||||
|
run: async (session, fn) => {
|
||||||
|
session.lastActive = Date.now();
|
||||||
|
session.activeOperations++;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
session.activeOperations--;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
exec: async (session, command) => {
|
||||||
|
const started = Date.now();
|
||||||
|
try {
|
||||||
|
return await execOnClient(session.client, command);
|
||||||
|
} catch (error) {
|
||||||
|
log.warn("Docker command failed", {
|
||||||
|
hostId: session.hostId,
|
||||||
|
command: command.split(" ")[2],
|
||||||
|
durationMs: Date.now() - started,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPending: (id, entry) => {
|
||||||
|
const existing = pending.get(id);
|
||||||
|
if (existing) existing.cancel();
|
||||||
|
pending.set(id, entry);
|
||||||
|
},
|
||||||
|
|
||||||
|
getPending: (id) => pending.get(id),
|
||||||
|
|
||||||
|
clearPending: (id) => {
|
||||||
|
pending.delete(id);
|
||||||
|
},
|
||||||
|
|
||||||
|
closeAll: () => {
|
||||||
|
for (const session of [...sessions.values()]) end(session);
|
||||||
|
for (const entry of pending.values()) entry.cancel();
|
||||||
|
pending.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import type { Client } from "ssh2";
|
|
||||||
import type {
|
|
||||||
PluginSsh,
|
|
||||||
PluginSshConnection,
|
|
||||||
PluginSshConnectOptions,
|
|
||||||
PluginSshHost,
|
|
||||||
} from "@termix/plugin-sdk/backend";
|
|
||||||
|
|
||||||
let current: PluginSsh | null = null;
|
|
||||||
|
|
||||||
/** Set in activate, cleared on deactivate. */
|
|
||||||
export function setPluginSsh(ssh: PluginSsh | null): void {
|
|
||||||
current = ssh;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pluginSsh(): PluginSsh {
|
|
||||||
if (!current) throw new Error("The plugin is not active");
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** withConnection with ssh2's Client type filled in. */
|
|
||||||
export function withSshConnection<T>(
|
|
||||||
host: number | PluginSshHost,
|
|
||||||
options: PluginSshConnectOptions & { pool: string },
|
|
||||||
fn: (client: Client) => Promise<T>,
|
|
||||||
): Promise<T> {
|
|
||||||
return pluginSsh().withConnection<T, Client>(host, options, fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** connect with ssh2's Client type filled in. */
|
|
||||||
export function connectSsh(
|
|
||||||
host: number | PluginSshHost,
|
|
||||||
options?: PluginSshConnectOptions,
|
|
||||||
): Promise<PluginSshConnection<Client>> {
|
|
||||||
return pluginSsh().connect<Client>(host, options);
|
|
||||||
}
|
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
|
import { ConnectionScreen, FullScreenAppWrapper } from "@termix/plugin-sdk/ui";
|
||||||
import React from "react";
|
import 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}
|
||||||
|
|||||||
@@ -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")}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { HomepageWidgetContribution } from "@termix/plugin-sdk/frontend";
|
||||||
|
|
||||||
|
/** The homepage's grid step, in pixels. */
|
||||||
|
export const GRID_SIZE = 30;
|
||||||
|
|
||||||
|
export interface DockerWidgetConfig {
|
||||||
|
hostId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the homepage hands a widget; only the fields this plugin reads. */
|
||||||
|
export interface WidgetComponentProps<C> {
|
||||||
|
widget: { title?: string | null };
|
||||||
|
config: C;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WidgetEditFormProps<C> {
|
||||||
|
config: C;
|
||||||
|
onChange: (config: C) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WidgetDefinition<C> = Omit<
|
||||||
|
HomepageWidgetContribution<C>,
|
||||||
|
"defaultConfig"
|
||||||
|
> & { defaultConfig: C };
|
||||||
@@ -2,21 +2,20 @@ import type { ComponentType } from "react";
|
|||||||
import { Box } from "lucide-react";
|
import { 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(
|
||||||
|
|||||||
@@ -0,0 +1,86 @@
|
|||||||
|
export interface DockerContainer {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
status: string;
|
||||||
|
state:
|
||||||
|
| "created"
|
||||||
|
| "running"
|
||||||
|
| "paused"
|
||||||
|
| "restarting"
|
||||||
|
| "removing"
|
||||||
|
| "exited"
|
||||||
|
| "dead";
|
||||||
|
ports: string;
|
||||||
|
created: string;
|
||||||
|
command?: string;
|
||||||
|
labels?: Record<string, string>;
|
||||||
|
networks?: string[];
|
||||||
|
mounts?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerStats {
|
||||||
|
cpu: string;
|
||||||
|
memoryUsed: string;
|
||||||
|
memoryLimit: string;
|
||||||
|
memoryPercent: string;
|
||||||
|
netInput: string;
|
||||||
|
netOutput: string;
|
||||||
|
blockRead: string;
|
||||||
|
blockWrite: string;
|
||||||
|
pids?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerLogOptions {
|
||||||
|
tail?: number;
|
||||||
|
timestamps?: boolean;
|
||||||
|
since?: string;
|
||||||
|
until?: string;
|
||||||
|
follow?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DockerValidation {
|
||||||
|
available: boolean;
|
||||||
|
version?: string;
|
||||||
|
runtime?: "docker" | "podman";
|
||||||
|
error?: string;
|
||||||
|
code?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the Docker views read from a host. */
|
||||||
|
export interface DockerHost {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
ip: string;
|
||||||
|
port: number;
|
||||||
|
username?: string;
|
||||||
|
syncId?: string | null;
|
||||||
|
connectionOrigin?: "local" | "remote" | null;
|
||||||
|
terminalConfig?: Record<string, unknown> | null;
|
||||||
|
pluginSettings?: Record<string, Record<string, unknown> | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Any host record the shell or an API hands over, as a DockerHost. */
|
||||||
|
export function toDockerHost(record: Record<string, unknown>): DockerHost {
|
||||||
|
return {
|
||||||
|
...(record as unknown as DockerHost),
|
||||||
|
id: Number(record.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerSettings(
|
||||||
|
host: object | null | undefined,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
const all = (host as { pluginSettings?: unknown } | null | undefined)
|
||||||
|
?.pluginSettings as
|
||||||
|
Record<string, Record<string, unknown> | undefined> | undefined;
|
||||||
|
return all?.docker ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dockerEnabled(host: object | null | undefined): boolean {
|
||||||
|
return dockerSettings(host).enableDocker === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostTitle(host: DockerHost): string {
|
||||||
|
return host.name || `${host.username ?? ""}@${host.ip}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
import express from "express";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
createMockCtx,
|
||||||
|
type MockPluginContext,
|
||||||
|
} from "@termix/plugin-sdk/testing";
|
||||||
|
import type { PluginWebSocketConnection } from "@termix/plugin-sdk/backend";
|
||||||
|
import { activate } from "../../src/backend/index.js";
|
||||||
|
import { manifest, sshHost } from "./server";
|
||||||
|
import { FakeClient, FakeStream } from "./fake-ssh";
|
||||||
|
|
||||||
|
/** A ws WebSocket as far as the console uses one. */
|
||||||
|
class FakeSocket extends EventEmitter {
|
||||||
|
readyState = 1;
|
||||||
|
sent: Array<Record<string, unknown>> = [];
|
||||||
|
closedWith: [number, string] | null = null;
|
||||||
|
|
||||||
|
send(data: string) {
|
||||||
|
this.sent.push(JSON.parse(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
ping() {}
|
||||||
|
|
||||||
|
close(code: number, reason: string) {
|
||||||
|
if (this.readyState !== 1) return;
|
||||||
|
this.readyState = 3;
|
||||||
|
this.closedWith = [code, reason];
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
|
||||||
|
message(payload: unknown) {
|
||||||
|
this.emit("message", Buffer.from(JSON.stringify(payload)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function consoleClient() {
|
||||||
|
const shells: FakeStream[] = [];
|
||||||
|
const client = new FakeClient([
|
||||||
|
[/which bash/, { stdout: "/bin/bash\n" }],
|
||||||
|
[/exec -it abc123 \/bin\/bash/, (stream) => shells.push(stream)],
|
||||||
|
]);
|
||||||
|
return { client, shells };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function activated(client: FakeClient): Promise<MockPluginContext> {
|
||||||
|
const mock = createMockCtx({
|
||||||
|
pluginId: manifest.id,
|
||||||
|
manifest,
|
||||||
|
capabilities: manifest.capabilities,
|
||||||
|
router: () => express.Router(),
|
||||||
|
permissions: ["docker.use"],
|
||||||
|
sshHosts: [sshHost(7)],
|
||||||
|
sshClient: client,
|
||||||
|
});
|
||||||
|
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||||
|
// Like the runtime, disposing a connection ends its client.
|
||||||
|
const connect = mock.ctx.ssh.connect;
|
||||||
|
mock.ctx.ssh.connect = (async (...args: Parameters<typeof connect>) => ({
|
||||||
|
...(await connect(...args)),
|
||||||
|
dispose: () => client.end(),
|
||||||
|
})) as typeof connect;
|
||||||
|
await activate(mock.ctx);
|
||||||
|
return mock;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openConsole(mock: MockPluginContext, userId = "user-1") {
|
||||||
|
const route = mock.wsRoutes.find((r) => r.path === "/console");
|
||||||
|
expect(route?.options).toEqual({ public: true, optionalAuth: true });
|
||||||
|
const socket = new FakeSocket();
|
||||||
|
mock.setActor(userId || undefined);
|
||||||
|
await route!.handler!({
|
||||||
|
userId,
|
||||||
|
request: {},
|
||||||
|
socket,
|
||||||
|
clientIp: "127.0.0.1",
|
||||||
|
requestOrigin: "http://localhost",
|
||||||
|
isDataUnlocked: () => true,
|
||||||
|
} as PluginWebSocketConnection);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function until(check: () => boolean) {
|
||||||
|
for (let i = 0; i < 50 && !check(); i++) {
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
|
expect(check()).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
let disposals: Array<() => void | Promise<void>> = [];
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
for (const dispose of disposals.reverse()) await dispose();
|
||||||
|
disposals = [];
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker console", () => {
|
||||||
|
it("refuses a socket without a user", async () => {
|
||||||
|
const mock = await activated(consoleClient().client);
|
||||||
|
disposals.push(...mock.disposals);
|
||||||
|
const socket = await openConsole(mock, "");
|
||||||
|
expect(socket.closedWith?.[0]).toBe(1008);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes an open console on disable, and the next enable works", async () => {
|
||||||
|
const first = consoleClient();
|
||||||
|
const mock = await activated(first.client);
|
||||||
|
|
||||||
|
const socket = await openConsole(mock);
|
||||||
|
socket.message({
|
||||||
|
type: "connect",
|
||||||
|
data: {
|
||||||
|
hostConfig: { id: 7, ip: "10.0.0.7" },
|
||||||
|
containerId: "abc123",
|
||||||
|
shell: "bash",
|
||||||
|
cols: 100,
|
||||||
|
rows: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await until(() => socket.sent.some((m) => m.type === "connected"));
|
||||||
|
socket.message({ type: "input", data: "ls\n" });
|
||||||
|
await until(() => first.shells[0]?.written.includes("ls\n") ?? false);
|
||||||
|
|
||||||
|
// Disable: the runtime empties the disposable bag.
|
||||||
|
for (const dispose of [...mock.disposals].reverse()) await dispose();
|
||||||
|
expect(first.shells[0].ended).toBe(true);
|
||||||
|
expect(first.client.ended).toBe(true);
|
||||||
|
expect(socket.closedWith?.[0]).toBe(1001);
|
||||||
|
|
||||||
|
// Enable again: a fresh activate on the same module serves a new console.
|
||||||
|
const second = consoleClient();
|
||||||
|
const again = await activated(second.client);
|
||||||
|
disposals.push(...again.disposals);
|
||||||
|
const next = await openConsole(again);
|
||||||
|
next.message({
|
||||||
|
type: "connect",
|
||||||
|
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||||
|
});
|
||||||
|
await until(() => next.sent.some((m) => m.type === "connected"));
|
||||||
|
second.shells[0].emit("data", Buffer.from("hello"));
|
||||||
|
await until(() =>
|
||||||
|
next.sent.some((m) => m.type === "output" && m.data === "hello"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a host id that resolves to another machine", async () => {
|
||||||
|
const mock = await activated(consoleClient().client);
|
||||||
|
disposals.push(...mock.disposals);
|
||||||
|
const socket = await openConsole(mock);
|
||||||
|
socket.message({
|
||||||
|
type: "connect",
|
||||||
|
data: { hostConfig: { id: 7, ip: "10.9.9.9" }, containerId: "abc123" },
|
||||||
|
});
|
||||||
|
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||||
|
expect(String(socket.sent[0].message)).toMatch(/Host mismatch/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a host with Docker switched off", async () => {
|
||||||
|
const mock = await activated(consoleClient().client);
|
||||||
|
disposals.push(...mock.disposals);
|
||||||
|
await mock.ctx.settings.setHost(7, "enableDocker", false);
|
||||||
|
const socket = await openConsole(mock);
|
||||||
|
socket.message({
|
||||||
|
type: "connect",
|
||||||
|
data: { hostConfig: { id: 7, ip: "10.0.0.7" }, containerId: "abc123" },
|
||||||
|
});
|
||||||
|
await until(() => socket.sent.some((m) => m.type === "error"));
|
||||||
|
expect(String(socket.sent[0].message)).toMatch(/not enabled/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
/** An ssh2 exec channel: stdout on "data", stderr on `stderr`, then "close". */
|
||||||
|
export class FakeStream extends EventEmitter {
|
||||||
|
stderr = new EventEmitter();
|
||||||
|
written: string[] = [];
|
||||||
|
ended = false;
|
||||||
|
window: [number, number] | null = null;
|
||||||
|
|
||||||
|
write(data: string) {
|
||||||
|
this.written.push(data);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
this.ended = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setWindow(rows: number, cols: number) {
|
||||||
|
this.window = [rows, cols];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emits output and exits, on the next tick like a real channel. */
|
||||||
|
finish(stdout: string, code = 0, stderr = "") {
|
||||||
|
setImmediate(() => {
|
||||||
|
if (stdout) this.emit("data", Buffer.from(stdout));
|
||||||
|
if (stderr) this.stderr.emit("data", Buffer.from(stderr));
|
||||||
|
this.emit("close", code);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ExecReply =
|
||||||
|
| { stdout?: string; stderr?: string; code?: number }
|
||||||
|
| ((stream: FakeStream) => void);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ssh2 Client that answers exec by the first matching pattern. A reply
|
||||||
|
* may be a function for a long-lived channel such as a console.
|
||||||
|
*/
|
||||||
|
export class FakeClient extends EventEmitter {
|
||||||
|
commands: string[] = [];
|
||||||
|
streams: FakeStream[] = [];
|
||||||
|
ended = false;
|
||||||
|
|
||||||
|
constructor(private replies: Array<[RegExp, ExecReply]> = []) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply(pattern: RegExp, reply: ExecReply) {
|
||||||
|
this.replies.unshift([pattern, reply]);
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(
|
||||||
|
command: string,
|
||||||
|
optionsOrCallback: unknown,
|
||||||
|
maybeCallback?: (err: Error | undefined, stream: FakeStream) => void,
|
||||||
|
) {
|
||||||
|
const callback = (
|
||||||
|
typeof optionsOrCallback === "function"
|
||||||
|
? optionsOrCallback
|
||||||
|
: maybeCallback
|
||||||
|
) as (err: Error | undefined, stream: FakeStream) => void;
|
||||||
|
this.commands.push(command);
|
||||||
|
const stream = new FakeStream();
|
||||||
|
this.streams.push(stream);
|
||||||
|
const match = this.replies.find(([pattern]) => pattern.test(command));
|
||||||
|
callback(undefined, stream);
|
||||||
|
if (!match) {
|
||||||
|
stream.finish("");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
const reply = match[1];
|
||||||
|
if (typeof reply === "function") reply(stream);
|
||||||
|
else stream.finish(reply.stdout ?? "", reply.code ?? 0, reply.stderr ?? "");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
end() {
|
||||||
|
if (this.ended) return;
|
||||||
|
this.ended = true;
|
||||||
|
this.emit("close");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import { createMockCtx } from "@termix/plugin-sdk/testing";
|
||||||
|
import { PluginCapabilityError } from "@termix/plugin-sdk/backend";
|
||||||
|
import { activate } from "../../src/backend/index.js";
|
||||||
|
import {
|
||||||
|
normalizeImportedHost,
|
||||||
|
readDockerHostSettings,
|
||||||
|
} from "../../src/backend/host-settings.js";
|
||||||
|
import {
|
||||||
|
diffContainerStates,
|
||||||
|
parseContainerStates,
|
||||||
|
} from "../../src/backend/container-state.js";
|
||||||
|
import { manifest, startServer, type TestServer } from "./server";
|
||||||
|
|
||||||
|
let server: TestServer | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server?.close();
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker activate", () => {
|
||||||
|
it("fails closed without network:serve", async () => {
|
||||||
|
const mock = createMockCtx({
|
||||||
|
pluginId: manifest.id,
|
||||||
|
manifest,
|
||||||
|
capabilities: manifest.capabilities.filter(
|
||||||
|
(cap) => cap !== "network:serve",
|
||||||
|
),
|
||||||
|
router: () => express.Router(),
|
||||||
|
});
|
||||||
|
await expect(activate(mock.ctx)).rejects.toBeInstanceOf(
|
||||||
|
PluginCapabilityError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("cannot reach a host without ssh:connect", async () => {
|
||||||
|
server = await startServer({
|
||||||
|
mock: {
|
||||||
|
capabilities: manifest.capabilities.filter(
|
||||||
|
(cap) => cap !== "ssh:connect",
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const response = await server.request("POST", "/ssh/connect", {
|
||||||
|
body: { sessionId: "s1", hostId: 7 },
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registers its import normalizer", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
expect(
|
||||||
|
server.mock.ctx.registry.consume("docker.hostImportNormalizer"),
|
||||||
|
).toBe(normalizeImportedHost);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker host settings", () => {
|
||||||
|
it("reads the switch and runtime with defaults", async () => {
|
||||||
|
server = await startServer({ dockerOn: false });
|
||||||
|
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
runtime: "docker",
|
||||||
|
});
|
||||||
|
await server.mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||||
|
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||||
|
expect(await readDockerHostSettings(server.mock.ctx, 7)).toEqual({
|
||||||
|
enabled: true,
|
||||||
|
runtime: "podman",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes an export's nested settings over the old flat fields", () => {
|
||||||
|
expect(
|
||||||
|
normalizeImportedHost({
|
||||||
|
enableDocker: false,
|
||||||
|
pluginSettings: {
|
||||||
|
docker: { enableDocker: true, containerRuntime: "podman" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads 2.8 flat fields and proxmox-created rows", () => {
|
||||||
|
expect(
|
||||||
|
normalizeImportedHost({
|
||||||
|
enableDocker: 1,
|
||||||
|
dockerConfig: '{"runtime":"podman"}',
|
||||||
|
}),
|
||||||
|
).toEqual({ enableDocker: true, containerRuntime: "podman" });
|
||||||
|
expect(normalizeImportedHost({ enableDocker: true })).toEqual({
|
||||||
|
enableDocker: true,
|
||||||
|
});
|
||||||
|
expect(normalizeImportedHost({ name: "plain" })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The polling side needs SSH, so what is tested here is the pure part:
|
||||||
|
* turning `ps` output into states, and two snapshots into events.
|
||||||
|
*/
|
||||||
|
describe("container state", () => {
|
||||||
|
it("reads name, state and health out of the ps output", () => {
|
||||||
|
const states = parseContainerStates(
|
||||||
|
[
|
||||||
|
'{"name":"web","state":"running","status":"Up 2 hours"}',
|
||||||
|
"",
|
||||||
|
"not json at all",
|
||||||
|
'{"state":"running"}',
|
||||||
|
'{"name":"api","state":"running","status":"Up 1 hour (unhealthy)"}',
|
||||||
|
].join("\n"),
|
||||||
|
);
|
||||||
|
expect([...states.keys()]).toEqual(["web", "api"]);
|
||||||
|
expect(states.get("api")).toEqual({ state: "running", unhealthy: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports transitions and skips containers seen for the first time", () => {
|
||||||
|
const running = { state: "running", unhealthy: false };
|
||||||
|
const exited = { state: "exited", unhealthy: false };
|
||||||
|
const restarting = { state: "restarting", unhealthy: false };
|
||||||
|
expect(
|
||||||
|
diffContainerStates(
|
||||||
|
new Map([
|
||||||
|
["web", running],
|
||||||
|
["db", exited],
|
||||||
|
["cache", running],
|
||||||
|
]),
|
||||||
|
new Map([
|
||||||
|
["web", exited],
|
||||||
|
["db", running],
|
||||||
|
["cache", restarting],
|
||||||
|
["new", running],
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
).toEqual([
|
||||||
|
{ container: "web", event: "exited" },
|
||||||
|
{ container: "db", event: "started" },
|
||||||
|
{ container: "cache", event: "restarting" },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import type {
|
||||||
|
PluginSshConnectOptions,
|
||||||
|
PluginSshHost,
|
||||||
|
} from "@termix/plugin-sdk/backend";
|
||||||
|
import { startServer, type TestServer } from "./server";
|
||||||
|
import { FakeClient } from "./fake-ssh";
|
||||||
|
|
||||||
|
let server: TestServer | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await server?.close();
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
async function connect(s: TestServer, sessionId = "s1", hostId = 7) {
|
||||||
|
return s.request("POST", "/ssh/connect", { body: { sessionId, hostId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swaps ctx.ssh.connect for one that runs `flow` against the prompt channel. */
|
||||||
|
function promptingConnect(
|
||||||
|
s: TestServer,
|
||||||
|
flow: (
|
||||||
|
ask: NonNullable<PluginSshConnectOptions["prompt"]>["ask"],
|
||||||
|
) => Promise<void>,
|
||||||
|
) {
|
||||||
|
s.mock.ctx.ssh.connect = (async (
|
||||||
|
host: PluginSshHost,
|
||||||
|
options?: PluginSshConnectOptions,
|
||||||
|
) => {
|
||||||
|
await flow(options!.prompt!.ask);
|
||||||
|
return {
|
||||||
|
client: s.client as never,
|
||||||
|
jumpClient: null,
|
||||||
|
host,
|
||||||
|
dispose: () => s.client.end(),
|
||||||
|
};
|
||||||
|
}) as never;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("docker routes", () => {
|
||||||
|
it("refuses every route without docker.use", async () => {
|
||||||
|
server = await startServer({ permissions: [] });
|
||||||
|
const calls: Array<[string, string]> = [
|
||||||
|
["POST", "/ssh/connect"],
|
||||||
|
["POST", "/ssh/connect-totp"],
|
||||||
|
["POST", "/ssh/connect-warpgate"],
|
||||||
|
["POST", "/ssh/disconnect"],
|
||||||
|
["POST", "/ssh/keepalive"],
|
||||||
|
["GET", "/ssh/status?sessionId=s1"],
|
||||||
|
["GET", "/validate/s1"],
|
||||||
|
["GET", "/containers/s1"],
|
||||||
|
["GET", "/containers/s1/abc"],
|
||||||
|
["POST", "/containers/s1/abc/start"],
|
||||||
|
["DELETE", "/containers/s1/abc/remove"],
|
||||||
|
["GET", "/containers/s1/abc/logs"],
|
||||||
|
["GET", "/containers/s1/abc/stats"],
|
||||||
|
];
|
||||||
|
for (const [method, path] of calls) {
|
||||||
|
const response = await server.request(method, path, {
|
||||||
|
body: method === "GET" ? undefined : {},
|
||||||
|
});
|
||||||
|
expect(response.status, `${method} ${path}`).toBe(403);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a host with Docker switched off", async () => {
|
||||||
|
server = await startServer({ dockerOn: false });
|
||||||
|
const response = await connect(server);
|
||||||
|
expect(response.status).toBe(403);
|
||||||
|
expect(response.body.code).toBe("DOCKER_DISABLED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers 404 for a host the user cannot reach", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
expect((await connect(server, "s1", 99)).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects, validates and manages containers on the session", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
const connected = await connect(server);
|
||||||
|
expect(connected.status).toBe(200);
|
||||||
|
expect(connected.body.success).toBe(true);
|
||||||
|
expect(server.mock.statusReports).toContainEqual({ hostId: 7, ok: true });
|
||||||
|
|
||||||
|
const validation = await server.request("GET", "/validate/s1");
|
||||||
|
expect(validation.body).toEqual({
|
||||||
|
available: true,
|
||||||
|
version: "27.1.1",
|
||||||
|
runtime: "docker",
|
||||||
|
});
|
||||||
|
|
||||||
|
const list = await server.request("GET", "/containers/s1");
|
||||||
|
expect(list.body).toEqual([
|
||||||
|
expect.objectContaining({ id: "abc123", name: "web", state: "running" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
server.client.reply(/ start abc123$/, { stdout: "abc123" });
|
||||||
|
const started = await server.request("POST", "/containers/s1/abc123/start");
|
||||||
|
expect(started.status).toBe(200);
|
||||||
|
expect(
|
||||||
|
server.client.commands.some((c) => c.endsWith("docker start abc123")),
|
||||||
|
).toBe(true);
|
||||||
|
|
||||||
|
server.client.reply(/ logs abc123/, { stdout: "line one\nline two\n" });
|
||||||
|
const logs = await server.request(
|
||||||
|
"GET",
|
||||||
|
"/containers/s1/abc123/logs?tail=50&since=2026-01-01T00:00:00Z",
|
||||||
|
);
|
||||||
|
expect(logs.body).toEqual({ success: true, logs: "line one\nline two\n" });
|
||||||
|
expect(server.client.commands.at(-1)).toContain(
|
||||||
|
"logs abc123 --tail 50 --since 2026-01-01T00:00:00Z 2>&1",
|
||||||
|
);
|
||||||
|
|
||||||
|
server.client.reply(/ stats abc123/, {
|
||||||
|
stdout:
|
||||||
|
'{"cpu":"1.5%","memory":"10MiB / 1GiB","memoryPercent":"1%","netIO":"1kB / 2kB","blockIO":"0B / 0B","pids":"3"}',
|
||||||
|
});
|
||||||
|
const stats = await server.request("GET", "/containers/s1/abc123/stats");
|
||||||
|
expect(stats.body).toMatchObject({
|
||||||
|
cpu: "1.5%",
|
||||||
|
memoryUsed: "10MiB",
|
||||||
|
memoryLimit: "1GiB",
|
||||||
|
netInput: "1kB",
|
||||||
|
netOutput: "2kB",
|
||||||
|
});
|
||||||
|
|
||||||
|
server.client.reply(/ rm abc123/, {
|
||||||
|
code: 1,
|
||||||
|
stderr: "Error: No such container: abc123",
|
||||||
|
});
|
||||||
|
const removed = await server.request(
|
||||||
|
"DELETE",
|
||||||
|
"/containers/s1/abc123/remove",
|
||||||
|
);
|
||||||
|
expect(removed.status).toBe(404);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(await server.request("POST", "/containers/s1/abc123/explode")).status,
|
||||||
|
).toBe(404);
|
||||||
|
expect(
|
||||||
|
(await server.request("GET", "/containers/s1/bad;id/logs")).status,
|
||||||
|
).toBe(400);
|
||||||
|
|
||||||
|
await server.request("POST", "/ssh/disconnect", {
|
||||||
|
body: { sessionId: "s1" },
|
||||||
|
});
|
||||||
|
const status = await server.request("GET", "/ssh/status?sessionId=s1");
|
||||||
|
expect(status.body.connected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses Podman when the host says so", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
await server.mock.ctx.settings.setHost(7, "containerRuntime", "podman");
|
||||||
|
await connect(server);
|
||||||
|
await server.request("GET", "/containers/s1");
|
||||||
|
expect(server.client.commands.at(-1)).toMatch(/ podman ps -a --format/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps another user's session to its owner", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
await connect(server);
|
||||||
|
const response = await server.request("GET", "/containers/s1", {
|
||||||
|
user: "user-2",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
const status = await server.request("GET", "/ssh/status?sessionId=s1", {
|
||||||
|
user: "user-2",
|
||||||
|
});
|
||||||
|
expect(status.body.connected).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parks a TOTP prompt and re-asks after a wrong code", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
promptingConnect(server, async (ask) => {
|
||||||
|
let code = await ask({ kind: "totp", prompt: "Code:", retry: false });
|
||||||
|
while (code !== "123456") {
|
||||||
|
if (code === null) throw new Error("Cancelled");
|
||||||
|
code = await ask({ kind: "totp", prompt: "Code:", retry: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await connect(server);
|
||||||
|
expect(first.body).toMatchObject({ requires_totp: true, prompt: "Code:" });
|
||||||
|
|
||||||
|
const pending = await server.request("GET", "/containers/s1");
|
||||||
|
expect(pending.body.code).toBe("AUTH_PENDING");
|
||||||
|
|
||||||
|
const wrong = await server.request("POST", "/ssh/connect-totp", {
|
||||||
|
body: { sessionId: "s1", totpCode: "000000" },
|
||||||
|
});
|
||||||
|
expect(wrong.body).toMatchObject({ requires_totp: true, retry: true });
|
||||||
|
|
||||||
|
const stranger = await server.request("POST", "/ssh/connect-totp", {
|
||||||
|
user: "user-2",
|
||||||
|
body: { sessionId: "s1", totpCode: "123456" },
|
||||||
|
});
|
||||||
|
expect(stranger.status).toBe(404);
|
||||||
|
|
||||||
|
const right = await server.request("POST", "/ssh/connect-totp", {
|
||||||
|
body: { sessionId: "s1", totpCode: "123456" },
|
||||||
|
});
|
||||||
|
expect(right.body).toMatchObject({ success: true, status: "success" });
|
||||||
|
expect((await server.request("GET", "/containers/s1")).status).toBe(200);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("continues a Warpgate sign-in and refuses a code for it", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
promptingConnect(server, async (ask) => {
|
||||||
|
await ask({
|
||||||
|
kind: "warpgate",
|
||||||
|
url: "https://warpgate.example/login",
|
||||||
|
securityKey: "KEY",
|
||||||
|
instructions: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const first = await connect(server);
|
||||||
|
expect(first.body).toMatchObject({
|
||||||
|
requires_warpgate: true,
|
||||||
|
url: "https://warpgate.example/login",
|
||||||
|
securityKey: "KEY",
|
||||||
|
});
|
||||||
|
const wrongKind = await server.request("POST", "/ssh/connect-totp", {
|
||||||
|
body: { sessionId: "s1", totpCode: "1" },
|
||||||
|
});
|
||||||
|
expect(wrongKind.status).toBe(400);
|
||||||
|
|
||||||
|
const done = await server.request("POST", "/ssh/connect-warpgate", {
|
||||||
|
body: { sessionId: "s1" },
|
||||||
|
});
|
||||||
|
expect(done.body.success).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for credentials when a host with no secret gets a password prompt", async () => {
|
||||||
|
server = await startServer({
|
||||||
|
mock: {
|
||||||
|
sshHosts: [
|
||||||
|
{
|
||||||
|
id: 7,
|
||||||
|
userId: "user-1",
|
||||||
|
ip: "10.0.0.7",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "none",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
promptingConnect(server, async (ask) => {
|
||||||
|
const answer = await ask({
|
||||||
|
kind: "input",
|
||||||
|
prompt: "Password:",
|
||||||
|
echo: false,
|
||||||
|
isPush: false,
|
||||||
|
});
|
||||||
|
if (answer === null)
|
||||||
|
throw new Error("All configured authentication methods failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await connect(server);
|
||||||
|
expect(response.body).toEqual({
|
||||||
|
status: "auth_required",
|
||||||
|
reason: "no_keyboard",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports a failed login to core", async () => {
|
||||||
|
const client = new FakeClient();
|
||||||
|
server = await startServer({ client });
|
||||||
|
server.mock.ctx.ssh.connect = (async () => {
|
||||||
|
throw new Error("All configured authentication methods failed");
|
||||||
|
}) as never;
|
||||||
|
const response = await connect(server);
|
||||||
|
expect(response.status).toBe(500);
|
||||||
|
expect(server.mock.statusReports).toContainEqual({
|
||||||
|
hostId: 7,
|
||||||
|
ok: false,
|
||||||
|
hostKeyChanged: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import type { AddressInfo } from "node:net";
|
||||||
|
import express, { type Router } from "express";
|
||||||
|
import {
|
||||||
|
createMockCtx,
|
||||||
|
type MockContextOptions,
|
||||||
|
type MockPluginContext,
|
||||||
|
} from "@termix/plugin-sdk/testing";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import manifestJson from "../../manifest.json";
|
||||||
|
import { activate } from "../../src/backend/index.js";
|
||||||
|
import { FakeClient } from "./fake-ssh";
|
||||||
|
|
||||||
|
export const manifest = manifestJson as unknown as PluginManifest;
|
||||||
|
|
||||||
|
export const PS_LINE =
|
||||||
|
'{"id":"abc123","name":"web","image":"nginx","status":"Up 2 hours","state":"running","ports":"80/tcp","created":"2026-01-01"}';
|
||||||
|
|
||||||
|
/** A host owned by user-1, as ctx.ssh.resolveHost sees it. */
|
||||||
|
export function sshHost(id: number, extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
userId: "user-1",
|
||||||
|
name: `host-${id}`,
|
||||||
|
ip: `10.0.0.${id}`,
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
authType: "password",
|
||||||
|
password: "pw",
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A client that looks like a Linux host with Docker running. */
|
||||||
|
export function dockerClient(): FakeClient {
|
||||||
|
return new FakeClient([
|
||||||
|
[/^ver$/, { stdout: "" }],
|
||||||
|
[/--version/, { stdout: "Docker version 27.1.1, build abc" }],
|
||||||
|
[/ ps -a --format/, { stdout: `${PS_LINE}\n` }],
|
||||||
|
[/ ps$/, { stdout: "" }],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TestServer {
|
||||||
|
mock: MockPluginContext;
|
||||||
|
client: FakeClient;
|
||||||
|
request: (
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
options?: { user?: string; body?: unknown },
|
||||||
|
// Route bodies vary per test; asserting on them is the point.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
) => Promise<{ status: number; body: any }>;
|
||||||
|
close: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The plugin activated with its router served the way core mounts it. */
|
||||||
|
export async function startServer(
|
||||||
|
options: {
|
||||||
|
permissions?: string[];
|
||||||
|
dockerOn?: boolean;
|
||||||
|
client?: FakeClient;
|
||||||
|
mock?: Partial<MockContextOptions>;
|
||||||
|
} = {},
|
||||||
|
): Promise<TestServer> {
|
||||||
|
const client = options.client ?? dockerClient();
|
||||||
|
let router: Router | null = null;
|
||||||
|
const mock = createMockCtx({
|
||||||
|
pluginId: manifest.id,
|
||||||
|
manifest,
|
||||||
|
capabilities: manifest.capabilities,
|
||||||
|
router: () => (router = express.Router()),
|
||||||
|
permissions: options.permissions ?? ["docker.use"],
|
||||||
|
sshHosts: [sshHost(7), sshHost(8)],
|
||||||
|
sshClient: client,
|
||||||
|
...options.mock,
|
||||||
|
});
|
||||||
|
if (options.dockerOn !== false) {
|
||||||
|
await mock.ctx.settings.setHost(7, "enableDocker", true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await activate(mock.ctx);
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use((req, _res, next) => {
|
||||||
|
mock.setActor(req.header("x-test-user") ?? undefined);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
app.use((req, res, next) => router!(req, res, next));
|
||||||
|
|
||||||
|
const server = http.createServer(app);
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, resolve));
|
||||||
|
const { port } = server.address() as AddressInfo;
|
||||||
|
|
||||||
|
return {
|
||||||
|
mock,
|
||||||
|
client,
|
||||||
|
async request(method, path, { user = "user-1", body } = {}) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
"x-test-user": user,
|
||||||
|
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
||||||
|
},
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return { status: response.status, body: text ? JSON.parse(text) : null };
|
||||||
|
},
|
||||||
|
async close() {
|
||||||
|
for (const dispose of mock.disposals.reverse()) await dispose();
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import type {
|
||||||
|
DockerEvent,
|
||||||
|
DockerEventsV1,
|
||||||
|
DockerServiceV1,
|
||||||
|
} from "../../src/backend/index.js";
|
||||||
|
import { startServer, type TestServer } from "./server";
|
||||||
|
|
||||||
|
let server: TestServer | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
await server?.close();
|
||||||
|
server = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
const psState = (state: string, status = "Up") =>
|
||||||
|
`{"name":"web","state":"${state}","status":"${status}"}\n`;
|
||||||
|
|
||||||
|
describe("docker.containers service", () => {
|
||||||
|
it("lists containers and runs actions as the caller", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
const docker = server.mock.services.get(
|
||||||
|
"docker.containers",
|
||||||
|
) as DockerServiceV1;
|
||||||
|
|
||||||
|
const containers = await server.mock.ctx.asUser("user-1", () =>
|
||||||
|
docker.listContainers(7),
|
||||||
|
);
|
||||||
|
expect(containers).toEqual([
|
||||||
|
expect.objectContaining({ id: "abc123", name: "web" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await server.mock.ctx.asUser("user-1", () =>
|
||||||
|
docker.action(7, "web", "restart"),
|
||||||
|
);
|
||||||
|
expect(server.client.commands.at(-1)).toMatch(/ docker restart web$/);
|
||||||
|
expect(server.mock.sshConnections.at(-1)).toMatchObject({
|
||||||
|
host: 7,
|
||||||
|
pool: "docker",
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
server.mock.ctx.asUser("user-1", () =>
|
||||||
|
docker.action(7, "web; rm -rf /", "stop"),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(/Invalid container name/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("needs a calling user", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
const docker = server.mock.services.get(
|
||||||
|
"docker.containers",
|
||||||
|
) as DockerServiceV1;
|
||||||
|
await expect(docker.listContainers(7)).rejects.toThrow(/calling user/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("docker.events service", () => {
|
||||||
|
it("reports state changes between polls to every subscriber", async () => {
|
||||||
|
vi.useFakeTimers({ toFake: ["Date"] });
|
||||||
|
server = await startServer();
|
||||||
|
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||||
|
const seen: DockerEvent[] = [];
|
||||||
|
const listener = (event: DockerEvent) => seen.push(event);
|
||||||
|
|
||||||
|
const unsubscribe = await server.mock.ctx.asUser("user-1", () =>
|
||||||
|
events.subscribe(7, listener),
|
||||||
|
);
|
||||||
|
// Subscribing the same listener again is a no-op.
|
||||||
|
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, listener));
|
||||||
|
|
||||||
|
const poll = async (output: string) => {
|
||||||
|
server!.client.reply(/ ps -a --format/, { stdout: output });
|
||||||
|
vi.setSystemTime(Date.now() + 61_000);
|
||||||
|
await server!.mock.runScheduled();
|
||||||
|
};
|
||||||
|
|
||||||
|
await poll(psState("running"));
|
||||||
|
expect(seen).toEqual([]);
|
||||||
|
|
||||||
|
await poll(psState("exited", "Exited (1)"));
|
||||||
|
expect(seen).toEqual([{ hostId: 7, container: "web", event: "exited" }]);
|
||||||
|
|
||||||
|
await poll(psState("running", "Up 1 second (unhealthy)"));
|
||||||
|
expect(seen.slice(1)).toEqual([
|
||||||
|
{ hostId: 7, container: "web", event: "started" },
|
||||||
|
{ hostId: 7, container: "web", event: "unhealthy" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
unsubscribe();
|
||||||
|
const before = server.client.commands.length;
|
||||||
|
await poll(psState("exited"));
|
||||||
|
expect(server.client.commands.length).toBe(before);
|
||||||
|
expect(seen).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops polling once the plugin is disposed", async () => {
|
||||||
|
server = await startServer();
|
||||||
|
const events = server.mock.services.get("docker.events") as DockerEventsV1;
|
||||||
|
await server.mock.ctx.asUser("user-1", () => events.subscribe(7, () => {}));
|
||||||
|
for (const dispose of [...server.mock.disposals].reverse()) await dispose();
|
||||||
|
expect(server.mock.scheduled.every((job) => job.stopped)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
renderWithApp,
|
||||||
|
type RenderedPluginApp,
|
||||||
|
} from "@termix/plugin-sdk/testing";
|
||||||
|
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
|
||||||
|
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
|
||||||
|
import * as plugin from "../../src/frontend/index";
|
||||||
|
import manifestJson from "../../manifest.json";
|
||||||
|
import locales from "../../locales/en.json";
|
||||||
|
|
||||||
|
const manifest = manifestJson as unknown as PluginManifest;
|
||||||
|
|
||||||
|
let rendered: RenderedPluginApp | null = null;
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rendered?.deactivate();
|
||||||
|
rendered = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
function host(enableDocker: boolean) {
|
||||||
|
return {
|
||||||
|
id: "7",
|
||||||
|
name: "box",
|
||||||
|
ip: "10.0.0.7",
|
||||||
|
port: 22,
|
||||||
|
username: "root",
|
||||||
|
enableSsh: true,
|
||||||
|
pluginSettings: { docker: { enableDocker } },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function stubApi(connectResult: Record<string, unknown>) {
|
||||||
|
const post = vi.fn(async (url: string) => {
|
||||||
|
if (url === "/ssh/connect") return { data: connectResult };
|
||||||
|
return { data: { success: true } };
|
||||||
|
});
|
||||||
|
const get = vi.fn(async (url: string) => {
|
||||||
|
if (url.startsWith("/validate/")) {
|
||||||
|
return {
|
||||||
|
data: { available: true, version: "27.1.1", runtime: "docker" },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (url.startsWith("/containers/")) {
|
||||||
|
return {
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "abc123",
|
||||||
|
name: "web",
|
||||||
|
image: "nginx",
|
||||||
|
status: "Up",
|
||||||
|
state: "running",
|
||||||
|
ports: "",
|
||||||
|
created: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { data: {} };
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
post,
|
||||||
|
get,
|
||||||
|
delete: vi.fn(),
|
||||||
|
put: vi.fn(),
|
||||||
|
patch: vi.fn(),
|
||||||
|
} as unknown as {
|
||||||
|
post: typeof post;
|
||||||
|
get: typeof get;
|
||||||
|
} & PluginApiClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("DockerManager", () => {
|
||||||
|
it("says Docker is off for a host without the switch", async () => {
|
||||||
|
const api = stubApi({ success: true });
|
||||||
|
rendered = await renderWithApp(plugin, {
|
||||||
|
manifest,
|
||||||
|
locales,
|
||||||
|
hosts: [host(false)],
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
rendered.renderTab("docker", {
|
||||||
|
host: host(false),
|
||||||
|
label: "box",
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Docker is not enabled for this host"),
|
||||||
|
).toBeTruthy();
|
||||||
|
expect(api.post).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("connects through the plugin api and lists containers", async () => {
|
||||||
|
const api = stubApi({ success: true, status: "success" });
|
||||||
|
rendered = await renderWithApp(plugin, {
|
||||||
|
manifest,
|
||||||
|
locales,
|
||||||
|
hosts: [host(true)],
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
rendered.renderTab("docker", {
|
||||||
|
host: host(true),
|
||||||
|
label: "box",
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.post).toHaveBeenCalledWith(
|
||||||
|
"/ssh/connect",
|
||||||
|
expect.objectContaining({ hostId: 7 }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(api.get).toHaveBeenCalledWith(
|
||||||
|
expect.stringMatching(/^\/validate\//),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the code prompt when the host asks for one", async () => {
|
||||||
|
const api = stubApi({
|
||||||
|
requires_totp: true,
|
||||||
|
sessionId: "s1",
|
||||||
|
prompt: "Verification code:",
|
||||||
|
});
|
||||||
|
rendered = await renderWithApp(plugin, {
|
||||||
|
manifest,
|
||||||
|
locales,
|
||||||
|
hosts: [host(true)],
|
||||||
|
api,
|
||||||
|
});
|
||||||
|
rendered.renderTab("docker", {
|
||||||
|
host: host(true),
|
||||||
|
label: "box",
|
||||||
|
isVisible: true,
|
||||||
|
});
|
||||||
|
expect(await screen.findByText("Verification code:")).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,11 @@ describe(`${manifest.id} activate`, () => {
|
|||||||
for (const id of rendered.registered.dashboardCards()) {
|
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 () => {
|
||||||
|
|||||||
@@ -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
Reference in new issue
Block a user