@hyzyn/dsh-docker 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.en.md +921 -0
- package/README.md +93 -12
- package/client.js +214 -26
- package/lib/docker.d.ts +34 -0
- package/lib/docker.js +93 -0
- package/lib/docker.js.map +1 -1
- package/lib/index.js +306 -19
- package/lib/index.js.map +1 -1
- package/package.json +4 -1
package/README.en.md
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
# @hyzyn/dsh-docker
|
|
2
|
+
|
|
3
|
+
[中文](README.md) | English
|
|
4
|
+
|
|
5
|
+
> The DSH sidebar "Containers" panel: containers on the local machine and on SSH hosts inspected on one screen — read logs, watch resources, enter containers, start / stop / remove, **Read-only by default**.
|
|
6
|
+
|
|
7
|
+
## Features
|
|
8
|
+
|
|
9
|
+
- **Many targets, one screen**: the "Overview" page fetches in parallel and one target failing does not affect the others; on the agent side `docker_ps target:'*'` gets the whole picture in one call.
|
|
10
|
+
- **The "needs attention" criteria are accurate**: unhealthy / repeatedly restarting / **OOM-killed** / non-zero exit / zombie — OOM and the real exit code come from one `docker inspect` (a `ps` summary cannot tell whether 137 was an OOM kill or a manual kill).
|
|
11
|
+
- **Four live SSE streams**: log FOLLOW, `docker stats`, `docker events` and `docker pull` share one piece of infrastructure (heartbeat / active-stream registry / teardown on disconnect); merged logs from a multi-select can be merged into one true timeline by timestamp, and "pause" is a real freeze.
|
|
12
|
+
- **Read-only by default**: start / stop / remove, exec and image mutations all require switches explicitly enabled in settings; while they are off the tools are not registered and the routes return 403; passwords and passphrases are never sent back to the browser.
|
|
13
|
+
- **tty is an optional partner**: with it installed there is an extra "Containers" button in the connection bar, SSH targets can reference connection-book entries directly, and cards gain a "Terminal" button that goes straight into the container; it also works without tty (degrading to copying the command).
|
|
14
|
+
|
|
15
|
+
## Relationship with dsh-tty
|
|
16
|
+
|
|
17
|
+
This plugin stands on its own: it imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently. With tty installed they cooperate at the optional extension points below, and without it they degrade quietly.
|
|
18
|
+
|
|
19
|
+
| Dimension | Description |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| Plugin form | A standalone package `@hyzyn/dsh-docker` that imports no tty code, and tty needs no source changes; the two can be installed and upgraded independently |
|
|
22
|
+
| Connection book | SSH targets can **reference a tty connection-book entry name** (read-only access to `sshHosts` through `ctx.settings.get('tty')`); when tty is not installed this degrades to "inline host/username" or a local target |
|
|
23
|
+
| Host fingerprints | This plugin keeps its own `hostKeys` (TOFU) and **prefers tty's already-recorded fingerprints as the seed** — the same host does not have to be confirmed in two places |
|
|
24
|
+
| Execution channel | Its own pooled SSH exec (`src/ssh-exec.ts`), fully independent of tty's PTY sessions; neither takes the other's slots |
|
|
25
|
+
| Context entry point | With tty ≥ 0.13.0 it can optionally consume tty's client service `ttyConnbar` and insert a "Containers" button in the SSH connection bar (next to SFTP) (**shown as soon as it is registered**), with the target resolved from the current session at click time; if tty is missing or too old this is skipped silently |
|
|
26
|
+
| Panel hosting | With tty ≥ 0.16 and the terminal panel open, the container panel is **docked to the right of the terminal** via `ttyPanel.mountPane` (resize / collapse / ✕ provided by tty) while the terminal stays visible and usable; otherwise it falls back to a full-screen modal with its own backdrop. The panel itself is the same component, only the host differs. The dock holds one panel at a time: when this plugin docks it takes down the previous one (for example tty's own SFTP), and conversely SFTP falls back to its own dialog when the mount slot is already taken |
|
|
27
|
+
| Terminal hosting | Interactive terminals are hosted by tty (it owns the PTY): with tty ≥ 0.15 they are **embedded in place** into the terminal drawer at the bottom of this panel via `ttyTerminal.mount` (in dock mode this becomes a **new tab** in the same panel, avoiding a terminal inside a panel inside a terminal); with tty ≥ 0.14 it falls back to "open a tab + collapse this panel"; with neither it copies the command. This plugin implements no PTY / xterm / reconnect stack |
|
|
28
|
+
| Division of labour | **Interactive troubleshooting** (`docker exec -it`, a shell inside the container, TUIs) is hosted by tty (embedded drawer or tab); **read-only inspection and agent automation** use this plugin's own exec channel |
|
|
29
|
+
|
|
30
|
+
Reuse at the data level without coupling at the code level: the connection book and the fingerprint seed are "reading the same settings", and the connection-bar button is "consuming a generic extension point" — neither is "depending on tty's modules", so upgrading or uninstalling tty does not break this plugin along with it.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
dsh plugin --profile web add @hyzyn/dsh-docker # npm install (once published)
|
|
36
|
+
dsh plugin --profile web add link:$(pwd)/packages/docker # repo development and debugging
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The aggregate package `@hyzyn/dsh-all` (or the repo-root bundle) already includes this plugin, so there is no need to
|
|
40
|
+
add it separately when installing everything at once. After installing, restart `dsh web` and the "Containers"
|
|
41
|
+
entry appears in the sidebar; the Settings → Plugins →
|
|
42
|
+
"Docker Container Panel" card maintains targets and switches, and **saving applies hot** (`settings/updated`
|
|
43
|
+
triggers re-resolution, with no restart needed).
|
|
44
|
+
|
|
45
|
+
> If you have already installed `@hyzyn/dsh-all` or the root bundle in the web profile, do **not** add this package
|
|
46
|
+
> again, or the plugin line is mounted twice and startup reports `duplicate loader entry id`.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
Two entry points, one panel:
|
|
51
|
+
|
|
52
|
+
- **Sidebar “Containers”** (the main entry point): works for any target, including local docker and switching between multiple targets.
|
|
53
|
+
- **SSH connection bar "Containers" button** (a contextual shortcut, tty ≥ 0.13.0): in an SSH tab of the tty
|
|
54
|
+
terminal panel a "Containers" button appears next to the connection bar's SFTP button — **shown as soon as it is
|
|
55
|
+
registered**, and clicking it opens the panel directly on **the host of the current session**, with no target to
|
|
56
|
+
pick. The target is resolved at click time: when the session comes from the connection book it matches by entry
|
|
57
|
+
name, otherwise it matches a resolved target by `host:port`; **no matching target does not hide the
|
|
58
|
+
button** — the panel carries a hint naming the session host (including the connection-book name) and how to
|
|
59
|
+
configure it in the settings card.
|
|
60
|
+
|
|
61
|
+

|
|
62
|
+
|
|
63
|
+
A panel opened this way **never covers the terminal**: with tty ≥ 0.16 it docks to the right of the terminal
|
|
64
|
+
panel (draggable width, collapsible into a narrow strip, ✕ to tuck away) while you keep typing in the terminal;
|
|
65
|
+
only with an older tty or no open panel does it fall back to the full-screen modal. In dock mode the card's
|
|
66
|
+
"Terminal" button instead **opens a new tab in the same terminal panel** running
|
|
67
|
+
`docker exec -it` (the panel is already inside a terminal, so nesting one more layer makes no sense); it also
|
|
68
|
+
**stops rendering the panel's own header** — the title and ✕ are handled by the sidebar title bar, and the
|
|
69
|
+
refresh control and read-only badge move to the
|
|
70
|
+
**end of the toolbar, right-aligned** (while refreshing the icon spins itself, with no extra spinner): the left
|
|
71
|
+
end stays for the target / view / search / filter controls, so refresh is not mistaken for the first filter and
|
|
72
|
+
sits where it does in the non-dock header; a 520px narrow column does not leave a blank line behind.
|
|
73
|
+
|
|
74
|
+
Inside the panel:
|
|
75
|
+
|
|
76
|
+

|
|
77
|
+
|
|
78
|
+
- **Switching targets has a transition and guards**: as soon as the picker changes, the cards below are still the
|
|
79
|
+
previous target's (a remote round trip can take up to 20s). Three principles for the transition layer —
|
|
80
|
+
**no layout shift, a sense of direction, little grey**:
|
|
81
|
+
- a 2px **indeterminate shimmer progress bar** (absolutely positioned) along the top of the panel body gives a
|
|
82
|
+
global "fetching" signal;
|
|
83
|
+
- a **blue capsule floats up centred** at the top of the body (the same placement and colour language as the
|
|
84
|
+
dsh-rss loading capsule): background `color-mix(accent 12%, surface)`, border `accent 42%`, text and spinner in
|
|
85
|
+
`--dk-accent`; the visible copy is just two segments, `⟳ Switching to Target2 · Currently showing: Target1`
|
|
86
|
+
(not written as a sentence, no quotes around target names), while the full "why won't it respond" story lives in
|
|
87
|
+
`title`, available on hover;
|
|
88
|
+
- old data stays at **82% + slight desaturation** (not dimmed into illegibility) and **the pointer is locked**: it
|
|
89
|
+
reads as "a different batch of content" rather than "it broke", and it also prevents acting on the stale list —
|
|
90
|
+
that would aim at the new target while sending commands with container IDs from the old list and really could
|
|
91
|
+
stop a same-named container on the other side;
|
|
92
|
+
- new data lands with an **8px slide-up + fade-in** over 200ms, so that "a different batch" is visible; the motion
|
|
93
|
+
respects `prefers-reduced-motion`.
|
|
94
|
+
|
|
95
|
+
Everything above is **absolutely positioned**: during a switch the first card's position and the scroll height
|
|
96
|
+
provably do not change at all (a banner approach would push the whole block of content down). **A failed switch
|
|
97
|
+
clears the old list** and settles into the new target's error state (it does not keep showing another target's
|
|
98
|
+
data), and the empty-state copy distinguishes "failed to read" from "filtered too narrowly".
|
|
99
|
+
- **Target selection**: the panel picks a target first (from the `targets` config, local / SSH); with only one
|
|
100
|
+
target configured it is selected by default, and agent tools may also omit the `target` parameter.
|
|
101
|
+
**The last selected target is remembered** (stored in the browser's `localStorage` under
|
|
102
|
+
`dsh-docker:last-target`, not written to config or settings): the next time the panel opens it is selected
|
|
103
|
+
automatically. The priority is **what the connection bar specifies > last remembered (and still present) >
|
|
104
|
+
first in the list** — once the remembered target is deleted or renamed it falls back to the first one instead of
|
|
105
|
+
resting on an "unknown target"; when entering from the terminal connection bar's "Containers" button, the current
|
|
106
|
+
session host takes priority over the remembered value. Switching targets is **a whole-context switch**:
|
|
107
|
+
requests still in flight for the previous target are all invalidated (a list write gate), so there is no
|
|
108
|
+
cross-talk like "the picker is already Target2 while the cards are still Target1's containers", and an old
|
|
109
|
+
target's timeout banner does not linger on the new target's page.
|
|
110
|
+
- **Multi-target Overview (read-only)**: the `Overview` pill next to the target picker (shown only with ≥2 targets
|
|
111
|
+
configured) — pick no target and see every host on one screen: a row of **counter cards** on top
|
|
112
|
+
(target name + a local / SSH marker + running / stopped / unhealthy), and below it a **table of containers needing
|
|
113
|
+
attention pinned to the top** (container / target / status / image; unhealthy sorts before restarting, ties follow
|
|
114
|
+
the targets' order in config, and rows do not jump between polls). Fetching is **parallel + progressive**:
|
|
115
|
+
each target issues its own `POST /containers` (`all:true`, since a bare `docker ps` does not return exited
|
|
116
|
+
containers and "stopped" would always be 0), and an unreachable target affects only its own cell — the card
|
|
117
|
+
outlined in red plus an "N targets unreachable" banner at the top, while the other targets' results show as usual
|
|
118
|
+
(deliberately **not** aggregated with `Promise.all`: an unreachable SSH host waits out the 20s readyTimeout, so
|
|
119
|
+
aggregating would keep the whole page silent for 20s). Clicking a counter card returns to that target's normal
|
|
120
|
+
container list, and clicking an attention row opens that container's details (going back lands on **that
|
|
121
|
+
target's** container list); **the Overview performs no cross-target operations**, and the attention table's rows
|
|
122
|
+
have no action buttons. Entering the Overview clears the failed banner left over from "the current target": the
|
|
123
|
+
Overview attributes everything per target (red card + unreachable banner) and no longer stacks a single-target
|
|
124
|
+
"operation failed" — the same SSH timeout told twice looks like two dead machines. Polling reuses the toolbar's
|
|
125
|
+
"auto refresh" switch (off by default): each round of this page is one `docker ps` per target across all N
|
|
126
|
+
targets, more expensive than any single-target page. With no exceptions it shows
|
|
127
|
+
"all good"; while some targets have not finished answering, the attention area shows "reading…" and
|
|
128
|
+
**that counter card also enters its loading state** (spinner + dimmed, no 0/0/0 — "0 containers" would be read as
|
|
129
|
+
"this machine has no containers" when it simply has not answered yet) ("don't know yet" is not "all good").
|
|
130
|
+
- **Container list**: name / status / health / image / port mappings / compose project and service /
|
|
131
|
+
short ID; it supports **search** by name or image, **filtering** by state (running / stopped / all),
|
|
132
|
+
and **auto refresh** (polling at `pollIntervalSec`; switching to the images page pauses it and hides that
|
|
133
|
+
switch — images change slowly, so there is no point running `docker images` every 5s; switching back to the
|
|
134
|
+
containers page restores the previous setting). In the toolbar "include stopped" and "auto refresh" are two
|
|
135
|
+
**grouped** switches, and the search box is the only flexible item among them: on a wide panel the whole toolbar
|
|
136
|
+
collapses into one row, and only in a narrow column (dock, 520px) does it wrap by group — no orphan row holding a
|
|
137
|
+
single checkbox.
|
|
138
|
+
- **"Activity" strip (the docker events stream)**: a collapsible narrow bar at the head of the container list
|
|
139
|
+
(expanded by default) that follows one SSE stream (`GET /api/dsh-docker/events/stream`, where the server runs
|
|
140
|
+
`docker events --filter type=container`) showing the last 8 events (time + container name + action, with `die`
|
|
141
|
+
carrying its exit code such as `die(137)`). It is the entry point for **event-driven refresh**: 500ms
|
|
142
|
+
**after an event arrives** it debounces a list refetch (not one request per frame), layering on top of the
|
|
143
|
+
existing `AUTO REFRESH` rather than excluding it — polling is the safety net, events cover "just happened". Events
|
|
144
|
+
stay in memory only (a ring buffer of 50), switching pages closes the stream, and switching targets clears the
|
|
145
|
+
buffer. The allowlist keeps only the nine lifecycle actions (start / die / stop / kill / oom /
|
|
146
|
+
health_status / destroy / rename / update): noise such as `exec_*` and `archive-path` (`docker cp`)
|
|
147
|
+
is dropped server-side — on one batch machine 47 events over 24 hours were all exec with 0 allowlist hits, so on
|
|
148
|
+
machines that are only ever exec'd the Activity strip is empty, and that is deliberate.
|
|
149
|
+
- **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
|
|
150
|
+
mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
|
|
151
|
+
longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
|
|
152
|
+
start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
|
|
153
|
+
`merged logs` + `Cancel`. `merged logs` needs at least 2 containers; at 7–8 selected it gives a soft hint
|
|
154
|
+
(browsers limit same-origin concurrent long connections), and above **8** the button is greyed out with a hint
|
|
155
|
+
about the cap. Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
|
|
156
|
+
project view (one `/logs/stream` per container, mixed by the `[service]` / container-name prefix, with filtering
|
|
157
|
+
and auto-scroll), and going back exits selection mode and clears it.
|
|
158
|
+
Clicking `Select for merging` again or pressing **Esc** likewise exits and clears. Selection is **temporary**:
|
|
159
|
+
not persisted, not named into groups, not written to settings; it is dropped when switching targets / switching
|
|
160
|
+
the "Containers · Images · Compose" segment / closing the panel, and containers that disappeared after a list
|
|
161
|
+
refresh are pruned by id.
|
|
162
|
+
- **One-click selection by condition** (in selection mode): the action bar's second row offers a row of condition
|
|
163
|
+
chips (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
|
|
164
|
+
selected), with counts **truncated to the remaining slots** — a chip reading 8 really does select 8; anything over
|
|
165
|
+
the cap is stated honestly in the title and the result hint. Conditions only apply within the
|
|
166
|
+
**current filtered result** (search / filter the state first, then select in one click).
|
|
167
|
+
- **Container cards**: "label + value" rows matching the reference layout (image / ID / ports / created /
|
|
168
|
+
compose, with monospaced truncatable values) plus a row of icon action buttons, **split by a vertical rule into
|
|
169
|
+
the "view / mutate" groups**:
|
|
170
|
+
|
|
171
|
+
| Group | Buttons | Description |
|
|
172
|
+
| --- | --- | --- |
|
|
173
|
+
| View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode |
|
|
174
|
+
| Mutate | start / stop, restart, remove | Ordered by increasing destructiveness; available only with `allowMutations` on, otherwise the whole group is greyed out |
|
|
175
|
+
|
|
176
|
+
Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second
|
|
177
|
+
confirmation after the click. **The whole group is locked while a command is in flight**: `stop` / `rm` waits for
|
|
178
|
+
the container to actually exit (up to a dozen seconds), during which the confirmation dialog stays open showing
|
|
179
|
+
"running…" (both buttons disabled), the card's other mutate buttons are dimmed and greyed, the icon that is running
|
|
180
|
+
becomes a spinner, and everything is restored only once the refreshed list lands — preventing rapid clicking from
|
|
181
|
+
stacking mutually interrupting commands such as stop + restart + remove. Image removal / pruning goes through the
|
|
182
|
+
same confirmation dialog and also has a running state. Clicking a card body opens the overview.
|
|
183
|
+
- **Container details (a full-column view)**: the top is "back + container name + status badge + target host",
|
|
184
|
+
with three tabs below — overview (`docker inspect` authoritative data + one-shot exec), logs and
|
|
185
|
+
stats. The log / stats icons on a card land directly on the corresponding tab.
|
|
186
|
+
- **Log view (a compact two-row layout)**: the first row = back + container name + status + target host +
|
|
187
|
+
`LINES` (tail line count) / `TIMESTAMPS` / **`FOLLOW` (live follow, see below)** /
|
|
188
|
+
`AUTO REFRESH` (a switch plus 2/3/5/10s intervals, polling on the log page only) + refresh / download / close;
|
|
189
|
+
the second row = the tabs + an **always-present** "filter logs"
|
|
190
|
+
input (a fixed slot on the right shows "N lines" / "N / M lines matched"; when it has content an ✕ floats inside
|
|
191
|
+
to clear, and Esc clears too). The input's width and position never change, so typing or clearing never nudges
|
|
192
|
+
this row. The log body is coloured by level (both common prefixes, `[INFO]` and `|INFO`,
|
|
193
|
+
are recognised), timestamps are dimmed, and filter hits are highlighted; beyond 2000 lines only the tail is
|
|
194
|
+
coloured, with a hint. In the details view the list toolbar and panel header are no longer layered on top, so
|
|
195
|
+
each screen has exactly one refresh entry point.
|
|
196
|
+
- **FOLLOW live log stream**: with the log page's `FOLLOW` switch on, the UI switches from "polling a snapshot" to
|
|
197
|
+
**SSE push** (`GET /api/dsh-docker/logs/stream`, where the server runs
|
|
198
|
+
`docker logs --follow`) — new log lines are appended as they arrive and polling stops; `FOLLOW` and
|
|
199
|
+
`AUTO REFRESH` are mutually exclusive (opening the stream stops polling and greys out the switch), and closing it
|
|
200
|
+
returns to snapshots with an immediate refresh. Streaming logs keep the last **5000 lines** (a ring buffer that
|
|
201
|
+
drops the oldest and hints once); filtering / level colouring share exactly the same rendering as snapshots. It
|
|
202
|
+
auto-scrolls to the bottom, pauses when the user scrolls up and floats a
|
|
203
|
+
"back to bottom" button; a status line in the top right shows the connection state, a browser disconnect is
|
|
204
|
+
auto-reconnected by EventSource (updating only the status, without an error banner), and a stream that ends
|
|
205
|
+
naturally because the container exited switches back to snapshot refresh automatically.
|
|
206
|
+
Connecting / switching pages / closing the panel all close the `EventSource`.
|
|
207
|
+
- **Overview**: `docker inspect`'s authoritative data — state and health, exit code, restart count and policy,
|
|
208
|
+
port mappings, mounts (including read-only flags), networks and IPs, entrypoint and command, and the latest
|
|
209
|
+
health-check output; below it you can run a one-shot `docker exec` (requires `allowExec`).
|
|
210
|
+
- **Logs**: a tail snapshot from `docker logs --tail` (`logTailDefault` lines by default), with timestamps and
|
|
211
|
+
`--since` switchable; output beyond `maxOutputKb` is truncated and marked. To keep watching new logs, turn on
|
|
212
|
+
`FOLLOW` above (the same argv plus `--follow`, with no overall timeout and no output cap, ending on the
|
|
213
|
+
connection's lifecycle).
|
|
214
|
+
|
|
215
|
+

|
|
216
|
+
- **Stats**: a `docker stats --no-stream` snapshot (CPU% / memory usage and share / network IO /
|
|
217
|
+
block IO / PIDs), refreshed by the panel at `pollIntervalSec`. The stats page also has **`FOLLOW` live
|
|
218
|
+
following**: turning it on switches to `GET /api/dsh-docker/stats/stream` (the server runs **without**
|
|
219
|
+
`--no-stream` for `docker stats`, one line per second), and the browser side keeps a **60-point ring
|
|
220
|
+
buffer** to draw CPU / memory **mini trend charts** (sparklines). Unlike the log stream, this one **does not end
|
|
221
|
+
naturally**; its close semantics are the frontend actively aborting the `EventSource`, and when `docker stats`
|
|
222
|
+
exits by itself the server sends `end` (reason=`stats-exit`), whereupon the UI hints and switches back to snapshot
|
|
223
|
+
polling.
|
|
224
|
+
- **Compose project view**: the toolbar's third segment. It groups the `composeProject` / `composeService`
|
|
225
|
+
labels into "project → service → container", one row per project showing the running / unhealthy / service counts
|
|
226
|
+
and each container's state; clicking into a project shows the service table, or opens **project-level merged
|
|
227
|
+
logs** — one `/logs/stream` per container in the project, mixed client-side by the `[service]` prefix (the
|
|
228
|
+
host-side `logsStream` already supports arbitrary containers, so no new endpoint is needed), with an auto-scroll
|
|
229
|
+
switch and a filter box. Merging follows arrival order and does not guarantee strict cross-container ordering.
|
|
230
|
+
- **Images**: a `docker images` list (reference / size / created / short ID);
|
|
231
|
+
`<none>:<none>` dangling images carry a `dangling` marker. The search box and the "N / M images"
|
|
232
|
+
counter are **fixed in the toolbar** (they do not scroll away with the list), and the header column names stick
|
|
233
|
+
within the table body. Each row has two actions, "details / remove": **details** opens the full-column view
|
|
234
|
+
(overview: size / size including parents / created / platform / layers / entrypoint and command / exposed ports /
|
|
235
|
+
digest / labels; build history: the per-layer commands and sizes from `docker history`). **Remove** (requires
|
|
236
|
+
`allowMutations`) runs `docker image rm` after a second confirmation (**without** `-f`, so an image that is
|
|
237
|
+
referenced fails with a hint to "remove the related containers first").
|
|
238
|
+
- **Networks / volumes (the fifth and sixth segments)**: the toolbar segments extend to "Containers / Images /
|
|
239
|
+
Compose / Networks / Volumes" (when a narrow column cannot fit them, the segment container scrolls horizontally
|
|
240
|
+
by itself, with no second-level menu). Both pages use the same "table + full-column details" layout:
|
|
241
|
+
- **Networks**: name / driver / scope / internal badge / ID, with a row click opening details — overview
|
|
242
|
+
(ID / driver / scope / created / subnet / gateway / internal·attachable·ingress·ipv6 / options / labels) and a
|
|
243
|
+
"connected containers" tab (container / IPv4 / IPv6 / MAC). **Container counts deliberately stay out of the
|
|
244
|
+
list rows**: only `docker network inspect` returns the connected list, and inspecting every row would be N
|
|
245
|
+
docker calls, so it is fetched once on entering details instead.
|
|
246
|
+
- **Volumes**: name / driver / scope / mountpoint (over-long paths are width-limited and truncated, with the full
|
|
247
|
+
value in title); details are name / driver / scope / mountpoint / created / options / labels (volumes have no
|
|
248
|
+
reverse index, so there is only the overview page).
|
|
249
|
+
- The details header has a **remove** button, and both toolbars have a **prune icon**, all gated by
|
|
250
|
+
`allowMutations` (greyed out with an explanatory title when off) and all requiring a second confirmation; a
|
|
251
|
+
failed removal (a network still has containers attached / a volume is still in use / 403) shows an inline banner
|
|
252
|
+
on the details page rather than failing silently.
|
|
253
|
+
- Auto refresh matches the images page: these two pages **do not poll** (inventories change slowly, and a `docker`
|
|
254
|
+
CLI call every 5s is burnt for nothing); they refresh only when switching pages or targets.
|
|
255
|
+
- **Pulling images (an SSE progress stream)**: the pull icon in the images toolbar (requires `allowMutations`)
|
|
256
|
+
opens the pull view; after entering a reference it goes through `GET /api/dsh-docker/images/pull/stream`
|
|
257
|
+
(where the server runs `docker pull`) — per-layer progress (Pulling fs layer / Downloading /
|
|
258
|
+
Extracting / Pull complete) appears live; progress lines are updated in place keyed by "layer key", and TTY `\r`
|
|
259
|
+
refreshes do not make the buffer grow ever longer. The toolbar's "prune dangling" runs
|
|
260
|
+
`docker image prune -f`, which **only removes untagged images** (deliberately without `--all`, to avoid
|
|
261
|
+
deleting ordinary unused images by mistake).
|
|
262
|
+
|
|
263
|
+

|
|
264
|
+
- **One-shot exec**: with `allowExec` on you can type a command, equivalent to
|
|
265
|
+
`docker exec <container> sh -c "<command>"`, returning the exit code and stdout/stderr (no TTY).
|
|
266
|
+
- **Interactive terminal (the first icon on a card)**: it runs `docker exec -it '<container>' sh`. It degrades in
|
|
267
|
+
three steps depending on tty's capabilities —
|
|
268
|
+

|
|
269
|
+
|
|
270
|
+
1. **Embedded in place (tty ≥ 0.15, recommended)**: a **terminal drawer** opens at the bottom of the panel, and
|
|
271
|
+
tty's `ttyTerminal.mount` mounts the terminal into it. The panel does not collapse, so you can watch the
|
|
272
|
+
container's logs and step into the container to type commands without losing context. The drawer takes height
|
|
273
|
+
away from the body, so it can give way without ending the session:
|
|
274
|
+
**collapsing** (the arrow at the right of its title bar, or double-clicking its top edge) squashes the drawer
|
|
275
|
+
into one title bar while the session keeps running; **dragging the top edge** resizes it (capped at 75% of the
|
|
276
|
+
panel height). Only two actions really end a session — the ✕ on the drawer and closing the panel; with an
|
|
277
|
+
active session, closing the panel asks for confirmation first, so that clicking the blank backdrop does not
|
|
278
|
+
kill a container shell that is mid-troubleshooting (the global terminal panel also treats "clicking the blank
|
|
279
|
+
area = minimize", the same stance).
|
|
280
|
+
2. **Open a tab through tty (tty ≥ 0.14)**: tty opens a new tab running the same command, and this panel then
|
|
281
|
+
collapses (this panel has a higher z-index, so not collapsing would only make the user feel "nothing
|
|
282
|
+
happened").
|
|
283
|
+
3. **Copy the command**: when tty is not installed / too old / an inline target uses key·password auth (the
|
|
284
|
+
browser has no credentials), it degrades to copying the command with a hint to paste it into the terminal
|
|
285
|
+
panel.
|
|
286
|
+
|
|
287
|
+
Local targets open a local session, while SSH targets go over SSH by connection-book entry name (or by the inline
|
|
288
|
+
fields authenticated by the agent), and the tab / drawer title is `<container> · exec`. Capability detection goes
|
|
289
|
+
through the service contract version (`mount` exists only with `ttyTerminal.version >= 2`), not by guessing
|
|
290
|
+
whether a function exists.
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
### Targets (local / SSH)
|
|
294
|
+
|
|
295
|
+
| `kind` | Description |
|
|
296
|
+
| --- | --- |
|
|
297
|
+
| `local` | the docker CLI on the machine hosting the plugin (`spawn` runs it directly, not through a shell) |
|
|
298
|
+
| `ssh` | connects to a remote host over `ssh2` and runs the docker CLI remotely (argv single-quote escaped) |
|
|
299
|
+
|
|
300
|
+
There are two ways to fill in `kind=ssh`:
|
|
301
|
+
|
|
302
|
+
1. **Reference a tty connection-book entry**: put the entry name in `book` (maintained in the connection book
|
|
303
|
+
under Settings → Plugins → Terminal Panel), and the host / port / username / auth method follow from it. The
|
|
304
|
+
settings card's dropdown only lists connection-book entries tty has saved; if tty is not installed or the entry
|
|
305
|
+
does not exist, that target fails to resolve and both the panel and the agent tools report a clear error.
|
|
306
|
+
2. **Inline fields**: `host` + `username` are required, the rest as needed (`port` / `auth` /
|
|
307
|
+
`keyPath` / `password` / `passphrase` / `agentForward`).
|
|
308
|
+
|
|
309
|
+
Targets are resolved **fresh for every operation**: if you change a connection-book entry in the tty card (a
|
|
310
|
+
different port, a new password), the next operation uses the new value immediately with no restart. The remote side
|
|
311
|
+
must satisfy: the docker CLI is installed, and the current account can use docker
|
|
312
|
+
**without sudo** (usually because it is in the `docker` group); otherwise `probe` passes through errors such as
|
|
313
|
+
`permission denied while trying to connect to the Docker daemon socket` verbatim.
|
|
314
|
+
|
|
315
|
+
### Merged logs (multi-select / Compose project)
|
|
316
|
+
|
|
317
|
+
Selecting several containers or opening a Compose project can both merge the logs of several containers into one
|
|
318
|
+
stream (one `docker logs -f` SSE per container, mixed client-side in arrival order). The toolbar offers:
|
|
319
|
+
|
|
320
|
+
| Control | Semantics |
|
|
321
|
+
| --- | --- |
|
|
322
|
+
| **Live / Paused** | a real pause (see below) |
|
|
323
|
+
| **Timestamps** | shows a timestamp on every line. Timestamps are **always received with the stream** (`timestamps=1`); this only affects display |
|
|
324
|
+
| **By arrival / By time** | `by arrival`: follows with zero delay; `by time`: merges into one true timeline using each line's container timestamp |
|
|
325
|
+
| **Level filter** | `all levels / WARN+ / ERROR+`. **Lines with no level prefix (continuation lines such as stack traces) inherit the level of the previous log entry**, so ERROR+ keeps its stack trace with it and INFO continuations are filtered out together with their first line; orphan continuation lines at the start of the window (whose record header is outside the window) cannot be judged and are kept |
|
|
326
|
+
| **⬇ .log / ⬇ .md** | exports what is currently displayed: `.log` is plain line text (`[service] ISO time body`), while `.md` carries a header with the source containers / line count / export time and can be attached to a ticket directly |
|
|
327
|
+
|
|
328
|
+
**How merging by time works**: the SSE connections for the various containers are established at different moments,
|
|
329
|
+
so A's initial backlog may arrive in one batch while B's arrives later, and sorting each batch on its own cannot fix
|
|
330
|
+
cross-batch inversions. The implementation **backfills the last 400 lines** — whenever a new batch of lines arrives
|
|
331
|
+
it re-sorts "the last 400 lines + the new lines" by timestamp (using the RFC3339 prefix from
|
|
332
|
+
`docker logs --timestamps` as the sort key, which is parsed and then stripped from the body). That way it neither
|
|
333
|
+
stalls for a while to get the first screen right (no one-second blank page on open) nor fails to correct historical
|
|
334
|
+
misordering after the fact; the price is that in "by time" mode the last few lines already on screen may shift
|
|
335
|
+
slightly (use "by arrival" while following live output).
|
|
336
|
+
|
|
337
|
+
### "Pause" in merged logs (a real pause)
|
|
338
|
+
|
|
339
|
+
The merged logs of a multi-select / Compose project have a `Live / Paused` switch. **Pausing freezes the content**,
|
|
340
|
+
not just auto-scroll:
|
|
341
|
+
|
|
342
|
+
- while paused, newly arriving logs go into a client-side buffer and **the DOM is no longer appended to** — the
|
|
343
|
+
screen reader is not pushed away, and the view does not jump when the display cap (2000 lines) trims the front;
|
|
344
|
+
- the button itself shows how many lines have accumulated (`Paused +348`);
|
|
345
|
+
- on resume the buffer is merged in one go (still under the 5000-line ring cap) and the view returns to the bottom.
|
|
346
|
+
|
|
347
|
+
Only stopping auto-scroll is not enough: with the label saying "paused" while the content keeps growing, users think
|
|
348
|
+
the switch is broken; and with a high log volume the view also jumps by itself as the front is trimmed.
|
|
349
|
+
|
|
350
|
+
### Overview (cross-target) and the "needs attention" criteria
|
|
351
|
+
|
|
352
|
+
The "Overview" page lays out all targets on one screen: one counter card per target (running / stopped / unhealthy /
|
|
353
|
+
**needs attention**), and below it a cross-target "containers needing attention" table (container / target / status /
|
|
354
|
+
**reason** / image; click a row for details, click a card to switch to that target's list). Three design constraints:
|
|
355
|
+
|
|
356
|
+
- **Progressive landing + failure isolation**: each target requests and lands independently; one unreachable SSH host
|
|
357
|
+
does not silence the whole page — the unreachable target gets its own banner and the other targets' results remain
|
|
358
|
+
available.
|
|
359
|
+
- **The "needs attention" criteria are the host's**: the container list and `/attention` are requested in parallel;
|
|
360
|
+
the latter does an extra `docker inspect`, so it can identify **OOM (OOMKilled)** and the **real exit code** — a
|
|
361
|
+
`ps` summary's `Exited (137)` cannot tell an OOM kill from a manual kill. When `/attention` is unavailable (an
|
|
362
|
+
older host / that target failed) it falls back to the summary criteria and labels the count
|
|
363
|
+
"needs attention (rough)".
|
|
364
|
+
- **Ordering**: OOM > zombie > unhealthy > repeatedly restarting > non-zero exit; equal weights are ordered by
|
|
365
|
+
"most recent end time" descending, so the freshest crash is at the top (hovering a row shows the end / start times,
|
|
366
|
+
restart count and exit code).
|
|
367
|
+
|
|
368
|
+
## Configuration (Settings → Plugins → "Docker Container Panel", saved and applied hot)
|
|
369
|
+
|
|
370
|
+

|
|
371
|
+
|
|
372
|
+
Configuration lives in the settings namespace `docker`, i.e. the `docker:` section of `~/.dsh/settings.yaml`
|
|
373
|
+
(`$DSH_HOME/settings.yaml`; DSH's settings file is provided by the host's `dsh-settings-file`). The composition
|
|
374
|
+
config in the plugin line acts as the schema's `base`, which the settings layer overrides; the HTTP
|
|
375
|
+
`POST /api/dsh-docker/config` is the card's write channel and accepts only the keys in the table below (unknown keys
|
|
376
|
+
return 400).
|
|
377
|
+
|
|
378
|
+
| Item | Default | Description |
|
|
379
|
+
| --- | --- | --- |
|
|
380
|
+
| `enabled` | true | disables the whole plugin (**takes effect after restarting `dsh web`**, same semantics as tty) |
|
|
381
|
+
| `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
|
|
382
|
+
| `dockerBin` | `docker` | the docker CLI executable name or path (`podman` works here); only letters, digits and `_ . / -` are allowed |
|
|
383
|
+
| `allowMutations` | false | allows **mutating operations**: container start / stop / restart / remove, image removal / dangling pruning / pulling (the panel buttons and the `docker_action`, `docker_image_remove`, `docker_image_prune`, `docker_image_pull` tools; while off, `/action`, `/images/remove`, `/images/prune`, `/images/pull/stream` return 403 and the corresponding tools are not registered) |
|
|
384
|
+
| `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
|
|
385
|
+
| `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
|
|
386
|
+
| `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
|
|
387
|
+
| `logTailDefault` | 200 | default log tail line count (1–5000) |
|
|
388
|
+
| `maxOutputKb` | 512 | output cap for a single command (KB, 1–8192); anything beyond is truncated and marked `truncated` |
|
|
389
|
+
| `targets` | `[]` | target list, see below |
|
|
390
|
+
| `hostKeys` | `[]` | SSH host fingerprint records (TOFU, maintained automatically) |
|
|
391
|
+
|
|
392
|
+
Out-of-range numbers are clamped to the boundary, and a value of the wrong type falls back to the default.
|
|
393
|
+
|
|
394
|
+
### `targets[]` fields
|
|
395
|
+
|
|
396
|
+
| Field | Default | Description |
|
|
397
|
+
| --- | --- | --- |
|
|
398
|
+
| `name` | — (required) | the target's display name, unique; empty or duplicate entries are dropped on save (≤64 characters) |
|
|
399
|
+
| `kind` | `local` | `local` for this machine / `ssh` for remote |
|
|
400
|
+
| `book` | `''` | with `kind=ssh`, references a tty connection-book entry name (leave empty to use the inline fields below) |
|
|
401
|
+
| `host` | `''` | inline hostname or IP (required when there is no `book`) |
|
|
402
|
+
| `port` | 22 | SSH port (1–65535, out of range falls back to 22) |
|
|
403
|
+
| `username` | `''` | inline SSH username (required when there is no `book`) |
|
|
404
|
+
| `auth` | `agent` | `agent` (uses `SSH_AUTH_SOCK`) / `key` (uses `keyPath`) / `password` (uses `password` and also attaches keyboard-interactive) |
|
|
405
|
+
| `keyPath` | `''` | private key path for `auth=key` (a leading `~` expands to home) |
|
|
406
|
+
| `password` | `''` | password for `auth=password`; **prefer `env:VAR`** to reference an environment variable |
|
|
407
|
+
| `passphrase` | `''` | private key passphrase; **prefer `env:VAR`** to reference an environment variable |
|
|
408
|
+
| `agentForward` | false | whether to forward the local ssh-agent (takes effect when `SSH_AUTH_SOCK` exists) |
|
|
409
|
+
|
|
410
|
+
An `env:VAR` inside `password` / `passphrase` is resolved only when connecting (`process.env[VAR]`), and a missing or
|
|
411
|
+
empty variable reports `环境变量未设置: VAR` ("environment variable not set: VAR") explicitly. These two values are **never sent back to the browser**:
|
|
412
|
+
the config snapshot only provides the two booleans `passwordSet` / `passphraseSet`.
|
|
413
|
+
|
|
414
|
+
### `hostKeys[]` (SSH host fingerprints, TOFU)
|
|
415
|
+
|
|
416
|
+
| Field | Description |
|
|
417
|
+
| --- | --- |
|
|
418
|
+
| `host` | hostname or IP (required) |
|
|
419
|
+
| `port` | port, default 22 (unique per host:port) |
|
|
420
|
+
| `fingerprint` | the verbatim hex fingerprint received by the `hostHash: 'sha256'` callback (required) |
|
|
421
|
+
|
|
422
|
+
The first connection is recorded automatically and written to disk; every later connection must match, and
|
|
423
|
+
**a changed fingerprint rejects the connection outright**, with guidance in the error to "delete this host's record
|
|
424
|
+
and reconnect". The record list can be deleted / reset in the settings card.
|
|
425
|
+
|
|
426
|
+
## Agent tools
|
|
427
|
+
|
|
428
|
+
| Tool | Registration | Parameters | Purpose / typical use |
|
|
429
|
+
| --- | --- | --- | --- |
|
|
430
|
+
| `docker_targets` | always registered | `probe?: boolean` | lists targets (name / kind / label); `probe:true` probes the docker version and daemon reachability for each one (SSH targets open connections, so it is slower). Other tools take their `target` from here |
|
|
431
|
+
| `docker_ps` | always registered | `target?` (**pass `*` = all targets**), `all?: boolean` | lists containers (name / state / health / image / ports / compose project and service / short ID); by default only running ones, `all:true` includes stopped. With `target:'*'` it returns results grouped by target, and **one unreachable target does not affect the others** (that group carries `error`). The first step of troubleshooting |
|
|
432
|
+
| `docker_attention` | always registered | `target?` (supports `*`), `limit?: number` | a **needs-attention summary**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / zombie; every item carries `reasons`, `exitCode`, `oomKilled`, `restartCount`. OOM and the real exit code come from one `docker inspect` (a ps summary cannot distinguish a manual kill from 137). The troubleshooting entry point: call it first when you are unsure which machine or container to look at |
|
|
433
|
+
| `docker_inspect` | always registered | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
|
|
434
|
+
| `docker_logs` | always registered | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?` | the tail from `docker logs --tail`; `since` uses docker syntax (such as `10m`, `2026-09-09T10:00:00`); over the cap it is marked `truncated` |
|
|
435
|
+
| `docker_stats` | always registered | `target?`, `ids?` (comma-separated container names/IDs) | a `docker stats --no-stream` snapshot: CPU% / memory usage and share / network IO / block IO / PIDs; omitting `ids` means all running containers. Live following is a panel capability (SSE), and the tool keeps single-value snapshot semantics |
|
|
436
|
+
| `docker_images` | always registered | `target?` | image list (repository:tag / size / created / short ID) |
|
|
437
|
+
| `docker_events` | always registered | `target?`, `since?` (docker `--since` syntax, default `10m`) | a container-event snapshot (`docker events --since <d> --until <now>`, likewise through the server-side allowlist): the nine kinds start / die / stop / kill / oom / health_status / destroy / rename / update, with noise such as `exec_*` already dropped server-side. For continuous observation have the user watch the "Activity" strip in the panel's container list |
|
|
438
|
+
| `docker_networks` | always registered | `target?` | network list (name / driver / scope / internal flag / short ID). The connected-container list stays out of the list rows — the details page does the inspect, whereas inspecting row by row would be N docker calls |
|
|
439
|
+
| `docker_volumes` | always registered | `target?` | volume list (name / driver / scope / mountpoint) |
|
|
440
|
+
| `docker_image_inspect` | always registered | `target?`, `ref` (required) | `docker image inspect` + `docker history`: size / size including parents / created / platform / layer count and layer list / entrypoint and command / exposed ports / digest / build history (each step's command and size) |
|
|
441
|
+
| `docker_action` | only with `allowMutations` | `target?`, `action` (`start` \| `stop` \| `restart` \| `remove`), `id` | container lifecycle operations. `remove` is destructive: it deletes the container config and writable layer (data volumes are not included), and the target container must be confirmed with the user before running |
|
|
442
|
+
| `docker_image_remove` | only with `allowMutations` | `target?`, `ref` (required) | removes an image (`docker image rm`, without `-f`). **Destructive**: it fails when the image is referenced by a container or a child image; confirm the target image and restate the consequences before running |
|
|
443
|
+
| `docker_image_prune` | only with `allowMutations` | `target?` | prunes dangling (untagged) images (`docker image prune -f`). Deliberately without `--all`, so only untagged images go |
|
|
444
|
+
| `docker_image_pull` | only with `allowMutations` | `target?`, `ref` (required), `timeoutSec?` (10–1800, default 600) | `docker pull` in snapshot form (**may take several minutes**); for interactive per-layer progress have the user watch the SSE progress stream from the pull icon on the panel's images page |
|
|
445
|
+
| `docker_exec` | only with `allowExec` | `target?`, `id`, `command` (required, run through `sh -c` inside the container), `timeoutSec?` (1–120, default `execTimeoutSec`) | a one-shot `docker exec` returning the exit code / stdout / stderr; there is no TTY, so for interactive troubleshooting have the user run `docker exec -it <container> sh` in the tty panel |
|
|
446
|
+
|
|
447
|
+
- When `target` is omitted it falls back to **the only** configured target; with several targets configured it is
|
|
448
|
+
required, and the error message lists the available target names.
|
|
449
|
+
- Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
|
|
450
|
+
the corresponding tools (including the three image mutation tools) disappear from the agent side, with no restart.
|
|
451
|
+
- Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs` →
|
|
452
|
+
`docker_inspect` → `docker_stats`; for image problems `docker_images` →
|
|
453
|
+
`docker_image_inspect`.
|
|
454
|
+
- With `announceToAgent` on, the plugin injects a capability announcement into the systemPrompt (including the
|
|
455
|
+
constraints "read-only by default" and "docker socket ≈ root on the target host"), so the model lists targets
|
|
456
|
+
before acting.
|
|
457
|
+
|
|
458
|
+
## HTTP routes (under the `/api/dsh-docker` prefix, all behind the loopback fence)
|
|
459
|
+
|
|
460
|
+
The fence validates `remoteAddress` (127.0.0.1 / ::1 / ::ffff:127.0.0.1), `Host`,
|
|
461
|
+
`Origin` and `sec-fetch-site`; any non-local request gets 403 `forbidden: loopback-only`.
|
|
462
|
+
The request body is capped at 1MB, and responses are uniformly `application/json` + `referrer-policy: no-referrer`.
|
|
463
|
+
|
|
464
|
+
| Route | Method | Request body | Response |
|
|
465
|
+
| --- | --- | --- | --- |
|
|
466
|
+
| `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered`) |
|
|
467
|
+
| `/config` | POST | any subset of the config keys above | `{ok:true, config}`; an unknown key is 400 and invalid JSON is 400 |
|
|
468
|
+
| `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
|
|
469
|
+
| `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
|
|
470
|
+
| `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`; with `target:'*'` it returns `{ok:true, groups:[{target,label,ok,error?,data?}]}` (concurrent cross-target aggregation) |
|
|
471
|
+
| `/attention` | POST | `{target?}` | for a single target `{ok:true, items: AttentionItem[]}`; with `target:'*'` `{ok:true, groups}` |
|
|
472
|
+
| `/inspect` | POST | `{target?, id}` | `{ok:true, details: ContainerDetail[]}` |
|
|
473
|
+
| `/stats` | POST | `{target?, ids?: string[]}` | `{ok:true, stats: ContainerStats[]}` |
|
|
474
|
+
| `/logs` | POST | `{target?, id, tail?, timestamps?, since?}` | `{ok:true, logs:{id, text, truncated}}` |
|
|
475
|
+
| `/logs/stream` | GET | query: `target?`, `id` (required), `tail?` (1–5000), `timestamps?` (`1`/`true`), `since?` | `200 text/event-stream` long connection, event protocol below; bad parameters / unknown target / non-loopback return ordinary JSON errors |
|
|
476
|
+
| `/stats/stream` | GET | query: `target?`, `ids?` (comma-separated; omitted = all running) | `200 text/event-stream`: one `stats` frame per second (ContainerStats, same shape as the /stats snapshot); it does not end naturally and is finished off by the client disconnecting |
|
|
477
|
+
| `/events/stream` | GET | query: `target?` | `200 text/event-stream`: one container event per `event` frame (already through the server-side allowlist, with missing-value fields omitted); it does not end naturally and is finished off by the client disconnecting |
|
|
478
|
+
| `/images` | POST | `{target?}` | `{ok:true, images: ImageSummary[]}` |
|
|
479
|
+
| `/images/inspect` | POST | `{target?, ref}` (required) | `{ok:true, image:{ref, detail: ImageDetail, history: ImageHistoryEntry[], historyError}}`; history prefers `--format '{{json .}}'` and falls back to a plain-text table on older versions |
|
|
480
|
+
| `/images/remove` | POST | `{target?, ref}` (required) | requires `allowMutations` (otherwise 403); `docker image rm` (without `-f`); `{ok:true, result:{ref, message}}` |
|
|
481
|
+
| `/images/prune` | POST | `{target?}` | requires `allowMutations` (otherwise 403); `docker image prune -f` (dangling only); `{ok:true, result:{message}}` |
|
|
482
|
+
| `/images/pull/stream` | GET | query: `target?`, `ref` (required) | requires `allowMutations` (otherwise 403 and no stream is opened); `200 text/event-stream`: `line` per-layer progress + `end{reason:'pull-exit',code,ref}` |
|
|
483
|
+
| `/networks` | POST | `{target?}` | `{ok:true, networks: NetworkSummary[]}` |
|
|
484
|
+
| `/networks/inspect` | POST | `{target?, name}` (required) | `{ok:true, network:{name, detail: NetworkDetail}}` (subnet / gateway / options / labels / connected containers) |
|
|
485
|
+
| `/networks/remove` | POST | `{target?, name}` (required) | requires `allowMutations` (otherwise 403); `docker network rm`; `{ok:true, result:{name, message}}` |
|
|
486
|
+
| `/networks/prune` | POST | `{target?}` | requires `allowMutations` (otherwise 403); `docker network prune -f`; `{ok:true, result:{message}}` |
|
|
487
|
+
| `/volumes` | POST | `{target?}` | `{ok:true, volumes: VolumeSummary[]}` |
|
|
488
|
+
| `/volumes/inspect` | POST | `{target?, name}` (required) | `{ok:true, volume:{name, detail: VolumeDetail}}` (mountpoint / options / labels) |
|
|
489
|
+
| `/volumes/remove` | POST | `{target?, name}` (required) | requires `allowMutations` (otherwise 403); `docker volume rm` (**the data goes with the volume**); `{ok:true, result:{name, message}}` |
|
|
490
|
+
| `/volumes/prune` | POST | `{target?}` | requires `allowMutations` (otherwise 403); `docker volume prune -f` (**deletes data**, see known limitations); `{ok:true, result:{message}}` |
|
|
491
|
+
| `/action` | POST | `{target?, action, id}` | requires `allowMutations` (otherwise 403); `{ok:true, result:{id, action, message}}` |
|
|
492
|
+
| `/exec` | POST | `{target?, id, command, timeoutSec?}` | requires `allowExec` (otherwise 403); `{ok:true, result:{id, command, code, stdout, stderr, truncated, durationMs}}` |
|
|
493
|
+
|
|
494
|
+
Any other sub-path is 404 (`unknown route: ...`); a GET on anything other than `/config`, `/targets` and the three
|
|
495
|
+
`*/stream` routes above returns 405; an execution failure (a docker error, a target that fails to resolve, and so on)
|
|
496
|
+
returns 500 or 400 plus an `{error}` text.
|
|
497
|
+
|
|
498
|
+
### SSE event protocol (`/logs/stream`, `/stats/stream`, `/events/stream`, `/images/pull/stream`)
|
|
499
|
+
|
|
500
|
+
The four long streams share one piece of infrastructure (`openSseStream`): uniform header writing +
|
|
501
|
+
`flushHeaders()`, a 15s `: ping` heartbeat frame, active-stream registration (a uniform `end` + abort when the plugin
|
|
502
|
+
is disabled / the config is hot-updated / it is uninstalled), and a silent abort when the client disconnects. They
|
|
503
|
+
differ only in the executor and the end reason:
|
|
504
|
+
|
|
505
|
+
Each frame is one `event:` line + one JSON `data:` line (single-line JSON encapsulation: newlines / quotes are
|
|
506
|
+
escaped and multi-byte characters are never split by an SSE line boundary), followed by an empty line:
|
|
507
|
+
|
|
508
|
+
| Event | data | Description |
|
|
509
|
+
| --- | --- | --- |
|
|
510
|
+
| `line` | `{"d":"..."}` / `{"e":"..."}` | stdout / stderr chunks (not guaranteed to split on line boundaries, so the client reassembles lines). Used by the log stream and the pull stream |
|
|
511
|
+
| `stats` | `ContainerStats` | stats stream only: one frame per container per second, with fields exactly matching the `/stats` snapshot. The server extracts **flat `{...}` objects** (`docker stats` goes through the TTY renderer even when stdout is a pipe, so frames are mixed with `ESC[H/ESC[K/ESC[J`, and parsing by line would drop whole lines) and collapses repeated renders of the same sample, so the client no longer has to parse docker's PascalCase strings |
|
|
512
|
+
| `event` | `{action, name, image, composeProject?, time?, exitCode?}` | event stream only: one container event per frame (event lines outside the allowlist are dropped server-side; fields whose value is null are omitted) |
|
|
513
|
+
| `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | the executor exited naturally. The log stream attaches the container's exit code, the stats stream has reason=`stats-exit`, the event stream reason=`events-exit`, and the pull stream attaches `ref` |
|
|
514
|
+
| `error` | `{"message":"..."}` | a parameter / execution failure, after which the stream closes; a break at the connection layer does not send this event |
|
|
515
|
+
|
|
516
|
+
The four streams differ only in "executor + end reason":
|
|
517
|
+
|
|
518
|
+
| Stream | Executor | Natural end condition | Close semantics |
|
|
519
|
+
| --- | --- | --- | --- |
|
|
520
|
+
| `/logs/stream` | `docker logs --follow` | the container stops (`container-exit`) | the frontend turns FOLLOW off / switches pages / closes the panel |
|
|
521
|
+
| `/stats/stream` | `docker stats` (**without** `--no-stream`) | all the containers being measured exit (`stats-exit`) | **the frontend aborts it** (this stream does not stop by itself) |
|
|
522
|
+
| `/events/stream` | `docker events` (`--filter type=container`) | the daemon-side stream ends (`events-exit`) | switching pages / switching targets / closing the panel |
|
|
523
|
+
| `/images/pull/stream` | `docker pull` | the pull completes / fails (`pull-exit`) | the frontend leaves the pull view |
|
|
524
|
+
|
|
525
|
+
- Response headers: `content-type: text/event-stream; charset=utf-8`, `cache-control:
|
|
526
|
+
no-cache`, `connection: keep-alive`, with `flushHeaders()` immediately after writing them (the host's gzip
|
|
527
|
+
explicitly skips `text/event-stream`, so nothing is buffered).
|
|
528
|
+
- Heartbeat: one `: ping` comment frame every 15s (ignored by clients per the SSE spec).
|
|
529
|
+
- Teardown: the client disconnects → the executor is aborted immediately (locally `SIGTERM`, then `SIGKILL` if it has
|
|
530
|
+
not exited in 2s; over SSH that exec channel is closed and the pooled connection is kept for reuse), writing no
|
|
531
|
+
frame at all; the plugin is disabled / the config is hot-updated / it is uninstalled → the server wraps up on its
|
|
532
|
+
own initiative (abort + `end`).
|
|
533
|
+
- A long SSH stream occupies a connection in the pool (a busy count) and idle reclamation (120s) does not kill it by
|
|
534
|
+
mistake; reclamation resumes once the stream ends.
|
|
535
|
+
|
|
536
|
+
## Security model
|
|
537
|
+
|
|
538
|
+
**The docker socket ≈ root on the target host.** Anyone who can reach the daemon can mount host directories, start
|
|
539
|
+
containers in privileged mode and read the secrets inside containers — which is why this plugin is designed
|
|
540
|
+
read-only first:
|
|
541
|
+
|
|
542
|
+
1. **Read-only by default**. With `allowMutations` off, `/action`, `/images/remove`,
|
|
543
|
+
`/images/prune`, `/images/pull/stream` all return 403, the panel's start / stop / remove / image
|
|
544
|
+
removal / pruning / pulling are unavailable, and the `docker_action`, `docker_image_remove`,
|
|
545
|
+
`docker_image_prune`, `docker_image_pull` tools are **not registered at all**; with `allowExec`
|
|
546
|
+
off, `/exec` returns 403 and the `docker_exec` tool is likewise not registered. The two switches are
|
|
547
|
+
independent and must be turned on explicitly by the user in the settings card. Read-type routes (`/logs/stream`,
|
|
548
|
+
`/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by either switch.
|
|
549
|
+
2. **Destructive operations restate their consequences**. `remove` maps to `docker rm` (**without `-f`**), and the
|
|
550
|
+
agent announcement requires confirming the target container with the user before running; a running container
|
|
551
|
+
errors with a hint that "the container is still running: stop it before removing", never a silent force-delete.
|
|
552
|
+
3. **Credentials do not land in plaintext (recommended)**. `password` / `passphrase` support `env:VAR` references,
|
|
553
|
+
and hosting the secrets with dsh-env-manager avoids plaintext in `settings.yaml`; `agent`
|
|
554
|
+
auth (`SSH_AUTH_SOCK`) is not written to disk at all. The config snapshot only answers "is it set".
|
|
555
|
+
4. **Host fingerprint TOFU pinning**. The first connection records the sha256 fingerprint; every later one must
|
|
556
|
+
match, and a change rejects the connection (MITM protection); a host tty has already confirmed is trusted directly
|
|
557
|
+
as a seed and copied into this plugin's records. TOFU's inherent limits are that "if the first connection already
|
|
558
|
+
met an MITM, what got recorded is a fake fingerprint", and that only one record is kept per host:port (multiple
|
|
559
|
+
key types on the same host may report a change incorrectly; deleting the record and reconnecting re-calibrates
|
|
560
|
+
it).
|
|
561
|
+
5. **Commands are always built as argv, never by string concatenation**. Container names / IDs first pass the
|
|
562
|
+
`assertRef` allowlist (`[A-Za-z0-9][A-Za-z0-9_.-]*`, ≤128 characters, rejecting spaces, `;`,
|
|
563
|
+
`$()`, backticks and so on), and image and container references likewise; remotely each argument is single-quote
|
|
564
|
+
escaped by `shJoin` before being handed to the remote shell, while locally `spawn(bin, args)` goes through no
|
|
565
|
+
shell.
|
|
566
|
+
6. **Output is capped**. `maxOutputKb` limits the stdout/stderr bytes of a single command; anything beyond is
|
|
567
|
+
truncated and marked, so large logs cannot blow up memory or the agent's context.
|
|
568
|
+
7. **HTTP is exposed to this machine only**. All routes go through the loopback fence, so a remote browser cannot
|
|
569
|
+
call them.
|
|
570
|
+
8. **The live log stream is a read-only capability**. `GET /logs/stream` matches `/logs`: it is not gated by
|
|
571
|
+
`allowMutations` / `allowExec` (it does not change container state), but it likewise admits loopback only, passes
|
|
572
|
+
`id` through the `assertRef` allowlist and clamps parameters the same way. Unlike a snapshot, a long stream has no
|
|
573
|
+
`maxOutputKb` cap (following would lose its point if truncated), and memory protection is shouldered by the
|
|
574
|
+
client's 5000-line ring buffer and 2000-line colouring cap.
|
|
575
|
+
|
|
576
|
+
## Known limitations
|
|
577
|
+
|
|
578
|
+
- **No interactive TTY**: `exec` is a one-shot command (`docker exec <id> sh -c <cmd>`,
|
|
579
|
+
without `-i` / `-t`), so it cannot run vim / top / an interactive shell, nor feed stdin for a
|
|
580
|
+
dialogue. For interactive troubleshooting run `docker exec -it <container> sh` in the tty panel (this works for
|
|
581
|
+
both local and SSH targets).
|
|
582
|
+
- **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events that
|
|
583
|
+
happen while the browser is disconnected and reconnecting have already been pushed by the server and are not
|
|
584
|
+
resent. The client compensates with "do a full list refresh right after a successful reconnect"
|
|
585
|
+
(aligning state, not replaying the events); the few entries missing from the Activity strip can only be inferred
|
|
586
|
+
from the final state after that refresh. For an exact, complete event history use the `docker_events` tool (a
|
|
587
|
+
snapshot with `--since`).
|
|
588
|
+
- **The four boundaries of streaming**: the log page's `FOLLOW` (SSE + `docker logs -f`), the stats page's
|
|
589
|
+
`FOLLOW` (SSE + `docker stats` + a 60-point sparkline), the container list's event stream
|
|
590
|
+
(SSE + `docker events`, driving the Activity strip and the debounced list refresh), and the images page's pull
|
|
591
|
+
progress stream (SSE + `docker pull`); but the agent tools `docker_logs` / `docker_stats` /
|
|
592
|
+
`docker_image_pull` all keep **snapshot semantics** (a single-value return model does not suit an unbounded
|
|
593
|
+
stream). Streaming logs keep only the last 5000 lines on the browser side (dropping the oldest, with a hint), and
|
|
594
|
+
stats keep only 60 samples.
|
|
595
|
+
A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
|
|
596
|
+
same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
|
|
597
|
+
the frontend actively aborting the `EventSource`.
|
|
598
|
+
- **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and fields come and go between
|
|
599
|
+
versions; the parser always degrades instead of throwing (for example, a missing `State` has the state derived from
|
|
600
|
+
`Status`, and health is extracted from `(healthy)` / `(unhealthy)`); with fields missing the corresponding columns
|
|
601
|
+
may be empty, so use the details (`docker inspect`) when you need authoritative data.
|
|
602
|
+
- **`rm` without `-f`**: container `remove` maps to `docker rm` and image `remove` to
|
|
603
|
+
`docker image rm`, neither with `-f` — a running container, or an image referenced by a container or a child
|
|
604
|
+
image, fails with a hint; to force deletion, run it by hand in the tty panel.
|
|
605
|
+
- **SSH targets need sudo-free docker**: if the account is not in the docker group, docker reports a permission
|
|
606
|
+
error which the panel and the tools pass through verbatim, without attempting automatic sudo escalation.
|
|
607
|
+
- **Docker not installed on the remote**: `probe` fails (`command not found` / exit code 127) and the panel shows
|
|
608
|
+
the error; when PATH differs, fill `dockerBin` with an absolute path.
|
|
609
|
+
- **Podman compatibility through `dockerBin`**: filling in `podman` runs, but the fields and output formats of
|
|
610
|
+
`stats` and `--format '{{json .}}'` differ from docker's, so only the parser's degradation paths are relied on;
|
|
611
|
+
this has not been verified item by item.
|
|
612
|
+
- **No image builds / Compose orchestration changes**: images support pulling / removal / dangling pruning, but there
|
|
613
|
+
is no `docker build`, `docker save` / `load` or `docker push`; Compose is a **read-only**
|
|
614
|
+
project view (grouping by project + project-level merged logs) and provides no `compose up` / `down` / `restart`.
|
|
615
|
+
- **`volume prune` deletes data and its behaviour varies by version**: `docker volume prune` on docker ≥ 23
|
|
616
|
+
has `-a/--all`, and **without it only anonymous volumes go** (which is how this plugin calls it); but docker < 23
|
|
617
|
+
has no such switch, and a plain prune deletes **named** volumes that are not in use as well. The confirmation copy
|
|
618
|
+
for volume pruning therefore spells out the version difference; before running it, please confirm that there is no
|
|
619
|
+
data volume you want to keep.
|
|
620
|
+
- **Network / volume mutations exist only as panel buttons, with no agent tools**: image remove / prune have
|
|
621
|
+
corresponding tools, but for this round networks / volumes only gained HTTP endpoints (`/networks/remove` and the
|
|
622
|
+
like) and panel buttons — deliberately without widening the mutation surface on the agent side. Letting the agent
|
|
623
|
+
delete them too would require adding separate tools (with their own confirmation conventions).
|
|
624
|
+
- **The Overview only summarises "the container picture" and is entirely read-only**: the `Overview` next to the
|
|
625
|
+
target picker only does counter cards and the attention table pinned to the top, with **no cross-target operations
|
|
626
|
+
whatsoever** (start / stop / remove are still done one by one in a single target's list), and it does not merge
|
|
627
|
+
logs / stats / event streams either (those remain the business of single-target, single-container pages). In
|
|
628
|
+
addition, "attention" covers only `unhealthy` and
|
|
629
|
+
`restarting`: `ContainerSummary` has no `exitCode`, so a "crashed exit" cannot be told from a "manually
|
|
630
|
+
stopped" one, and counting every `exited` as an exception would let a container that was stopped once flood the
|
|
631
|
+
screen forever — look at dead containers under "stopped" in the counter card instead.
|
|
632
|
+
- **The connection-bar button needs a matching target to have data**: the button always shows on an SSH tab, but if
|
|
633
|
+
the session host has no corresponding `kind=ssh` target (neither the connection-book name nor `host:port` matches),
|
|
634
|
+
clicking it only shows a hint that it is "not configured as a Docker target" instead of a container list; the
|
|
635
|
+
connection bar on local tabs is hidden entirely.
|
|
636
|
+
Target additions and removals are picked up within at most 30 seconds (saving the settings card refreshes
|
|
637
|
+
immediately).
|
|
638
|
+
- **`enabled: false` needs a restart**: disabling the plugin does not unload the registered routes and tools (long
|
|
639
|
+
streams in progress — logs / stats / pulling — are wrapped up immediately, but the routes themselves remain), and
|
|
640
|
+
only restarting `dsh web` disables it completely.
|
|
641
|
+
- **Mutating operations have no separate audit log**: only docker's own records and the host `ctx.logger`'s
|
|
642
|
+
ordinary output.
|
|
643
|
+
|
|
644
|
+
- **The boundaries of cross-target aggregation**: concurrency cap 4, per-target timeout 45s; a single target failing
|
|
645
|
+
or timing out affects only its own cell (the group carries `error`). With many targets the Overview's request
|
|
646
|
+
volume grows linearly with the target count (2 requests per target), and auto refresh multiplies that volume —
|
|
647
|
+
with many targets it is best to turn auto refresh off.
|
|
648
|
+
|
|
649
|
+
## How it works
|
|
650
|
+
|
|
651
|
+
```
|
|
652
|
+
Browser half (client.js)
|
|
653
|
+
├─ sidebar "Containers" entry → panel: target picker / container list (search + state filter) /
|
|
654
|
+
│ container cards (action bar in two groups: view = terminal/logs/stats | mutate = start-stop/restart/remove) /
|
|
655
|
+
│ container list "Activity" strip (event-driven refresh) + "Select for merging" multi-select → temporary merged logs /
|
|
656
|
+
│ multi-target Overview (all targets fetched in parallel + progressive landing; counter cards / attention table pinned to the top; read-only) /
|
|
657
|
+
│ Compose project view (project grouping / service table / project-level merged logs) /
|
|
658
|
+
│ image list (inline details · remove) + image details (layers / build history) + pull progress /
|
|
659
|
+
│ network list + network details (subnet / connected containers) + volume list + volume details / one-shot exec
|
|
660
|
+
│ └─ terminal drawer: with tty ≥ 0.15, embeds tty's terminal in place via ttyTerminal.mount
|
|
661
|
+
│ (the panel does not collapse; collapsing/dragging only changes size and does not interrupt the session; only ✕ or closing the panel disposes,
|
|
662
|
+
│ and with an active session closing the panel confirms first → tty ends the session and tears down the DOM)
|
|
663
|
+
│ ├─ fetch → /api/dsh-docker/* (loopback fence)
|
|
664
|
+
│ ├─ FOLLOW → EventSource /logs/stream (SSE: 5000-line ring buffer / auto stick to bottom /
|
|
665
|
+
│ │ back to bottom / auto reconnect on disconnect / back to snapshot when the container exits)
|
|
666
|
+
│ ├─ stats FOLLOW → EventSource /stats/stream (SSE: a 60-point ring buffer draws
|
|
667
|
+
│ │ CPU / memory sparklines; **the frontend aborts it**, and the stream only ends when docker stats exits by itself)
|
|
668
|
+
│ ├─ events → EventSource /events/stream (SSE: the Activity strip's 50-entry ring buffer +
|
|
669
|
+
│ │ a 500ms debounce triggering a list refetch; one full refresh after a successful reconnect)
|
|
670
|
+
│ ├─ pull → EventSource /images/pull/stream (SSE: per-layer progress upserted by layer key)
|
|
671
|
+
│ └─ merged logs → one /logs/stream per container in the project, mixed client-side by [service]
|
|
672
|
+
├─ optionally consumes tty's ttyConnbar service → SSH connection bar "Containers" button
|
|
673
|
+
│ (matches configured targets by book name / host:port; only appears on a hit)
|
|
674
|
+
├─ optionally consumes tty's ttyPanel service (0.16.0) → docks to the right while the terminal panel is open
|
|
675
|
+
└─ optionally consumes tty's ttyTerminal service → the card's "Terminal" button opens a docker exec tab directly
|
|
676
|
+
(falls back to copying the command when tty is unavailable / credentials are not in the browser)
|
|
677
|
+
|
|
678
|
+
Host half (src/index.ts)
|
|
679
|
+
├─ settings namespace docker (~/.dsh/settings.yaml)
|
|
680
|
+
│ DOCKER_SETTINGS_SCHEMA → normalizeConfig (clamping / allowlisted keys)
|
|
681
|
+
├─ read-only reuse of tty settings' sshHosts (connection book) and hostKeys (fingerprint seed)
|
|
682
|
+
├─ resolveTarget: local → runLocal; ssh → look up the connection book via book or use inline fields
|
|
683
|
+
├─ one DockerApi per target (src/docker.ts)
|
|
684
|
+
│ ├─ argv construction + assertRef allowlist + output cap (maxOutputKb)
|
|
685
|
+
│ └─ tolerant parsing: {{json .}} line by line / as an array, case-insensitive field names, degradation on missing fields
|
|
686
|
+
├─ RemoteExec (src/ssh-exec.ts)
|
|
687
|
+
│ ├─ lazy connection pool: one connection reused per user@host:port, reclaimed after 120s idle
|
|
688
|
+
│ │ (swept every 30s, connect timeout 20s, keepalive 10s; long streams with busy>0 skip reclamation)
|
|
689
|
+
│ ├─ non-PTY exec channel: one channel per command, closed as soon as stdout/stderr are drained;
|
|
690
|
+
│ │ long streams (run()/stream()) have no overall timeout or output cap and are stopped via AbortSignal
|
|
691
|
+
│ ├─ shJoin single-quote escaping (parsed by the remote shell); env:VAR secret lookup
|
|
692
|
+
│ └─ hostVerifier TOFU pinning (record on first use, reject on change)
|
|
693
|
+
├─ runLocal / runLocalStream: spawn(dockerBin, args) (no shell, local targets)
|
|
694
|
+
│ stop ladder: SIGTERM → SIGKILL if it has not exited in 2s
|
|
695
|
+
├─ generic SSE long connection openSseStream (src/index.ts, one piece of infrastructure for all four streams)
|
|
696
|
+
│ ├─ loopback fence + assertRef / assertImageRef + tail clamping (same as the snapshot routes)
|
|
697
|
+
│ ├─ header write + flushHeaders / 15s ping heartbeat / active-stream registry / silent abort when the frontend disconnects
|
|
698
|
+
│ ├─ /logs/stream: docker logs -f → line{"d"|"e"} + end{container-exit,code}
|
|
699
|
+
│ ├─ /stats/stream (read-only): docker stats normalised line by line → stats{ContainerStats};
|
|
700
|
+
│ │ it does not end naturally, so a frontend disconnect aborts it and docker stats exiting on its own sends end{stats-exit}
|
|
701
|
+
│ ├─ /events/stream (read-only): docker events --filter type=container →
|
|
702
|
+
│ │ event{action,name,image,...} (allowlist filtered); the daemon side ending sends end{events-exit}
|
|
703
|
+
│ ├─ /images/pull/stream (allowMutations): docker pull → line{d|e} +
|
|
704
|
+
│ │ end{pull-exit,code,ref}; 403 and no stream when mutations are off
|
|
705
|
+
│ └─ plugin disabled / config hot-updated / uninstalled → all four streams get a uniform end + abort
|
|
706
|
+
├─ image routes: /images/inspect (read-only) · /images/remove · /images/prune (allowMutations)
|
|
707
|
+
├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
|
|
708
|
+
└─ agent tools: docker_targets / docker_ps / docker_inspect /
|
|
709
|
+
docker_logs (snapshot semantics unchanged) / docker_stats / docker_events / docker_images /
|
|
710
|
+
docker_image_inspect / docker_networks / docker_volumes (always registered)
|
|
711
|
+
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
712
|
+
(allowMutations) / docker_exec (allowExec)
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
## Development and acceptance
|
|
716
|
+
|
|
717
|
+
```bash
|
|
718
|
+
pnpm --filter @hyzyn/dsh-docker build # tsc → lib/ (host half) + esbuild → client.js (browser half)
|
|
719
|
+
pnpm --filter @hyzyn/dsh-docker typecheck
|
|
720
|
+
pnpm --filter @hyzyn/dsh-docker smoke # three offline regression suites, none needing a docker daemon
|
|
721
|
+
pnpm test # repo-level vitest (including this package's logs-stream / streams suites)
|
|
722
|
+
```
|
|
723
|
+
|
|
724
|
+
`scripts/smoke.mjs` (33 items, reading the `lib/` build output) covers pure logic: ps parsing (field mapping /
|
|
725
|
+
compose labels / ports / deriving a missing `State` / noise lines / JSON arrays), port-string parsing and
|
|
726
|
+
deduplication, stats parsing (percentages / memory / IO / PIDs), abnormal input for size and percent, images parsing
|
|
727
|
+
(dangling), **image inspect / history parsing (both the JSON and the plain-text-table path)**,
|
|
728
|
+
inspect parsing (state / health / exit code / mounts / networks / ports / not throwing on missing fields),
|
|
729
|
+
injection rejection for `assertRef` / **`assertImageRef` (admitting registry/digest, rejecting flags and
|
|
730
|
+
injection)**, `assertBin`, `formatBytes`, `shJoin` escaping, `DockerApi`'s argv construction
|
|
731
|
+
(ps / logs / action / exec / probe success and failure / **image inspect·rm·prune·pull·statsStream·pullStream**),
|
|
732
|
+
`normalizeConfig` defaults and clamping, `sanitizeTargets` / `sanitizeHostKeys`,
|
|
733
|
+
`resolveTarget`'s four paths, and `mergeTargetSecrets`' credential-preserving semantics.
|
|
734
|
+
|
|
735
|
+
`scripts/route-smoke.mjs` (54 items) runs end to end with **a fake cordis ctx + a fake docker CLI script**: plugin
|
|
736
|
+
mounting (settings / tools / routes / capability-announcement registration), the actual calls and returns of
|
|
737
|
+
**26 routes** (including the event sequences and parameter validation of the four SSE streams `/logs/stream`,
|
|
738
|
+
`/stats/stream`, `/events/stream`, `/images/pull/stream`, with the event stream additionally asserting that noise is
|
|
739
|
+
dropped by the allowlist), the `docker_events` tool's snapshot output and its `since` character-set validation,
|
|
740
|
+
`/images/inspect`'s details + build history, `/config` credential masking, 400 for unknown config keys, 403 for
|
|
741
|
+
`/action`, `/exec` and image mutations (remove / prune / the pull stream) while read-only together with the
|
|
742
|
+
corresponding tools not being registered, immediate unlocking after the switches are turned on (including the
|
|
743
|
+
`settings/updated` hot-update path), 403 for non-loopback (including the three stream routes), container names /
|
|
744
|
+
image references rejected by the allowlist when injection is attempted, the fallback when `target` is omitted and the
|
|
745
|
+
error with several targets, and 403 on the stream routes after being disabled.
|
|
746
|
+
|
|
747
|
+
`scripts/client-smoke.mjs` (27 items) executes the build artifact
|
|
748
|
+
`client.js` in Node with minimal DOM / React stubs: verifying the registration id and factory shape, that it only
|
|
749
|
+
requires modules provided by the platform seed
|
|
750
|
+
(`react` / `react/jsx-runtime` / `react-dom/client`), that the settings card key registered by `apply` equals the
|
|
751
|
+
namespace `docker`, that it degrades quietly when the host sidebar cannot be found and that unmount can be called
|
|
752
|
+
repeatedly, the four paths of ttyConnbar integration (connection-book name hit / host:port hit / no button for an
|
|
753
|
+
unconfigured host / silent skip when tty is not installed), FOLLOW's SSE subscription and the "back to bottom"
|
|
754
|
+
interaction (static assertions), the **image details / pull stream / remove / prune entry points**,
|
|
755
|
+
**stats FOLLOW + sparkline hooks**, **Compose grouping and merged logs**, **the "Activity" strip wiring + the event
|
|
756
|
+
ring buffer / action labels / debounce** (pure logic through the `__events` test seam), and the style rule that hides
|
|
757
|
+
the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
|
|
758
|
+
Verification that needs a real daemon follows the manual checklist below.
|
|
759
|
+
|
|
760
|
+
`test/logs-stream.test.ts` (27 cases, run by the root `pnpm test`) covers four layers of the live log stream:
|
|
761
|
+
`logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
|
|
762
|
+
(newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
|
|
763
|
+
ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
|
|
764
|
+
layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
|
|
765
|
+
disabled.
|
|
766
|
+
|
|
767
|
+
`test/streams.test.ts` (33 cases) covers the **stats stream / event stream / pull stream / networks and volumes /
|
|
768
|
+
generic SSE infrastructure**: `statsStream` without `--no-stream` (the same construction point as the snapshot),
|
|
769
|
+
`pullStream`'s `assertImageRef` allowlist, `/stats/stream` normalising line-by-line JSON (including half lines
|
|
770
|
+
across chunks) into `stats` events with the same shape as the `/stats` snapshot, heartbeats, silent abort when the
|
|
771
|
+
client disconnects, `eventsStream`'s argv (no `--since` / `--until`, carrying the `type=container` filter) and the
|
|
772
|
+
`events()` snapshot's `--since` + `--until` (without until it would never exit),
|
|
773
|
+
`parseContainerEvent`'s allowlist / bad-line dropping / health_status suffix / field extraction / compatibility with
|
|
774
|
+
the old `status` field, `/events/stream`'s event sequence and half lines across chunks,
|
|
775
|
+
`/images/pull/stream`'s allowMutations gate (403 and no stream) / `pull-exit` wrap-up / uniform wrap-up when
|
|
776
|
+
disabled, **`assertName`'s validation matrix (`/` and `:` must be rejected — and those are exactly what
|
|
777
|
+
`assertImageRef` admits), the ls·inspect·rm·prune argv for networks / volumes (prune must carry `-f`), tolerant
|
|
778
|
+
ls·inspect parsing for network / volume (string booleans, older versions without `Mountpoint`, empty output / bad
|
|
779
|
+
lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 for remove/prune with the switches off,
|
|
780
|
+
400 for a missing name, 500 for an illegal name)**, plus `formatBytes`.
|
|
781
|
+
|
|
782
|
+
### Manual acceptance checklist
|
|
783
|
+
|
|
784
|
+
1. **Local target**: add a `kind=local` target named `local`, and `probe` returns the server version;
|
|
785
|
+
the container list matches `docker ps -a` (including stopped containers).
|
|
786
|
+
2. **SSH target**: with an entry already in the tty connection book, reference it via `book` → the container list /
|
|
787
|
+
details / logs work; the first connection logs "host key fingerprint recorded (TOFU)" and the second does not
|
|
788
|
+
prompt again; after manually changing the fingerprint in `hostKeys` and reconnecting, the connection should
|
|
789
|
+
**be rejected** with reset guidance.
|
|
790
|
+
3. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
|
|
791
|
+
`/images/prune`, `/images/pull/stream` all return 403; on the agent side there are only the 7 read-only tools, and
|
|
792
|
+
the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
|
|
793
|
+
"allow mutations" these routes and tools appear immediately (no restart needed).
|
|
794
|
+
4. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
|
|
795
|
+
CPU, memory, network and block IO; the image list carries a marker on dangling entries.
|
|
796
|
+
5. **FOLLOW live log stream**: turning on `FOLLOW` on the log page → the status line first says
|
|
797
|
+
"connecting" and then "following live", and new lines from `docker logs -f` appear immediately (`docker run --rm alpine sh
|
|
798
|
+
-c 'i=0; while :; do echo line-$i; i=$((i+1)); sleep 1; done'` makes this observable);
|
|
799
|
+
with FOLLOW on, `AUTO REFRESH` is greyed out and polling stops; scrolling up brings up "back to bottom",
|
|
800
|
+
and clicking it returns to the bottom and resumes auto stick-to-bottom; turning FOLLOW off immediately returns to
|
|
801
|
+
snapshots. Stop the container → the stream receives
|
|
802
|
+
`end`, hints "the container has exited (exit code N)" and automatically does one more snapshot. Kill `dsh web` and
|
|
803
|
+
start it again (or hot-change the plugin config) → the status line briefly says "reconnecting" and then heals
|
|
804
|
+
itself, with no error banner. Run the same with an SSH target and confirm that panel operations such as
|
|
805
|
+
`docker ps` on the same host are unaffected while the stream runs (connection reuse), and that idle reclamation
|
|
806
|
+
(120s) does not cut the stream.
|
|
807
|
+
6. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
|
|
808
|
+
live (docker stats)", and the CPU / memory sparklines grow a little every second (run `docker run --rm
|
|
809
|
+
alpine sh -c 'while :; do :; done'` to watch CPU rise); turning FOLLOW off immediately returns to snapshots and
|
|
810
|
+
resumes polling. Stop the containers being measured → the stream receives `end` (stats-exit), hints, and then
|
|
811
|
+
returns to polling automatically.
|
|
812
|
+
Note that **this stream does not end naturally**: switching pages / closing the panel must close the `EventSource`
|
|
813
|
+
(on the host side `docker stats` should be seen being SIGTERM'd).
|
|
814
|
+
7. **Image details / removal / pruning**: click "details" on a row of the images page → the layer count in the
|
|
815
|
+
overview matches `docker image inspect` and the build history matches `docker history` (Docker ≥ 26
|
|
816
|
+
goes through `--format`, older versions fall back to the plain-text table); look up a dangling row by image ID.
|
|
817
|
+
Click "remove" → after a second confirmation it runs `docker image rm`; removing an image that a container
|
|
818
|
+
references should fail with a hint.
|
|
819
|
+
"prune dangling" only removes untagged images, and its output ends with `Total reclaimed space`.
|
|
820
|
+
8. **Pull progress stream**: the pull icon in the images toolbar (hovering shows "pull image") → enter a small image
|
|
821
|
+
you do not have locally (such as `alpine:3.20`)
|
|
822
|
+
→ per-layer status lines appear live and are updated in place by layer; when it finishes it hints "pull complete"
|
|
823
|
+
and refreshes the list automatically.
|
|
824
|
+
Clicking "stop" mid-pull or leaving the view → `docker pull` is terminated by SIGTERM with no leftover process.
|
|
825
|
+
With "allow mutations" off the button is greyed out, and going straight to `/images/pull/stream` returns 403.
|
|
826
|
+
9. **Compose project view**: start two or three services with a compose file (`docker compose up -d`) →
|
|
827
|
+
switch the toolbar to "Compose" → the services are grouped under one project card with the correct
|
|
828
|
+
running / service counts and states;
|
|
829
|
+
click into the project to see the service table, switch to "merged logs" → the services' logs appear mixed by the
|
|
830
|
+
`[service]` prefix (the equivalent of `docker compose logs -f`), and the filter box can filter by service name and
|
|
831
|
+
content; after turning "auto
|
|
832
|
+
scroll" off new logs keep entering the buffer without the view jumping. Containers without a compose label are
|
|
833
|
+
grouped under "other containers (non-compose)".
|
|
834
|
+
10. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
|
|
835
|
+
errors with a "stop it before removing" hint, and stopping first and then removing succeeds.
|
|
836
|
+
11. **After turning on `allowExec`**: a command such as `ls -la /app` returns stdout and the exit code; changing the
|
|
837
|
+
command to `sleep 60` (with `timeoutSec` lowered) should be interrupted and report a timeout; a `command` longer
|
|
838
|
+
than 8000 characters is rejected.
|
|
839
|
+
12. **The exec drawer coexists with the body** (tty ≥ 0.15): after opening the drawer from a card's "Terminal", switch
|
|
840
|
+
to logs / stats / another container's details, and both the drawer and the terminal session must stay alive;
|
|
841
|
+
**collapsing** (the arrow or double-clicking the top edge) squashes the drawer into one title bar and the session
|
|
842
|
+
keeps running (expanding restores it); **dragging the top edge** changes the height without exceeding 75% of the
|
|
843
|
+
panel; clicking the blank backdrop or the panel's ✕ should now raise an "end the container terminal session"
|
|
844
|
+
confirmation, and cancelling keeps the session alive while only confirming ends it (the tty side receives a kill).
|
|
845
|
+
Offline regression: `node packages/tty/scripts/preview.mjs docker-exec-logs`
|
|
846
|
+
(headless Chrome runs the real client: drawer + log page + collapse and expand, asserting that the session was
|
|
847
|
+
not ended).
|
|
848
|
+
13. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
|
|
849
|
+
tty panel →
|
|
850
|
+
connection bar "Containers" → the container panel should dock **to the right of the terminal** (not a full-screen
|
|
851
|
+
modal) while the terminal stays typable;
|
|
852
|
+
dragging the left edge resizes it (up to 72% of the panel width), the title-bar arrow collapses it into a narrow
|
|
853
|
+
strip (the terminal takes back the full width and the container panel is not unmounted), and ✕ tucks the panel
|
|
854
|
+
away without affecting the SSH tab; clicking a card's "Terminal" at this point should **open a new
|
|
855
|
+
`<container> · exec` tab in the same terminal panel** rather than nesting another terminal drawer.
|
|
856
|
+
Offline regression: `node packages/tty/scripts/preview.mjs docker-dock`
|
|
857
|
+
(asserting docked / no backdrop / the terminal widens after collapsing / the container panel survives).
|
|
858
|
+
14. **Multi-select merged logs**: click `Select for merging` in the container list → checkboxes appear on the left of
|
|
859
|
+
the cards and the card action bars collapse;
|
|
860
|
+
check 2–3 containers → the action bar shows "3 containers selected", and clicking `merged logs` → the merged
|
|
861
|
+
view's title is
|
|
862
|
+
"merged logs · 3 containers" with the three streams mixed by the `[service]` / container-name prefix; with only 1
|
|
863
|
+
checked the button is greyed out and hints "select at least 2 containers", and at 9 checked it is greyed out and
|
|
864
|
+
hints at a maximum of 8; stop one of the containers from the list → that stream ends with the usual `end`
|
|
865
|
+
semantics while the others are unaffected.
|
|
866
|
+
15. **Esc leaves selection mode**: pressing Esc in selection mode → back to the normal list, checks cleared and the
|
|
867
|
+
action bar gone;
|
|
868
|
+
clicking `Select for merging` again (its label is now `Exit selection`) has the same effect; switching targets /
|
|
869
|
+
switching segments / closing the panel also clear the selection mode and the checks together.
|
|
870
|
+
16. **The Activity strip**: the "Activity" strip appears at the head of the container list, its status dot goes from
|
|
871
|
+
yellow to green and the copy reads "receiving live
|
|
872
|
+
(docker events)"; running `docker restart <container>` / `docker stop`+`start` → the
|
|
873
|
+
list state follows within 1 second and the Activity strip shows `stop` / `start` (abnormal exits show the coded
|
|
874
|
+
form such as `die(137)`); clicking the title collapses it (the event stream is not interrupted, and expanding
|
|
875
|
+
still shows the accumulated latest 8);
|
|
876
|
+
several consecutive `docker exec` calls **produce no events at all** (`exec_*` has already been dropped by the
|
|
877
|
+
server-side allowlist);
|
|
878
|
+
after the network drops / `dsh web` restarts, recovery briefly shows a yellow status dot and automatically does
|
|
879
|
+
one full list refresh.
|
|
880
|
+
17. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
|
|
881
|
+
`bridge` / `host` / `none`
|
|
882
|
+
plus project networks created by compose; click one to enter details → the subnet / gateway in the overview match
|
|
883
|
+
`docker network
|
|
884
|
+
inspect`, an `internal` network carries a badge, and the "connected containers" tab lists container names and
|
|
885
|
+
IPv4.
|
|
886
|
+
The volume list's mountpoints match `docker volume ls` (over-long paths are truncated, hovering shows the full
|
|
887
|
+
value).
|
|
888
|
+
With "allow mutations" off, both prune icons and the remove key in details are grey; with it on:
|
|
889
|
+
removing a network that no container is attached to succeeds, while removing one still in use errors with a hint;
|
|
890
|
+
volume pruning raises a confirmation saying "the data will be deleted as well". **Note**: on docker < 23 volume
|
|
891
|
+
pruning also deletes named volumes, so it is best to confirm on a test target first.
|
|
892
|
+
|
|
893
|
+
18. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
|
|
894
|
+
the target picker;
|
|
895
|
+
clicking it shows two counter cards on one screen (each badged "local" / "SSH") plus the attention area, with the
|
|
896
|
+
card numbers matching that target's
|
|
897
|
+
container list. Change one target's SSH address to something wrong (or stop the remote docker) and refresh →
|
|
898
|
+
only that card is outlined in red and "1 target unreachable" appears at the top, while the other card still shows
|
|
899
|
+
its results, and **the page does not**
|
|
900
|
+
wait for the SSH timeout before showing content (progressive landing). Click an attention row → it lands on that
|
|
901
|
+
container's details on that host, and going back gives
|
|
902
|
+
**that target's** container list rather than the Overview; click a counter card → that target's container list.
|
|
903
|
+
When everything is fine the attention area shows
|
|
904
|
+
"all good"; while targets are still answering it shows "reading…". Turning on "auto refresh" → polls all targets
|
|
905
|
+
at
|
|
906
|
+
`pollIntervalSec` (turning it off stops that).
|
|
907
|
+
19. **Switching targets does not cross-talk (the list write gate)**: let Target1 (an unreachable SSH host) fail first
|
|
908
|
+
and raise an "operation failed" banner,
|
|
909
|
+
then immediately switch to the healthy Target2 → the banner should **disappear with the switch at once** and the
|
|
910
|
+
list should be Target2's containers; then wait 20s
|
|
911
|
+
so that Target1's timeout response comes back **after** that → the banner must not reappear and the list must not
|
|
912
|
+
be replaced with
|
|
913
|
+
Target1's containers. Entering from the Overview by clicking Target1's red counter card should likewise not show
|
|
914
|
+
the previous target's failure.
|
|
915
|
+
Reverse check: switch to Target2 and then immediately back to Target1 (which still cannot connect) → the banner
|
|
916
|
+
should describe Target1's own
|
|
917
|
+
failure, not treat Target2's successful result as Target1's.
|
|
918
|
+
|
|
919
|
+
## Version / license
|
|
920
|
+
|
|
921
|
+
`@hyzyn/dsh-docker` 0.3.1 · [Apache License 2.0](../../LICENSE)
|