@hyzyn/dsh-docker 0.13.3 → 0.13.6
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 +98 -345
- package/README.md +98 -299
- package/client-src/index.js +240 -63
- package/client-src/log-stream.js +48 -3
- package/client.js +24 -24
- package/lib/docker.d.ts +24 -5
- package/lib/docker.js +33 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +450 -88
- package/lib/index.js.map +1 -1
- package/package.json +4 -4
- package/scripts/client-smoke.mjs +62 -17
- package/scripts/route-smoke.mjs +36 -2
package/README.en.md
CHANGED
|
@@ -41,8 +41,7 @@ dsh plugin --profile web add link:$(pwd)/packages/docker # repo development a
|
|
|
41
41
|
|
|
42
42
|
The aggregate package `@hyzyn/dsh-all` (or the repo-root bundle) already includes this plugin, so there is no need to
|
|
43
43
|
add it separately when installing everything at once. After installing, restart `dsh web` and the "Containers"
|
|
44
|
-
entry appears in the sidebar; the
|
|
45
|
-
"Docker Container Panel" card maintains targets and switches, and **saving applies hot** (`settings/updated`
|
|
44
|
+
entry appears in the sidebar; the "Docker Container Panel" card maintains targets and switches, and **saving applies hot** (`settings/updated`
|
|
46
45
|
triggers re-resolution, with no restart needed).
|
|
47
46
|
|
|
48
47
|
> If you have already installed `@hyzyn/dsh-all` or the root bundle in the web profile, do **not** add this package
|
|
@@ -225,9 +224,9 @@ Inside the panel:
|
|
|
225
224
|
start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
|
|
226
225
|
`merged logs` + `Cancel`. `merged logs` needs at least 2 containers; selecting the 3rd gives a soft hint, and above
|
|
227
226
|
**3** the button is greyed out with a hint about the cap (**where that cap comes from is in "Known limitations" —
|
|
228
|
-
the
|
|
229
|
-
project view (one `/logs/stream
|
|
230
|
-
and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
|
|
227
|
+
the merge cap**; the selection bar states it too). Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
|
|
228
|
+
project view (**one** `/logs/stream?ids=…` carrying every selected container, frames tagged with `id`, mixed by the
|
|
229
|
+
`[service]` / container-name prefix, with filtering and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
|
|
231
230
|
fully aligned with the single-container log view** (text filter + level threshold + `⬇ .log` / `⬇ .md` exporting
|
|
232
231
|
what is displayed + line count), plus two merged-only controls: **by time / by arrival** ordering and a **pause**
|
|
233
232
|
that freezes the view while the streams keep receiving (restoring flushes them in one go).
|
|
@@ -317,9 +316,10 @@ semantically wrong.
|
|
|
317
316
|
- **Compose project view**: the toolbar's third segment. It groups the `composeProject` / `composeService`
|
|
318
317
|
labels into "project → service → container", one row per project showing the running / unhealthy / service counts
|
|
319
318
|
and each container's state; clicking into a project shows the service table, or opens **project-level merged
|
|
320
|
-
logs** — one `/logs/stream
|
|
321
|
-
|
|
322
|
-
|
|
319
|
+
logs** — **one** `/logs/stream?ids=…` carrying every container in the project (frames tagged with `id`), mixed
|
|
320
|
+
client-side by the `[service]` prefix, with an auto-scroll switch and a filter box. Merging follows arrival order
|
|
321
|
+
and does not guarantee strict cross-container ordering. A failed per-container sub-stream is marked "partially
|
|
322
|
+
failed" while the other containers keep streaming (the connection stays open).
|
|
323
323
|
- **Images**: a `docker images` list (reference / size / created / short ID);
|
|
324
324
|
`<none>:<none>` dangling images carry a `dangling` marker. The search box and the "N / M images"
|
|
325
325
|
counter are **fixed in the toolbar** (they do not scroll away with the list), and the header column names stick
|
|
@@ -393,7 +393,7 @@ semantically wrong.
|
|
|
393
393
|
There are two ways to fill in `kind=ssh`:
|
|
394
394
|
|
|
395
395
|
1. **Reference a tty connection-book entry**: put the entry name in `book` (maintained in the connection book
|
|
396
|
-
under
|
|
396
|
+
under the Terminal Panel card), and the host / port / username / auth method follow from it. The
|
|
397
397
|
settings card's dropdown only lists connection-book entries tty has saved; if tty is not installed or the entry
|
|
398
398
|
does not exist, that target fails to resolve and both the panel and the agent tools report a clear error.
|
|
399
399
|
2. **Inline fields**: `host` + `username` are required, the rest as needed (`port` / `auth` /
|
|
@@ -472,7 +472,8 @@ only writes the plugin's own configuration and involves no target-side write tha
|
|
|
472
472
|
### Merged logs (multi-select / Compose project)
|
|
473
473
|
|
|
474
474
|
Selecting several containers or opening a Compose project can both merge the logs of several containers into one
|
|
475
|
-
stream
|
|
475
|
+
stream: **one** `/logs/stream?ids=…` carries them all (frames tagged with `id`; the host still starts one
|
|
476
|
+
`docker logs -f` per container), mixed client-side in arrival order. The toolbar offers:
|
|
476
477
|
|
|
477
478
|
| Control | Semantics |
|
|
478
479
|
| --- | --- |
|
|
@@ -522,7 +523,7 @@ The "Overview" page lays out all targets on one screen: one counter card per tar
|
|
|
522
523
|
"most recent end time" descending, so the freshest crash is at the top (hovering a row shows the end / start times,
|
|
523
524
|
restart count and exit code).
|
|
524
525
|
|
|
525
|
-
## Configuration (
|
|
526
|
+
## Configuration (saved and applied hot)
|
|
526
527
|
|
|
527
528
|

|
|
528
529
|
|
|
@@ -533,7 +534,6 @@ Where the settings surface **lives** depends on the DSH version, but it is alway
|
|
|
533
534
|
|---|---|
|
|
534
535
|
| `0.2.0-rc.1` and later | **Directly below the description on the plugin detail page** (`plugins.bundle.config`, key = **this package's own** bundle name) — no extra ">" step into a sub-page |
|
|
535
536
|
| `0.1.6` line | Plugins sidebar → the bundle's **row** → the ">" row detail (`plugins.row.config`) |
|
|
536
|
-
| `≤0.1.5` | Settings → Plugins → the plugin's collapsible card (`settings.plugin.item`) |
|
|
537
537
|
| All of the above | Settings → **plugin configuration**, the same entry (`settings.kit.item`, provided by `@hyzyn/dsh-kit-settings`) |
|
|
538
538
|
|
|
539
539
|
On hosts that have the bundle slot, **this package's** row entry is **no longer registered** — two entries
|
|
@@ -612,30 +612,38 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
612
612
|
| Tool | Registration | Parameters | Purpose / typical use |
|
|
613
613
|
| --- | --- | --- | --- |
|
|
614
614
|
| `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 |
|
|
615
|
-
| `docker_connect_local` | always registered | — (no parameters) | **Connect local**: makes sure a `kind=local` target exists and selects it (an existing one is reused, never duplicated; SSH / custom targets stay untouched), then probes the daemon read-only: returns `{name, kind, created, saved, reachable, serverVersion?}`.
|
|
616
|
-
| `docker_ps` |
|
|
617
|
-
| `docker_attention` |
|
|
618
|
-
| `docker_inspect` |
|
|
619
|
-
| `docker_logs` |
|
|
620
|
-
| `docker_logs_grep` |
|
|
621
|
-
| `docker_stats` |
|
|
622
|
-
| `docker_images` |
|
|
623
|
-
| `docker_events` |
|
|
624
|
-
| `docker_networks` |
|
|
625
|
-
| `docker_volumes` |
|
|
626
|
-
| `docker_image_inspect` |
|
|
627
|
-
| `docker_action` |
|
|
628
|
-
| `docker_image_remove` |
|
|
629
|
-
| `docker_image_prune` |
|
|
630
|
-
| `docker_image_pull` |
|
|
631
|
-
| `docker_exec` |
|
|
615
|
+
| `docker_connect_local` | always registered | — (no parameters) | **Connect local**: makes sure a `kind=local` target exists and selects it (an existing one is reused, never duplicated; SSH / custom targets stay untouched), then probes the daemon read-only: returns `{name, kind, created, saved, reachable, serverVersion?}`. With no targets configured the other tools are not registered at all (see the zero-target note below) — this is the one step that fixes that. **Requires no capability grant** (it only writes the plugin's own config plus a read-only probe) and never installs Docker / starts a daemon / switches context / touches environment variables. When the daemon is unreachable the result text names the cause (CLI missing / probe timed out / daemon not running / no socket permission; when the CLI is missing it also names another container CLI found on PATH, but never rewrites dockerBin for you) instead of an empty list |
|
|
616
|
+
| `docker_ps` | registered with ≥1 target | `target?` (**pass `*` = all targets**), `all?: boolean`, `limit?` (1–500, default 100), `offset?` (default 0) | 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`). Ports are merged across the IPv4/IPv6 dual-stack expansion (one `-p` no longer shows twice, D130); an empty `ports` does **not** mean "nothing exposed" — host-network containers publish on the host itself, and that case now carries a `net` field (D131). The first step of troubleshooting |
|
|
617
|
+
| `docker_attention` | registered with ≥1 target | `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 |
|
|
618
|
+
| `docker_inspect` | registered with ≥1 target | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
|
|
619
|
+
| `docker_logs` | registered with ≥1 target | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?`, `until?` | the tail from `docker logs --tail`; `since` / `until` use docker syntax (e.g. `10m`, `2026-09-09T10:00:00`) to bound a time window; over the cap it is marked `truncated`; when truncated (`truncated`) the result explains how to narrow it — a smaller `tail`, a `since`/`until` window, or `docker_logs_grep` |
|
|
620
|
+
| `docker_logs_grep` | registered with ≥1 target | `target?`, `id`, `pattern` (required, ≤512 chars), `regexp?`, `ignoreCase?`, `since?`, `until?`, `context?` (0–20, default 2), `limit?` (1–500, default 200), `tail?` (omitted = scan all history), `timestamps?` (on by default) | **server-side log search**: line-by-line filtering in the host (never touches the remote shell, never pipes ` | grep`), returns hit lines plus ±context lines, and honestly reports `matched` (all hits even when not returned) / `scanned` / `limited` / `truncated` — "only scanned this far" is distinguishable from "scanned everything"; the scan window is **aligned to line boundaries** (the partial line left by a byte-level cut is never returned as a line, so every entry in `lines` is a complete line); returned lines have their own cap: at most 4000 lines (`MAX_RESULT_LINES`); over it the result is cut to the cap and carries `truncatedForSize` plus `notes` (lower `limit` / reduce `context` / bound the window with `since`·`until`), while `matched` still reports the full count. Division of labour with `docker_logs`: error in the recent tail → `docker_logs` (cheaper); buried deep / a specific time window / keywords, error codes, request IDs → this tool. Literal substring by default (grep -F semantics, zero ReDoS); JS regex only with `regexp: true` |
|
|
621
|
+
| `docker_stats` | registered with ≥1 target | `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 |
|
|
622
|
+
| `docker_images` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | image list (repository:tag / size / created / short ID) |
|
|
623
|
+
| `docker_events` | registered with ≥1 target | `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 |
|
|
624
|
+
| `docker_networks` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | 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 |
|
|
625
|
+
| `docker_volumes` | registered with ≥1 target | `target?`、`limit?` (1–500, default 100), `offset?` (default 0) | volume list (name / driver / scope / mountpoint) |
|
|
626
|
+
| `docker_image_inspect` | registered with ≥1 target | `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) |
|
|
627
|
+
| `docker_action` | `allowMutations` + ≥1 target | `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 |
|
|
628
|
+
| `docker_image_remove` | `allowMutations` + ≥1 target | `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 |
|
|
629
|
+
| `docker_image_prune` | `allowMutations` + ≥1 target | `target?` | prunes dangling (untagged) images (`docker image prune -f`). Deliberately without `--all`, so only untagged images go |
|
|
630
|
+
| `docker_image_pull` | `allowMutations` + ≥1 target | `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 |
|
|
631
|
+
| `docker_exec` | `allowExec` + ≥1 target | `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 |
|
|
632
632
|
|
|
633
633
|
- When `target` is omitted it falls back to **the only** configured target; with several targets configured it is
|
|
634
634
|
required, and the error message lists the available target names.
|
|
635
|
+
- **With no targets configured only `docker_targets` / `docker_connect_local` are registered**: the other tools
|
|
636
|
+
have nothing to address, so registering them would only make the model guess once. As soon as a target is
|
|
637
|
+
configured they are registered **immediately**, with no restart. Those are exactly the two rows marked
|
|
638
|
+
“always registered” in the table.
|
|
639
|
+
- **The four list tools support pagination**: `docker_ps` / `docker_images` / `docker_networks` / `docker_volumes`
|
|
640
|
+
all accept `limit` (1–500, default 100) and `offset` (default 0), and their results carry `total` / `truncated`;
|
|
641
|
+
when truncated the rendered text gives the `offset` for the next page. Pagination only fixes “the result is too
|
|
642
|
+
long to read through” — it does not change the 512KB ceiling of a single docker command (that still errors).
|
|
635
643
|
- Changing either switch **re-registers the tools immediately**: after turning off `allowMutations` / `allowExec`
|
|
636
644
|
the corresponding tools (including the three image mutation tools) disappear from the agent side, with no restart.
|
|
637
645
|
- **Registered calls are also subject to the session permission tier gate (tier gate)**: by class — every
|
|
638
|
-
“
|
|
646
|
+
tool marked “registered with ≥1 target” in the table above is `read` (allowed on every tier); `docker_action` /
|
|
639
647
|
`docker_image_remove` / `docker_image_prune` / `docker_image_pull` are `write`, `docker_exec` is `exec`,
|
|
640
648
|
and these two classes trigger an **approval prompt on every call** under a confined tier (Read Only /
|
|
641
649
|
Workspace Write — the call runs only if the user allows this one; deterministically denied when unattended,
|
|
@@ -651,9 +659,11 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
651
659
|
`since` / `until` to search deeper) →
|
|
652
660
|
`docker_inspect` → `docker_stats`; for image problems `docker_images` →
|
|
653
661
|
`docker_image_inspect`.
|
|
654
|
-
- With `announceToAgent` on, the plugin injects a
|
|
655
|
-
|
|
656
|
-
|
|
662
|
+
- With `announceToAgent` on, the plugin injects a compact announcement into the systemPrompt: the plugin/panel
|
|
663
|
+
location, the "read-only by default" and "docker socket ≈ root on the target host" boundaries, the
|
|
664
|
+
"`allowExec` only governs the one-shot exec channel" split (the Terminal button is not gated by it), the `*`
|
|
665
|
+
scope of `target`, and the cross-tool troubleshooting order. It does **not** restate each tool's usage — that
|
|
666
|
+
is what the tool descriptions are for.
|
|
657
667
|
|
|
658
668
|
## HTTP routes (under the `/api/dsh-docker` prefix, all behind the loopback fence)
|
|
659
669
|
|
|
@@ -694,7 +704,7 @@ returned 403 on Desktop — the symptom was the logs / stats / events / pull str
|
|
|
694
704
|
| `/inspect` | POST | `{target?, id}` | `{ok:true, details: ContainerDetail[]}` |
|
|
695
705
|
| `/stats` | POST | `{target?, ids?: string[]}` | `{ok:true, stats: ContainerStats[]}` |
|
|
696
706
|
| `/logs` | POST | `{target?, id, tail?, timestamps?, since?}` | `{ok:true, logs:{id, text, truncated}}` |
|
|
697
|
-
| `/logs/stream` | GET | query: `target?`, `id` (
|
|
707
|
+
| `/logs/stream` | GET | query: `target?`, `id` (single container, or use `ids` instead) or `ids` (comma-separated multiple containers, **one connection carries all of them**, at most 32), `tail?` (**0**–5000; `0` = follow only, no history backfill, used on client reconnect), `timestamps?` (`1`/`true`), `since?` | `200 text/event-stream` long connection, event protocol below; bad parameters / unknown target / non-loopback return ordinary JSON errors |
|
|
698
708
|
| `/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 |
|
|
699
709
|
| `/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 |
|
|
700
710
|
| `/images` | POST | `{target?}` | `{ok:true, images: ImageSummary[]}` |
|
|
@@ -731,17 +741,28 @@ escaped and multi-byte characters are never split by an SSE line boundary), foll
|
|
|
731
741
|
| Event | data | Description |
|
|
732
742
|
| --- | --- | --- |
|
|
733
743
|
| `ready` | `{}` | sent once **the executor is really running** (local child `spawn` / remote channel opened = `StreamHandlers.onStart`): only then does the client consider the stream "receiving data". **Arrival of the response headers (`onopen`) does not count** — those are written by `flushHeaders()` immediately, while connecting (SSH `acquire` / opening the remote channel) can take tens of seconds, and an unreachable target produces nothing but an `error`. If it never starts there is **no** `ready` at all |
|
|
734
|
-
| `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 |
|
|
744
|
+
| `line` | `{"d":"..."}` / `{"e":"..."}`; with multiple sources `{"id":"<container id>","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. **In `ids=` mode every frame carries `id`**, and the client routes the chunk back to that container |
|
|
745
|
+
| `source-end` | `{"id":"<container id>","code":N\|null}` | **multiple sources only**: that container's `docker logs -f` exited naturally (the container stopped). The connection **stays open** — the other containers keep streaming; `end` only comes once every source has finished |
|
|
746
|
+
| `source-error` | `{"id":"<container id>","message":"..."}` | **multiple sources only**: that one sub-stream failed (spawn error / remote channel refused). The connection also stays open and the other containers continue; the client marks it "partially failed" |
|
|
735
747
|
| `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 |
|
|
736
748
|
| `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) |
|
|
737
|
-
| `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | the executor exited naturally. The log stream attaches the container's
|
|
749
|
+
| `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | the executor exited naturally. The log stream attaches a container exit code (with multiple sources it is the **last non-null code in `ids` order**; each container's real code is in its own `source-end`), the stats stream has reason=`stats-exit`, the event stream reason=`events-exit`, and the pull stream attaches `ref` |
|
|
738
750
|
| `error` | `{"message":"..."}` | a parameter / execution failure, after which the stream closes; a break at the connection layer does not send this event |
|
|
739
751
|
|
|
752
|
+
**Multiple containers over one connection (`ids=`, D168)**: merged logs (multi-select / Compose project logs) pack N
|
|
753
|
+
containers into **one** SSE — the host starts one `docker logs -f` per container and tags frames with `id`. That way the
|
|
754
|
+
merged view holds exactly **one** browser per-origin connection no matter how many containers are selected (the old
|
|
755
|
+
implementation opened one `EventSource` per container, which together with the Activity strip's event stream could
|
|
756
|
+
fill the 4-live-stream budget measured in D165). The single-source `id=` shape is **byte-for-byte unchanged**: no `id`
|
|
757
|
+
in frames, no `source-end`/`source-error` — old clients keep parsing it exactly as before. `/config`'s
|
|
758
|
+
`streamProtocol` is therefore bumped to **3**, and the client uses it to decide between the single connection and
|
|
759
|
+
falling back to "one stream per container".
|
|
760
|
+
|
|
740
761
|
The four streams differ only in "executor + end reason":
|
|
741
762
|
|
|
742
763
|
| Stream | Executor | Natural end condition | Close semantics |
|
|
743
764
|
| --- | --- | --- | --- |
|
|
744
|
-
| `/logs/stream` | `docker logs --follow` | the container stops (`container-exit
|
|
765
|
+
| `/logs/stream` | `docker logs --follow` (one per container in `ids=` mode) | the container stops (`container-exit`; in multi-source mode: every source has finished) | the frontend turns FOLLOW off / switches pages / closes the panel |
|
|
745
766
|
| `/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) |
|
|
746
767
|
| `/events/stream` | `docker events` (`--filter type=container`) | the daemon-side stream ends (`events-exit`) | switching pages / switching targets / closing the panel |
|
|
747
768
|
| `/images/pull/stream` | `docker pull` | the pull completes / fails (`pull-exit`) | the frontend leaves the pull view |
|
|
@@ -865,35 +886,38 @@ read-only first:
|
|
|
865
886
|
A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
|
|
866
887
|
same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
|
|
867
888
|
the frontend actively aborting the `EventSource`.
|
|
868
|
-
- **The browser's per-origin budget is 4 live streams
|
|
889
|
+
- **The browser's per-origin budget is 4 live streams, and the Docker panel now holds at most 1 (D165 measurement + D168)**:
|
|
869
890
|
the host speaks **HTTP/1.1** (no h2 multiplexing), a browser allows only so many TCP connections **per origin**, and
|
|
870
|
-
**every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is
|
|
871
|
-
ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it —
|
|
872
|
-
**whole DSH page freeze**, not just the Docker panel
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
on
|
|
886
|
-
|
|
887
|
-
|
|
891
|
+
**every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is
|
|
892
|
+
exhausted, **every** ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it —
|
|
893
|
+
users see the **whole DSH page freeze**, not just the Docker panel (measured: 0/4 live streams → in-page requests fine;
|
|
894
|
+
**5 streams → 10/10 timed out**; full data and the incident are in [DEFECTS.md](./DEFECTS.md) under D165).
|
|
895
|
+
The Docker panel's resident streams are now **1 in every view**: container list = the Activity strip's event stream;
|
|
896
|
+
details = log FOLLOW or stats FOLLOW (only one tab is live at a time); merged / Compose project logs = **one** `ids=`
|
|
897
|
+
multi-container stream; pull = one progress stream. **Entering a detail / merged view closes the event stream**
|
|
898
|
+
(the list is replaced, the Activity strip is not visible), and returning reconnects it and does a full refresh (D168).
|
|
899
|
+
**This is a hard constraint, not a performance tip**: going over does not mean "a bit slower", it means
|
|
900
|
+
every request on that page fails together. To reproduce: open
|
|
901
|
+
`new EventSource('/api/dsh-docker/events/stream?target=<target>')` in devtools until the 5th one, then fire an ordinary request
|
|
902
|
+
and watch it hang; closing them restores it. (WebSockets do **not** draw on this budget — the tty terminal's WS coexisted fine with 4 SSE streams.)
|
|
903
|
+
- **Multiple tabs have to be counted across *all* plugins, not just Docker**: those TCP slots are shared by the whole
|
|
904
|
+
page — the official `dsh-client-hmr`, other plugins' SSE streams, everything. If the host itself keeps 2 resident SSE
|
|
905
|
+
streams per tab, **3 tabs = 6 connections**, which is already the cliff; this layer is not something this plugin can
|
|
906
|
+
fix (the real fix is WebSocket / h2 on the host side, or one multiplexed connection shared by all plugins). Docker
|
|
907
|
+
has already reduced its own share to 1 (see above); count the rest from what is actually installed.
|
|
908
|
+
- **The merge cap is a readability + host-channel bound, no longer a browser-connection bound (D168)**: merged logs use
|
|
909
|
+
one connection for every selected container, so `PICK_MAX = 3` is no longer derived from "4-slot budget − 1 event
|
|
910
|
+
stream"; it stays 3 because more than three merged logs are hard to read and because each selected container still
|
|
911
|
+
runs its own `docker logs -f` on the host (one channel on SSH targets, see the 8-channel budget below). The selection
|
|
912
|
+
bar says the same thing.
|
|
888
913
|
- **The SSH channel budget is shared per target (`MaxSessions`)**: a target keeps only **one** TCP connection, and
|
|
889
914
|
every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults to
|
|
890
|
-
just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel;
|
|
915
|
+
just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel; merged logs still run
|
|
916
|
+
**one channel per selected container** (up to 3), and the next
|
|
891
917
|
"refresh list" (a short command) can then be rejected by the
|
|
892
918
|
far end with `(SSH) Channel open failure: open failed`. The plugin therefore caps each SSH target at **8 concurrent
|
|
893
|
-
long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect)
|
|
894
|
-
the
|
|
895
|
-
conflated them (it used 6, derived from `MaxSessions`, which was above the measured safe value). Going over
|
|
896
|
-
the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
|
|
919
|
+
long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect), and the merge selection at 3.
|
|
920
|
+
Going over the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
|
|
897
921
|
tunes `MaxSessions` (`sshd -T | grep maxsessions`), the current cap is a compile-time constant — file an issue if it
|
|
898
922
|
needs to follow.
|
|
899
923
|
Two more remedies attach to this (`D150`): short commands **queue per target** (at most 2 concurrent per connection,
|
|
@@ -1013,7 +1037,8 @@ Browser half (client.js)
|
|
|
1013
1037
|
│ ├─ events → EventSource /events/stream (SSE: the Activity strip's 50-entry ring buffer +
|
|
1014
1038
|
│ │ a 500ms debounce triggering a list refetch; one full refresh after a successful reconnect)
|
|
1015
1039
|
│ ├─ pull → EventSource /images/pull/stream (SSE: per-layer progress upserted by layer key)
|
|
1016
|
-
│ └─ merged logs → one /logs/stream
|
|
1040
|
+
│ └─ merged logs → **one** EventSource /logs/stream?ids=… (all containers share it; frames carry id,
|
|
1041
|
+
│ mixed client-side by [service]; old hosts fall back to one per container)
|
|
1017
1042
|
├─ optionally consumes tty's ttyConnbar service → SSH connection bar "Containers" button
|
|
1018
1043
|
│ (matches configured targets by book name / host:port; only appears on a hit)
|
|
1019
1044
|
├─ optionally consumes tty's ttyPanel service (0.16.0) → docks to the right while the terminal panel is open
|
|
@@ -1041,7 +1066,8 @@ Host half (src/index.ts)
|
|
|
1041
1066
|
│ ├─ loopback fence + assertRef / assertImageRef + tail clamping (same as the snapshot routes)
|
|
1042
1067
|
│ ├─ header write + flushHeaders / 15s ping heartbeat / active-stream registry / silent abort when the frontend disconnects
|
|
1043
1068
|
│ │ once the pipeline is up, a ready{} frame first (StreamHandlers.onStart: the only thing the client counts as "receiving data")
|
|
1044
|
-
│ ├─ /logs/stream: docker logs -f → line{"d"|"e"} + end{container-exit,code}
|
|
1069
|
+
│ ├─ /logs/stream: docker logs -f → line{"d"|"e"} + end{container-exit,code};
|
|
1070
|
+
│ │ ids= mode: one connection, N executors → line{id,d|e} + source-end/source-error per container
|
|
1045
1071
|
│ ├─ /stats/stream (read-only): docker stats normalised line by line → stats{ContainerStats};
|
|
1046
1072
|
│ │ it does not end naturally, so a frontend disconnect aborts it and docker stats exiting on its own sends end{stats-exit}
|
|
1047
1073
|
│ ├─ /events/stream (read-only): docker events --filter type=container →
|
|
@@ -1053,9 +1079,9 @@ Host half (src/index.ts)
|
|
|
1053
1079
|
├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
|
|
1054
1080
|
└─ agent tools: docker_targets / docker_ps / docker_inspect /
|
|
1055
1081
|
docker_logs (snapshot semantics unchanged) / docker_logs_grep (server-side search) / docker_stats / docker_events / docker_images /
|
|
1056
|
-
docker_image_inspect / docker_networks / docker_volumes (
|
|
1082
|
+
docker_image_inspect / docker_networks / docker_volumes (registered with ≥1 target)
|
|
1057
1083
|
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
1058
|
-
(allowMutations) / docker_exec (allowExec)
|
|
1084
|
+
(allowMutations + ≥1 target) / docker_exec (allowExec + ≥1 target)
|
|
1059
1085
|
```
|
|
1060
1086
|
|
|
1061
1087
|
## Development and acceptance
|
|
@@ -1064,288 +1090,15 @@ Host half (src/index.ts)
|
|
|
1064
1090
|
pnpm --filter @hyzyn/dsh-docker build # tsc → lib/ (host half) + esbuild → client.js (browser half)
|
|
1065
1091
|
pnpm --filter @hyzyn/dsh-docker typecheck
|
|
1066
1092
|
pnpm --filter @hyzyn/dsh-docker smoke # three offline regression suites, none needing a docker daemon
|
|
1067
|
-
pnpm test # repo-level vitest (
|
|
1093
|
+
pnpm test # repo-level vitest (this package's suites under test/ are included; counts are whatever the run reports)
|
|
1068
1094
|
```
|
|
1069
1095
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
(
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
injection)**, `assertBin`, `formatBytes`, `shJoin` escaping, `DockerApi`'s argv construction
|
|
1077
|
-
(ps / logs / action / exec / probe success and failure / **image inspect·rm·prune·pull·statsStream·pullStream**),
|
|
1078
|
-
`normalizeConfig` defaults and clamping, `sanitizeTargets` / `sanitizeHostKeys`,
|
|
1079
|
-
`resolveTarget`'s four paths, and `mergeTargetSecrets`' credential-preserving semantics.
|
|
1080
|
-
|
|
1081
|
-
`scripts/route-smoke.mjs` runs end to end with **a fake cordis ctx + a fake docker CLI script**: plugin
|
|
1082
|
-
mounting (settings / tools / routes / capability-announcement registration), the actual calls and returns of
|
|
1083
|
-
**27 routes** (including the event sequences and parameter validation of the four SSE streams `/logs/stream`,
|
|
1084
|
-
`/stats/stream`, `/events/stream`, `/images/pull/stream`, with the event stream additionally asserting that noise is
|
|
1085
|
-
dropped by the allowlist), the `docker_events` tool's snapshot output and its `since` character-set validation,
|
|
1086
|
-
`/images/inspect`'s details + build history, `/config` credential masking, 400 for unknown config keys, 403 for
|
|
1087
|
-
`/action`, `/exec` and image mutations (remove / prune / the pull stream) while read-only together with the
|
|
1088
|
-
corresponding tools not being registered, immediate unlocking after the switches are turned on (including the
|
|
1089
|
-
`settings/updated` hot-update path), 403 for non-loopback (including the three stream routes), container names /
|
|
1090
|
-
image references rejected by the allowlist when injection is attempted, the fallback when `target` is omitted and the
|
|
1091
|
-
error with several targets, and 403 on the stream routes after being disabled.
|
|
1092
|
-
|
|
1093
|
-
`scripts/client-smoke.mjs` executes the build artifact
|
|
1094
|
-
`client.js` in Node with minimal DOM / React stubs: verifying the registration id and factory shape, that it only
|
|
1095
|
-
requires modules provided by the platform seed
|
|
1096
|
-
(`react` / `react/jsx-runtime` / `react-dom/client`), that the settings card key registered by `apply` equals the
|
|
1097
|
-
namespace `docker`, that it degrades quietly when the host sidebar cannot be found and that unmount can be called
|
|
1098
|
-
repeatedly, the four paths of ttyConnbar integration (connection-book name hit / host:port hit / no button for an
|
|
1099
|
-
unconfigured host / silent skip when tty is not installed), FOLLOW's SSE subscription and the "back to bottom"
|
|
1100
|
-
interaction (static assertions), the **image details / pull stream / remove / prune entry points**,
|
|
1101
|
-
**stats FOLLOW + sparkline hooks**, **Compose grouping and merged logs**, **the "Activity" strip wiring + the event
|
|
1102
|
-
ring buffer / action labels / debounce** (pure logic through the `__events` test seam), and the style rule that hides
|
|
1103
|
-
the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
|
|
1104
|
-
Verification that needs a real daemon follows the manual checklist below.
|
|
1105
|
-
|
|
1106
|
-
`test/logs-stream.test.ts` (case count deliberately not hardcoded — `vitest run`'s output is the single source of truth) covers four layers of the live log stream:
|
|
1107
|
-
`logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
|
|
1108
|
-
(newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
|
|
1109
|
-
ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
|
|
1110
|
-
layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
|
|
1111
|
-
disabled — plus **`ready` is sent only after `spawn`** (not a single frame before it even though the headers are
|
|
1112
|
-
already flushed, and it precedes the first log line) — plus **the `busy` count must return to zero after a long stream is aborted** (D164: neither a far end that
|
|
1113
|
-
never confirms the channel close nor eight such leaks in a row may pin the target, and chunks arriving after teardown
|
|
1114
|
-
are not delivered) — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
|
|
1115
|
-
window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
|
|
1116
|
-
`skip` has not been written yet must still be reported — a 46-frame ledger where every frame is either delivered or
|
|
1117
|
-
accounted for). The coalescer's own rules (window merge / flush on the size cap /
|
|
1118
|
-
idempotent flush / dispose stops the timer) live in `test/sse-coalesce.test.ts` (6 cases).
|
|
1119
|
-
|
|
1120
|
-
`test/log-window.test.ts` (17 cases) covers the **pure windowing logic** (D152, `client-src/log-window.js`):
|
|
1121
|
-
measured heights in the cache vs the estimate for rows never measured, window placement and self-consistent
|
|
1122
|
-
padding for the non-pinned case, the pinned case anchoring to the tail with zero bottom padding (the premise
|
|
1123
|
-
for exact stick-to-bottom), `reanchor` only compensating for height changes above the anchor, `offsetOf` and
|
|
1124
|
-
"compensation after head eviction = the evicted height" (the cross-generation anchor math behind D154), cache
|
|
1125
|
-
FIFO eviction and empty-list edges. The real-DOM layer (measuring, anchor correction, pinning) is verified by the
|
|
1126
|
-
performance gate in real Chrome; offline there is also the `__logWindow` seam in `scripts/client-smoke.mjs`.
|
|
1127
|
-
|
|
1128
|
-
`pnpm --filter @hyzyn/dsh-docker perf:logs` (`scripts/log-perf.mjs`) is the **browser-side performance gate for the
|
|
1129
|
-
log page**: a real Chrome measures four scenarios — the 5000-row first paint (time / DOM size), the 20000-row FOLLOW
|
|
1130
|
-
burst (processing time and max event-loop lag), the **event flood** (the same 6.2k lines/s as 1 line per event vs 50
|
|
1131
|
-
lines per event — this is the per-event fixed cost D151 removed) and the **sustained flood** (20k lines/s for 6s; once
|
|
1132
|
-
the buffer is full every frame evicts and inserts, which is the "the page janks as soon as logs get fast" users
|
|
1133
|
-
report). It also checks two things users tend to misread: the snapshot must show the yellow "truncated" banner whenever
|
|
1134
|
-
the "Output limit (KB)" byte cap bites (otherwise "LINES = 1000 returned only 985 rows" looks like data loss — that
|
|
1135
|
-
985 is docker's own record granularity), it also asserts that "scroll-up pauses following" reacts to **real gestures only** (wheel / touch /
|
|
1136
|
-
scrollbar / keyboard: a gesture must pause and one click must resume, while positional drift with no gesture must
|
|
1137
|
-
*not* stop following), and it simulates the **backlog → reconnect** path (D153/D152: after a host-side `output-limit` the body must
|
|
1138
|
-
still show rows, following must not silently stop, and after the automatic reconnect the last mounted row must be
|
|
1139
|
-
the newest), it **parks mid-history during a flood** (D154: while the ring buffer keeps evicting, the top visible row
|
|
1140
|
-
must stay the same row for 1.2s — uncompensated eviction makes reading history drift like an automatic rewind), it checks the **generation switch** (D155: a snapshot refreshed into a generation of taller wrapped rows and then back to normal rows must have its padding computed from the *current* generation — a stale cache inflates `scrollHeight` by 35644px), and **checks the window is
|
|
1141
|
-
correct**: the snapshot content is deterministic, so scrolling to the top must show `seq=0` as the first row,
|
|
1142
|
-
scrolling to the bottom `seq=4999` as the last, and after the flood the last mounted row must be the newest one
|
|
1143
|
-
(stick-to-bottom still following). Over budget means a non-zero exit. Measured after D152: 5000-row snapshot
|
|
1144
|
-
179ms / **229 body nodes** (previously ~10000) with 53–57 rows mounted; 20k-row burst 933ms / max lag **2ms**
|
|
1145
|
-
(previously 52–97ms); event flood 10ms vs 7ms; sustained 20k lines/s × 6s max lag **6ms** with **0** frames >50ms
|
|
1146
|
-
(previously 70–97ms / 25–26) and 213 nodes. The budget also caps mounted rows at 200 — if windowing breaks, that
|
|
1147
|
-
turns red immediately. It depends on the tty preview fixtures
|
|
1148
|
-
(generated by `node packages/tty/scripts/preview.mjs`) and **skips with exit 0** when the fixtures or Chrome are
|
|
1149
|
-
missing — it is an npm script, not a required vitest entry.
|
|
1150
|
-
|
|
1151
|
-
`test/streams.test.ts` (46 cases) covers the **stats stream / event stream / pull stream / networks and volumes /
|
|
1152
|
-
generic SSE infrastructure**: `statsStream` without `--no-stream` (the same construction point as the snapshot),
|
|
1153
|
-
`pullStream`'s `assertImageRef` allowlist, `/stats/stream` normalising line-by-line JSON (including half lines
|
|
1154
|
-
across chunks) into `stats` events with the same shape as the `/stats` snapshot, heartbeats, silent abort when the
|
|
1155
|
-
client disconnects, **the `ready` frame only once the pipeline is up** (one case per stats / event / pull route:
|
|
1156
|
-
zero frames before `spawn` even though the headers are already flushed; two more pin "`ready` precedes the first
|
|
1157
|
-
data frame" and "a failed `spawn` sends no `ready`"), `eventsStream`'s argv (no `--since` / `--until`, carrying the `type=container` filter) and the
|
|
1158
|
-
`events()` snapshot's `--since` + `--until` (without until it would never exit),
|
|
1159
|
-
`parseContainerEvent`'s allowlist / bad-line dropping / health_status suffix / field extraction / compatibility with
|
|
1160
|
-
the old `status` field, `/events/stream`'s event sequence and half lines across chunks,
|
|
1161
|
-
`/images/pull/stream`'s allowMutations gate (403 and no stream) / `pull-exit` wrap-up / uniform wrap-up when
|
|
1162
|
-
disabled, **`assertName`'s validation matrix (`/` and `:` must be rejected — and those are exactly what
|
|
1163
|
-
`assertImageRef` admits), the ls·inspect·rm·prune argv for networks / volumes (prune must carry `-f`), tolerant
|
|
1164
|
-
ls·inspect parsing for network / volume (string booleans, older versions without `Mountpoint`, empty output / bad
|
|
1165
|
-
lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 for remove/prune with the switches off,
|
|
1166
|
-
400 for a missing name, 500 for an illegal name)**, plus `formatBytes`.
|
|
1167
|
-
|
|
1168
|
-
### Manual acceptance checklist
|
|
1169
|
-
|
|
1170
|
-
1. **Local target**: add a `kind=local` target named `local`, and `probe` returns the server version;
|
|
1171
|
-
the container list matches `docker ps -a` (including stopped containers).
|
|
1172
|
-
2. **SSH target**: with an entry already in the tty connection book, reference it via `book` → the container list /
|
|
1173
|
-
details / logs work; the first connection logs "host key fingerprint recorded (TOFU)" and the second does not
|
|
1174
|
-
prompt again; after manually changing the fingerprint in `hostKeys` and reconnecting, the connection should
|
|
1175
|
-
**be rejected** with reset guidance.
|
|
1176
|
-
3. **Connect local (one click)** — exercise **both** entries:
|
|
1177
|
-
- **With no targets**: clear every target and save (or use a fresh profile), open the panel — the
|
|
1178
|
-
empty state in the body shows a "Connect local" primary button, and the dropdown should **not**
|
|
1179
|
-
list it again; one click should produce a `kind=local` target named `local`, select it
|
|
1180
|
-
immediately, and load the container list;
|
|
1181
|
-
- **With other targets**: keep a few SSH hosts and open the target dropdown — the last item is
|
|
1182
|
-
`+ Connect local`, and the toolbar must carry **no** permanent entry; picking it should likewise
|
|
1183
|
-
add `local` and switch to it, after which the item disappears from the dropdown (a local target
|
|
1184
|
-
now exists).
|
|
1185
|
-
Neither place may show "the same entry listed twice"; nothing should install Docker, start a daemon
|
|
1186
|
-
or switch contexts along the way. On a host without docker you should see a named cause (CLI
|
|
1187
|
-
missing / probe timed out / daemon not running / no socket permission) rather than an empty list.
|
|
1188
|
-
- **On a machine that only has podman** (drop docker from `PATH`, leave podman): that sentence
|
|
1189
|
-
should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
|
|
1190
|
-
rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
|
|
1191
|
-
4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
|
|
1192
|
-
`/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly 13 read-only tools are
|
|
1193
|
-
registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `logs_grep` / `stats` / `events` / `images` /
|
|
1194
|
-
`image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
|
|
1195
|
-
the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
|
|
1196
|
-
"allow mutations" these routes and tools appear immediately (no restart needed).
|
|
1197
|
-
5. **Logs / stats / images**: `tail` and `timestamps` / `since` take effect; stats show
|
|
1198
|
-
CPU, memory, network and block IO; the image list carries a marker on dangling entries.
|
|
1199
|
-
6. **FOLLOW live log stream**: turning on `FOLLOW` on the log page → the status line first says
|
|
1200
|
-
"connecting" and then "following live", and new lines from `docker logs -f` appear immediately (`docker run --rm alpine sh
|
|
1201
|
-
-c 'i=0; while :; do echo line-$i; i=$((i+1)); sleep 1; done'` makes this observable);
|
|
1202
|
-
with FOLLOW on, `AUTO REFRESH` is greyed out and polling stops; scrolling up brings up "back to bottom",
|
|
1203
|
-
and clicking it returns to the bottom and resumes auto stick-to-bottom; turning FOLLOW off immediately returns to
|
|
1204
|
-
snapshots. Stop the container → the stream receives
|
|
1205
|
-
`end`, hints "the container has exited (exit code N)" and automatically does one more snapshot. Kill `dsh web` and
|
|
1206
|
-
start it again (or hot-change the plugin config) → the status line briefly says "reconnecting" and then heals
|
|
1207
|
-
itself, with no error banner. Run the same with an SSH target and confirm that panel operations such as
|
|
1208
|
-
`docker ps` on the same host are unaffected while the stream runs (connection reuse), and that idle reclamation
|
|
1209
|
-
(120s) does not cut the stream.
|
|
1210
|
-
7. **Stats live following**: turning on `FOLLOW` on the stats page → "connecting to the stats stream…" → "following
|
|
1211
|
-
live (docker stats)", and the CPU / memory sparklines grow a little every second (run `docker run --rm
|
|
1212
|
-
alpine sh -c 'while :; do :; done'` to watch CPU rise); turning FOLLOW off immediately returns to snapshots and
|
|
1213
|
-
resumes polling. Stop the containers being measured → the stream receives `end` (stats-exit), hints, and then
|
|
1214
|
-
returns to polling automatically.
|
|
1215
|
-
Note that **this stream does not end naturally**: switching pages / closing the panel must close the `EventSource`
|
|
1216
|
-
(on the host side `docker stats` should be seen being SIGTERM'd).
|
|
1217
|
-
8. **Image details / removal / pruning**: click "details" on a row of the images page → the layer count in the
|
|
1218
|
-
overview matches `docker image inspect` and the build history matches `docker history` (Docker ≥ 26
|
|
1219
|
-
goes through `--format`, older versions fall back to the plain-text table); look up a dangling row by image ID.
|
|
1220
|
-
Click "remove" → after a second confirmation it runs `docker image rm`; removing an image that a container
|
|
1221
|
-
references should fail with a hint.
|
|
1222
|
-
"prune dangling" only removes untagged images, and its output ends with `Total reclaimed space`.
|
|
1223
|
-
9. **Pull progress stream**: the pull icon in the images toolbar (hovering shows "pull image") → enter a small image
|
|
1224
|
-
you do not have locally (such as `alpine:3.20`)
|
|
1225
|
-
→ per-layer status lines appear live and are updated in place by layer; when it finishes it hints "pull complete"
|
|
1226
|
-
and refreshes the list automatically.
|
|
1227
|
-
Clicking "stop" mid-pull or leaving the view → `docker pull` is terminated by SIGTERM with no leftover process.
|
|
1228
|
-
With "allow mutations" off the button is greyed out, and going straight to `/images/pull/stream` returns 403.
|
|
1229
|
-
10. **Compose project view**: start two or three services with a compose file (`docker compose up -d`) →
|
|
1230
|
-
switch the toolbar to "Compose" → the services are grouped under one project card with the correct
|
|
1231
|
-
running / service counts and states;
|
|
1232
|
-
click into the project to see the service table, switch to "merged logs" → the services' logs appear mixed by the
|
|
1233
|
-
`[service]` prefix (the equivalent of `docker compose logs -f`), and the filter box can filter by service name and
|
|
1234
|
-
content; after turning "auto
|
|
1235
|
-
scroll" off new logs keep entering the buffer without the view jumping. Containers without a compose label are
|
|
1236
|
-
grouped under "other containers (non-compose)".
|
|
1237
|
-
11. **After turning on `allowMutations`**: stop / start / restart succeed; removing a running container
|
|
1238
|
-
errors with a "stop it before removing" hint, and stopping first and then removing succeeds.
|
|
1239
|
-
12. **After turning on `allowExec`**: a command such as `ls -la /app` returns stdout and the exit code; changing the
|
|
1240
|
-
command to `sleep 60` (with `timeoutSec` lowered) should be interrupted and report a timeout; a `command` longer
|
|
1241
|
-
than 8000 characters is rejected.
|
|
1242
|
-
13. **The exec drawer coexists with the body** (tty ≥ 0.15): after opening the drawer from a card's "Terminal", switch
|
|
1243
|
-
to logs / stats / another container's details, and both the drawer and the terminal session must stay alive;
|
|
1244
|
-
**collapsing** (the arrow or double-clicking the top edge) squashes the drawer into one title bar and the session
|
|
1245
|
-
keeps running (expanding restores it); **dragging the top edge** changes the height without exceeding 75% of the
|
|
1246
|
-
panel; clicking the blank backdrop or the panel's ✕ should now raise an "end the container terminal session"
|
|
1247
|
-
confirmation, and cancelling keeps the session alive while only confirming ends it (the tty side receives a kill).
|
|
1248
|
-
Offline regression: `node packages/tty/scripts/preview.mjs docker-exec-logs`
|
|
1249
|
-
(headless Chrome runs the real client: drawer + log page + collapse and expand, asserting that the session was
|
|
1250
|
-
not ended).
|
|
1251
|
-
14. **Entering the container panel from the connection bar (dock mode, needs tty ≥ 0.16)**: open an SSH tab in the
|
|
1252
|
-
tty panel →
|
|
1253
|
-
connection bar "Containers" → the container panel should dock **to the right of the terminal** (not a full-screen
|
|
1254
|
-
modal) while the terminal stays typable;
|
|
1255
|
-
dragging the left edge resizes it (up to 72% of the panel width), the title-bar arrow collapses it into a narrow
|
|
1256
|
-
strip (the terminal takes back the full width and the container panel is not unmounted), and ✕ tucks the panel
|
|
1257
|
-
away without affecting the SSH tab; clicking a card's "Terminal" at this point should **open a new
|
|
1258
|
-
`<container> · exec` tab in the same terminal panel** rather than nesting another terminal drawer.
|
|
1259
|
-
Offline regression: `node packages/tty/scripts/preview.mjs docker-dock`
|
|
1260
|
-
(asserting docked / no backdrop / the terminal widens after collapsing / the container panel survives).
|
|
1261
|
-
15. **Multi-select merged logs**: click `Select for merging` in the container list → checkboxes appear on the left of
|
|
1262
|
-
the cards and the card action bars collapse;
|
|
1263
|
-
check 2–3 containers → the action bar shows "3 containers selected", and clicking `merged logs` → the merged
|
|
1264
|
-
view's title is
|
|
1265
|
-
"merged logs · 3 containers" with the three streams mixed by the `[service]` / container-name prefix; with only 1
|
|
1266
|
-
checked the button is greyed out and hints "select at least 2 containers", and at 9 checked it is greyed out and
|
|
1267
|
-
hints at a maximum of 8; stop one of the containers from the list → that stream ends with the usual `end`
|
|
1268
|
-
semantics while the others are unaffected.
|
|
1269
|
-
16. **Esc leaves selection mode**: pressing Esc in selection mode → back to the normal list, checks cleared and the
|
|
1270
|
-
action bar gone;
|
|
1271
|
-
clicking `Select for merging` again (its label is now `Exit selection`) has the same effect; switching targets /
|
|
1272
|
-
switching segments / closing the panel also clear the selection mode and the checks together.
|
|
1273
|
-
17. **The Activity strip**: the "Activity" strip appears at the head of the container list, its status dot goes from
|
|
1274
|
-
yellow to green and the copy reads "receiving live
|
|
1275
|
-
(docker events)"; running `docker restart <container>` / `docker stop`+`start` → the
|
|
1276
|
-
list state follows within 1 second and the Activity strip shows `stop` / `start` (abnormal exits show the coded
|
|
1277
|
-
form such as `die(137)`); clicking the title collapses it (the event stream is not interrupted, and expanding
|
|
1278
|
-
still shows the accumulated latest 8);
|
|
1279
|
-
several consecutive `docker exec` calls **produce no events at all** (`exec_*` has already been dropped by the
|
|
1280
|
-
server-side allowlist);
|
|
1281
|
-
after the network drops / `dsh web` restarts, recovery briefly shows a yellow status dot and automatically does
|
|
1282
|
-
one full list refresh.
|
|
1283
|
-
**No false green** (2026-10-11 report): point a target's SSH address at a blackhole address (or open the panel
|
|
1284
|
-
while the target is unreachable) → the status dot must be **yellow** with "Connecting to the event stream…", and
|
|
1285
|
-
only turns green once the host confirms `docker events` came up (the `ready` frame); if it cannot connect it
|
|
1286
|
-
finally settles on the red "Event stream closed" + the **Reconnect** button. The log / stats / pull streams
|
|
1287
|
-
follow the same rule (while connecting they each stay on their own "connecting…" copy instead of claiming
|
|
1288
|
-
"following live / receiving live / pulling").
|
|
1289
|
-
One more requirement — **never get stuck**: when the host half is old code (e.g. the host process has not been
|
|
1290
|
-
restarted, so `/config` has no `streamProtocol`) while the browser already loaded the new `client.js`, the strip
|
|
1291
|
-
must fall back to the old criterion and light up — it must not sit on "Connecting to the event stream…" forever.
|
|
1292
|
-
18. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
|
|
1293
|
-
`bridge` / `host` / `none`
|
|
1294
|
-
plus project networks created by compose; click one to enter details → the subnet / gateway in the overview match
|
|
1295
|
-
`docker network
|
|
1296
|
-
inspect`, an `internal` network carries a badge, and the "connected containers" tab lists container names and
|
|
1297
|
-
IPv4.
|
|
1298
|
-
The volume list's mountpoints match `docker volume ls` (over-long paths are truncated, hovering shows the full
|
|
1299
|
-
value).
|
|
1300
|
-
With "allow mutations" off, both prune icons and the remove key in details are grey; with it on:
|
|
1301
|
-
removing a network that no container is attached to succeeds, while removing one still in use errors with a hint;
|
|
1302
|
-
volume pruning raises a confirmation saying "the data will be deleted as well". **Note**: on docker < 23 volume
|
|
1303
|
-
pruning also deletes named volumes, so it is best to confirm on a test target first.
|
|
1304
|
-
|
|
1305
|
-
19. **Multi-target Overview**: configure two or more targets (local + one SSH) → the `Overview` pill appears next to
|
|
1306
|
-
the target picker;
|
|
1307
|
-
clicking it shows two counter cards on one screen (each badged "local" / "SSH") plus the attention area, with the
|
|
1308
|
-
card numbers matching that target's
|
|
1309
|
-
container list. Change one target's SSH address to something wrong (or stop the remote docker) and refresh →
|
|
1310
|
-
only that card is outlined in red and "1 target unreachable" appears at the top, while the other card still shows
|
|
1311
|
-
its results, and **the page does not**
|
|
1312
|
-
wait for the SSH timeout before showing content (progressive landing). Click an attention row → it lands on that
|
|
1313
|
-
container's details on that host, and going back gives
|
|
1314
|
-
**that target's** container list rather than the Overview; click a counter card → that target's container list.
|
|
1315
|
-
When everything is fine the attention area shows
|
|
1316
|
-
"all good"; while targets are still answering it shows "reading…". Turning on "auto refresh" → polls all targets
|
|
1317
|
-
at
|
|
1318
|
-
`pollIntervalSec` (turning it off stops that).
|
|
1319
|
-
20. **Switching targets does not cross-talk (the list write gate)**: let Target1 (an unreachable SSH host) fail first
|
|
1320
|
-
and raise an "operation failed" banner,
|
|
1321
|
-
then immediately switch to the healthy Target2 → the banner should **disappear with the switch at once** and the
|
|
1322
|
-
list should be Target2's containers; then wait 20s
|
|
1323
|
-
so that Target1's timeout response comes back **after** that → the banner must not reappear and the list must not
|
|
1324
|
-
be replaced with
|
|
1325
|
-
Target1's containers. Entering from the Overview by clicking Target1's red counter card should likewise not show
|
|
1326
|
-
the previous target's failure.
|
|
1327
|
-
Reverse check: switch to Target2 and then immediately back to Target1 (which still cannot connect) → the banner
|
|
1328
|
-
should describe Target1's own
|
|
1329
|
-
failure, not treat Target2's successful result as Target1's.
|
|
1330
|
-
21. **`docker_logs_grep` server-side search** (the shape used for the 2026-10-09 real-machine run): start a
|
|
1331
|
-
container whose log content you know in advance (any image you already have locally; no network needed):
|
|
1332
|
-
`docker run -d --rm --name dsh-grep-probe <local image> sh -c 'echo "[boot] starting"; for i in 1 2 3 4 5; do echo "tick $i ok"; done; echo "FATAL request-id=REQ-7788"; echo "WARN disk 91%" >&2; sleep 3600'`
|
|
1333
|
-
Then have the agent search it with `docker_logs_grep` and **check the raw return value item by item** (do not
|
|
1334
|
-
accept a paraphrase):
|
|
1335
|
-
- search `FATAL` → 1 hit, "scanned 8 lines", the hit marked `>` with ±2 context lines;
|
|
1336
|
-
- `tick [0-9]+` **without** `regexp` → 0 hits; with `regexp=true` → 5 hits (proves the default is **literal**);
|
|
1337
|
-
- `fatal` without `ignoreCase` → 0; with `ignoreCase=true` → 1;
|
|
1338
|
-
- `tick` + `limit=1` → "5 hits" reported but only one window returned, plus the "hits exceed the limit" note (`limited`);
|
|
1339
|
-
- `since=1h` hits / `until=1h` and `since=1s` both 0 hits **and "scanned 0 lines"** (proves `--since`/`--until` really pass through);
|
|
1340
|
-
- search `WARN` → the hit line's stream is `[err]`;
|
|
1341
|
-
- search a container that does not exist → docker's own error text, **not** an empty result.
|
|
1342
|
-
Forcing `truncated` needs a log **>8MB** (the 8MB scan floor is hard; lowering "output limit (KB)" does not move it):
|
|
1343
|
-
let a container write ~300k lines with the needles at the **very head** and the **very tail** → a full scan gives
|
|
1344
|
-
0 hits + the "scan output exceeded the byte limit" note for the former and a hit + the same note for the latter;
|
|
1345
|
-
adding `tail=5000` makes the note disappear in both (proving it reports a **real** scan-side overflow, not
|
|
1346
|
-
"the log is big so it is always true"). Also, **every returned line must be a complete line** (timestamp prefix):
|
|
1347
|
-
the byte gate cuts mid-line, and `lineAlign` drops that partial line (see the real-machine correction in ROADMAP)
|
|
1348
|
-
— a fragment such as `g line 174798` without a timestamp means this has regressed.
|
|
1096
|
+
The verification surface has three layers: **three offline smoke suites** (no docker daemon needed),
|
|
1097
|
+
**this package's vitest suites**, and the **manual acceptance checklist against a real daemon**.
|
|
1098
|
+
Per-item coverage, the browser-side performance budget and the manual steps live in
|
|
1099
|
+
[docs/verification.md](./docs/verification.md) (Chinese) — README only keeps "how to run"; the lists
|
|
1100
|
+
are not duplicated here (the same fact in two places is debt, see
|
|
1101
|
+
[conventions § documentation layers](../../docs/conventions.md#文档分层)).
|
|
1349
1102
|
|
|
1350
1103
|
## Version / license
|
|
1351
1104
|
|