@hyzyn/dsh-docker 0.11.0 → 0.13.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 +67 -12
- package/README.md +58 -12
- package/client-src/index.js +87 -4
- package/client-src/log-stream.js +27 -4
- package/client.js +23 -23
- package/lib/docker.d.ts +90 -6
- package/lib/docker.js +122 -3
- package/lib/docker.js.map +1 -1
- package/lib/index.js +136 -2
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +24 -0
- package/lib/ssh-exec.js +41 -6
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +3 -3
- package/scripts/client-smoke.mjs +61 -0
- package/scripts/route-smoke.mjs +37 -4
package/README.en.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
- **An explicit one-click "connect local"**: two **non-permanent** entries — with no targets at all it is the primary button in the "no Docker targets configured yet" empty state, and once you have other targets it is the **last item in the target dropdown**, `+ Connect local` (it is deliberately **not** on the toolbar, where being permanent means occupying space and begging for clicks — pure noise) — one click (or picking that item) adds the machine hosting the plugin as a local target and selects it (an existing local target is reused, never duplicated; SSH and custom targets stay untouched), and the same operation is `docker_connect_local` on the agent side. It does **not** change the meaning of an empty `targets`, never invents an implicit default target, never installs Docker / starts a daemon / switches docker contexts, and needs no capability grant (it only adds one local target pointing at the plugin's own host).
|
|
11
11
|
- **Aggregated fetches across targets**: the Overview page fans out over every `targets[]` entry in parallel, and an unreachable target only spoils its own cell; the agent side exposes the same shape through `docker_ps target:"*"` / `docker_attention target:"*"`, so targets never block one another.
|
|
12
12
|
- **"Needs attention" reads authoritative fields**: unhealthy / repeatedly restarting / OOM-killed / non-zero exit / dead; OOM and the real exit code come from one `docker inspect` — the 137 in a `docker ps` summary cannot separate an OOM kill from a manual kill, so filtering on the summary alone must misreport.
|
|
13
|
-
- **Four long-lived SSE streams on one substrate**: log FOLLOW, `docker stats`, `docker events` and `docker pull` all run through the same `openSseStream` (heartbeat / active-stream registry / teardown on disconnect) and differ only in how they end — logs and pulls finish on their own, stats and events are aborted by the browser. Multi-select merged logs recover true cross-container ordering from the `--timestamps` prefix; "pause" freezes rendering only (the stream keeps receiving and flushes in one batch on resume).
|
|
13
|
+
- **Four long-lived SSE streams on one substrate**: log FOLLOW, `docker stats`, `docker events` and `docker pull` all run through the same `openSseStream` (heartbeat / active-stream registry / teardown on disconnect / a `ready` frame as soon as the pipeline is up) and differ only in how they end — logs and pulls finish on their own, stats and events are aborted by the browser. "Connected" and "receiving data" are two different things: the client only counts `ready`, never the arrival of the response headers (see "SSE event protocol"). Multi-select merged logs recover true cross-container ordering from the `--timestamps` prefix; "pause" freezes rendering only (the stream keeps receiving and flushes in one batch on resume).
|
|
14
14
|
- **Read-only by default, capability switches in three tiers**: start / stop / remove, exec and image mutations are independent switches; while one is off the agent tools are **not registered** and the HTTP routes return 403 (the capability does not exist, rather than failing when called). Container names and IDs pass a whitelist, every command is built as argv with single-quote escaping, and passwords / passphrases are referenced as `env:NAME` (resolved through the official credential layer, falling back to the environment) and never sent back to the browser.
|
|
15
15
|
- **Right-click a log into the agent**: select the failing lines in the log view, then right-click for "send to the current session / fill the input box so I can edit first" — the selection travels with its target, container, time window and 20 lines of context on each side (the menu states plainly that the content enters the model context and may carry credentials). Delivery reports itself twice: a **viewport-level toast** (attached to `body`, above the panel and the terminal modal), and — when the terminal panel is open — an **automatic fold of the terminal** (`minimize()` on the `ttyPanel` v2 contract; sessions keep running and the sidebar "Terminal" entry's badge restores it), so the conversation is simply there. On older tty without that call it degrades to the toast's "the conversation is behind the panel" hint. To edit first, use the draft action — **the session input box is the only editing surface** (multi-line, with the full context in view, and exactly what the agent receives), instead of a second, weaker card editor. Locating the target session is version-tolerant: **≤0.1.5** reads `current` off the `sessions.list` snapshot, while **since 0.1.6** that field moved out of the session domain together with view selection, so it reads `retainedBy.mainView > 0` instead (the same test `dsh-client-ui-session` and this repo's codegraph use) — trusting only the legacy field greys out both menu items as "no session open". The same read drives "carry the containers tab across a session switch".
|
|
16
16
|
- **Data-level reuse of dsh-tty, no code coupling**: no tty code is imported and tty needs no source change, so the two install and upgrade independently; with tty present three optional extension points are consumed — connection-bar actions (`ttyConnbar`), the terminal host (`ttyTerminal`: a new tab under the tab/dock carriers, an in-place drawer under the modal) and the terminal-side dock (`ttyPanel.mountPane`, used only by the fallback path) — and each degrades silently without tty or below the required version.
|
|
@@ -211,6 +211,14 @@ Inside the panel:
|
|
|
211
211
|
(2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed,
|
|
212
212
|
so this only says "will not recover on its own — click Reconnect". The cause, the cap and the `MaxSessions`
|
|
213
213
|
details live in this page, not in the UI.
|
|
214
|
+
**"Receiving events" is only claimed on the host's `ready` frame** (2026-10-11 user report): SSE's `onopen`
|
|
215
|
+
only means "the response headers arrived" — the host flushes those headers **before** it starts `docker events`,
|
|
216
|
+
and connecting can stall for tens of seconds when the target is unreachable or the SSH connection is being
|
|
217
|
+
re-established (D150's "quota full → rebuild the connection" adds another round). During that window the strip
|
|
218
|
+
used to be green and say "Receiving live (docker events)" while not a single event had arrived. The green dot now
|
|
219
|
+
waits for `ready` (= the pipeline really came up); until then the state stays at "Connecting to the event stream…"
|
|
220
|
+
or "Connection lost, reconnecting…" — both of which are true. The log / stats / pull streams follow the same rule
|
|
221
|
+
(see "SSE event protocol").
|
|
214
222
|
- **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
|
|
215
223
|
mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
|
|
216
224
|
longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
|
|
@@ -608,7 +616,8 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
608
616
|
| `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`). 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 |
|
|
609
617
|
| `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 |
|
|
610
618
|
| `docker_inspect` | always registered | `target?`, `id` (required) | `docker inspect`'s authoritative details: state / health check / exit code / restart count / ports / mounts / networks / startup command |
|
|
611
|
-
| `docker_logs` | always registered | `target?`, `id`, `tail?` (1–5000, default `logTailDefault`), `timestamps?`, `since?` | the tail from `docker logs --tail`; `since`
|
|
619
|
+
| `docker_logs` | always registered | `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` |
|
|
620
|
+
| `docker_logs_grep` | always registered | `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). 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` |
|
|
612
621
|
| `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 |
|
|
613
622
|
| `docker_images` | always registered | `target?` | image list (repository:tag / size / created / short ID) |
|
|
614
623
|
| `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 |
|
|
@@ -637,7 +646,9 @@ and reconnect". The record list can be deleted / reset in the settings card (del
|
|
|
637
646
|
static switches above: the switches govern registration (host-level, persistent grant), the tier gate governs
|
|
638
647
|
each registered call (per call, follows the tier immediately). Mechanism:
|
|
639
648
|
[docs/permission-tier-plan.md](../../docs/permission-tier-plan.md).
|
|
640
|
-
- Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs`
|
|
649
|
+
- Recommended troubleshooting order: `docker_targets` → `docker_ps` → `docker_logs`
|
|
650
|
+
(when the tail does not explain the failure, `docker_logs_grep` with `pattern` /
|
|
651
|
+
`since` / `until` to search deeper) →
|
|
641
652
|
`docker_inspect` → `docker_stats`; for image problems `docker_images` →
|
|
642
653
|
`docker_image_inspect`.
|
|
643
654
|
- With `announceToAgent` on, the plugin injects a capability announcement into the systemPrompt (including the
|
|
@@ -673,7 +684,7 @@ returned 403 on Desktop — the symptom was the logs / stats / events / pull str
|
|
|
673
684
|
|
|
674
685
|
| Route | Method | Request body | Response |
|
|
675
686
|
| --- | --- | --- | --- |
|
|
676
|
-
| `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered`) |
|
|
687
|
+
| `/config` | GET | — | `{ok:true, config}`: the config snapshot (targets expose only `passwordSet` / `passphraseSet`, plus the read-only `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol` — the last one is the SSE frame protocol version the client uses to decide what counts as "ready"; see "SSE event protocol") |
|
|
677
688
|
| `/config` | POST | any subset of the config keys above | `{ok:true, config}`; an unknown key is 400 and invalid JSON is 400 |
|
|
678
689
|
| `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
|
|
679
690
|
| `/connect-local` | POST | `{}` (the body is ignored) | **Connect local**: reuses or creates a `kind=local` target and returns `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`; 503 when the host has no settings service and 500 when the save fails (both carry a reason and change no in-memory config). Requires the same-origin proof (it is a write path) but **not** `allowMutations` / `allowExec` |
|
|
@@ -710,14 +721,16 @@ returns 500 or 400 plus an `{error}` text.
|
|
|
710
721
|
|
|
711
722
|
The four long streams share one piece of infrastructure (`openSseStream`): uniform header writing +
|
|
712
723
|
`flushHeaders()`, a 15s `: ping` heartbeat frame, active-stream registration (a uniform `end` + abort when the plugin
|
|
713
|
-
is disabled / the config is hot-updated / it is uninstalled),
|
|
714
|
-
|
|
724
|
+
is disabled / the config is hot-updated / it is uninstalled), a silent abort when the client disconnects, and a
|
|
725
|
+
**`ready` frame sent as soon as the pipeline is actually up** (see the table below and
|
|
726
|
+
`StreamHandlers.onStart`). They differ only in the executor and the end reason:
|
|
715
727
|
|
|
716
728
|
Each frame is one `event:` line + one JSON `data:` line (single-line JSON encapsulation: newlines / quotes are
|
|
717
729
|
escaped and multi-byte characters are never split by an SSE line boundary), followed by an empty line:
|
|
718
730
|
|
|
719
731
|
| Event | data | Description |
|
|
720
732
|
| --- | --- | --- |
|
|
733
|
+
| `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 |
|
|
721
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 |
|
|
722
735
|
| `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 |
|
|
723
736
|
| `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) |
|
|
@@ -756,6 +769,16 @@ The four streams differ only in "executor + end reason":
|
|
|
756
769
|
own initiative (abort + `end`).
|
|
757
770
|
- A long SSH stream occupies a connection in the pool (a busy count) and idle reclamation (120s) does not kill it by
|
|
758
771
|
mistake; reclamation resumes once the stream ends.
|
|
772
|
+
- **What counts as "ready" (client side)**: the green dot / "following live" only trusts the host's `ready` frame —
|
|
773
|
+
arriving response headers do not count. Each stream also has a harder piece of evidence as a fallback (receiving it
|
|
774
|
+
means the pipeline is producing): the first real event for the events stream, the first line for the log stream,
|
|
775
|
+
the first sample for the stats stream, the first progress frame for the pull stream.
|
|
776
|
+
**Development skew** (the host process still runs the old code while the browser reads a new `client.js` from disk)
|
|
777
|
+
is handled by version negotiation: `streamProtocol < 2` in `/config` (or a host old enough to lack the field)
|
|
778
|
+
means that host never sends `ready`, so the client falls back to the old criterion (headers arrived = connected)
|
|
779
|
+
and reopens that stream exactly once so the fallback takes effect — measured on 2026-10-11: an old host plus a new
|
|
780
|
+
client used to be pinned on "Connecting to the event stream…" forever (cards could read containers while the strip
|
|
781
|
+
kept spinning).
|
|
759
782
|
|
|
760
783
|
## Security model
|
|
761
784
|
|
|
@@ -1017,6 +1040,7 @@ Host half (src/index.ts)
|
|
|
1017
1040
|
├─ generic SSE long connection openSseStream (src/index.ts, one piece of infrastructure for all four streams)
|
|
1018
1041
|
│ ├─ loopback fence + assertRef / assertImageRef + tail clamping (same as the snapshot routes)
|
|
1019
1042
|
│ ├─ header write + flushHeaders / 15s ping heartbeat / active-stream registry / silent abort when the frontend disconnects
|
|
1043
|
+
│ │ once the pipeline is up, a ready{} frame first (StreamHandlers.onStart: the only thing the client counts as "receiving data")
|
|
1020
1044
|
│ ├─ /logs/stream: docker logs -f → line{"d"|"e"} + end{container-exit,code}
|
|
1021
1045
|
│ ├─ /stats/stream (read-only): docker stats normalised line by line → stats{ContainerStats};
|
|
1022
1046
|
│ │ it does not end naturally, so a frontend disconnect aborts it and docker stats exiting on its own sends end{stats-exit}
|
|
@@ -1028,7 +1052,7 @@ Host half (src/index.ts)
|
|
|
1028
1052
|
├─ image routes: /images/inspect (read-only) · /images/remove · /images/prune (allowMutations)
|
|
1029
1053
|
├─ network / volume routes: /networks · /volumes and their inspect (read-only), remove / prune (allowMutations)
|
|
1030
1054
|
└─ agent tools: docker_targets / docker_ps / docker_inspect /
|
|
1031
|
-
docker_logs (snapshot semantics unchanged) / docker_stats / docker_events / docker_images /
|
|
1055
|
+
docker_logs (snapshot semantics unchanged) / docker_logs_grep (server-side search) / docker_stats / docker_events / docker_images /
|
|
1032
1056
|
docker_image_inspect / docker_networks / docker_volumes (always registered)
|
|
1033
1057
|
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
1034
1058
|
(allowMutations) / docker_exec (allowExec)
|
|
@@ -1084,7 +1108,8 @@ Verification that needs a real daemon follows the manual checklist below.
|
|
|
1084
1108
|
(newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
|
|
1085
1109
|
ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
|
|
1086
1110
|
layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
|
|
1087
|
-
disabled — plus
|
|
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
|
|
1088
1113
|
never confirms the channel close nor eight such leaks in a row may pin the target, and chunks arriving after teardown
|
|
1089
1114
|
are not delivered) — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
|
|
1090
1115
|
window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
|
|
@@ -1123,11 +1148,13 @@ turns red immediately. It depends on the tty preview fixtures
|
|
|
1123
1148
|
(generated by `node packages/tty/scripts/preview.mjs`) and **skips with exit 0** when the fixtures or Chrome are
|
|
1124
1149
|
missing — it is an npm script, not a required vitest entry.
|
|
1125
1150
|
|
|
1126
|
-
`test/streams.test.ts` (
|
|
1151
|
+
`test/streams.test.ts` (46 cases) covers the **stats stream / event stream / pull stream / networks and volumes /
|
|
1127
1152
|
generic SSE infrastructure**: `statsStream` without `--no-stream` (the same construction point as the snapshot),
|
|
1128
1153
|
`pullStream`'s `assertImageRef` allowlist, `/stats/stream` normalising line-by-line JSON (including half lines
|
|
1129
1154
|
across chunks) into `stats` events with the same shape as the `/stats` snapshot, heartbeats, silent abort when the
|
|
1130
|
-
client disconnects, `
|
|
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
|
|
1131
1158
|
`events()` snapshot's `--since` + `--until` (without until it would never exit),
|
|
1132
1159
|
`parseContainerEvent`'s allowlist / bad-line dropping / health_status suffix / field extraction / compatibility with
|
|
1133
1160
|
the old `status` field, `/events/stream`'s event sequence and half lines across chunks,
|
|
@@ -1162,8 +1189,8 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
|
|
|
1162
1189
|
should **name podman** and say "put podman in 'docker CLI' and it works", while **not**
|
|
1163
1190
|
rewriting `dockerBin` for you (go back to the settings card — the value is still `docker`).
|
|
1164
1191
|
4. **Read-only interception**: with both switches off, `/action`, `/exec`, `/images/remove`,
|
|
1165
|
-
`/images/prune`, `/images/pull/stream` all return 403; on the agent side exactly
|
|
1166
|
-
registered (`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` / `stats` / `events` / `images` /
|
|
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` /
|
|
1167
1194
|
`image_inspect` / `networks` / `volumes` — see the tool table for the authoritative list), and
|
|
1168
1195
|
the panel's start / stop / remove, image removal, pruning and pull buttons are greyed out. After turning on
|
|
1169
1196
|
"allow mutations" these routes and tools appear immediately (no restart needed).
|
|
@@ -1253,6 +1280,15 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
|
|
|
1253
1280
|
server-side allowlist);
|
|
1254
1281
|
after the network drops / `dsh web` restarts, recovery briefly shows a yellow status dot and automatically does
|
|
1255
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.
|
|
1256
1292
|
18. **Networks / volumes**: the toolbar segments show "Networks" and "Volumes". The network list should contain
|
|
1257
1293
|
`bridge` / `host` / `none`
|
|
1258
1294
|
plus project networks created by compose; click one to enter details → the subnet / gateway in the overview match
|
|
@@ -1291,6 +1327,25 @@ lines), and the gating of the eight `/networks` and `/volumes` endpoints (403 fo
|
|
|
1291
1327
|
Reverse check: switch to Target2 and then immediately back to Target1 (which still cannot connect) → the banner
|
|
1292
1328
|
should describe Target1's own
|
|
1293
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.
|
|
1294
1349
|
|
|
1295
1350
|
## Version / license
|
|
1296
1351
|
|
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
- **显式一键连接本机**:两个**非常驻**入口——一个目标都没有时是「还没有配置 Docker 目标」空态里的主按钮,已有别的目标时是**目标下拉的末一项** `+ 连接本机`(工具条上**不挂**:常驻时它既占位又招人点,纯噪音)——一次点击(或选中该项)就把宿主所在机器加成本机目标并选中(已有本机目标则直接复用,不重复创建;SSH / 自定义目标原样保留),agent 侧同一条操作是 `docker_connect_local`。它**不**改空 `targets` 的语义、**不**凭空造隐式默认目标、**不**自动装 Docker / 起 daemon / 切 docker context,也不需要任何能力授权(只加一条指向插件自身的本机目标)。
|
|
12
12
|
- **多目标聚合取数**:总览页对全部 `targets[]` 并行请求,单个目标不可达只污染自己那一格;agent 侧同一口径由 `docker_ps target:"*"` / `docker_attention target:"*"` 暴露,跨目标不互相阻塞。
|
|
13
13
|
- **「需关注」读权威字段**:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次 `docker inspect` 补齐——`docker ps` 摘要里的 137 分不出 OOM 与手动 kill,只按摘要筛必然误报。
|
|
14
|
-
- **四条 SSE 长流共用一套基建**:日志 FOLLOW、`docker stats`、`docker events`、`docker pull` 走同一个 `openSseStream`(心跳 / 活跃流登记 /
|
|
14
|
+
- **四条 SSE 长流共用一套基建**:日志 FOLLOW、`docker stats`、`docker events`、`docker pull` 走同一个 `openSseStream`(心跳 / 活跃流登记 / 断开清理 / 管道起来后先发一帧 `ready`),差异只在收尾语义——日志与拉取自然结束,统计与事件由前端主动断。「连上了」与「在收数据」是两件事:客户端只认 `ready`,响应头到达不算(见「SSE 事件协议」)。多选聚合日志按 `--timestamps` 前缀还原跨容器真实时序,「暂停」只冻结渲染(流继续接收,恢复时一次性补齐)。
|
|
15
15
|
- **默认只读,能力开关分三级**:启停删 / exec / 镜像变更各自独立开关,未开启时 agent 工具**不注册**、HTTP 路由 403(能力不存在,而非调用后报错);开关的**提权走两条带外通道**(启动环境变量 / 卡片里就地确认,见下条);容器名与 ID 过白名单,命令一律 argv 构造 + 单引号转义,密码 / 口令以 `env:NAME` **凭据引用**(官方凭据层解析,缺失时退回环境变量)且永不回传浏览器。
|
|
16
16
|
- **与 dsh-tty 数据级复用、代码级不耦合**:不 import 任何 tty 代码,tty 也无需改一行源码,两者可各自安装与升级;装了 tty 则消费三个可选扩展点——连接栏动作(`ttyConnbar`)、终端承载(`ttyTerminal`:标签 / dock 承载下经 `open` 新开标签,模态下经 `mount` 就地嵌入抽屉)、以及只在兜底路径用到的终端右侧 dock(`ttyPanel.mountPane`);未装或版本不足逐项静默降级。
|
|
17
17
|
|
|
@@ -188,6 +188,12 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
|
|
|
188
188
|
断开时右侧那句提示是**折叠头里的单行小字**,所以刻意写短(2026-10-08 用户反馈
|
|
189
189
|
「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点
|
|
190
190
|
「重连」」——成因、上限与 `MaxSessions` 那一串写在本页,不塞进界面。
|
|
191
|
+
**「在收事件」只认宿主那帧 `ready`**(2026-10-11 用户现场):SSE 的 `onopen` 只等于
|
|
192
|
+
「响应头到了」——宿主是先把响应头 flush 出去、**再去起** `docker events`,而目标不可达 /
|
|
193
|
+
SSH 正在重连时建连可以卡十几秒(D150 的「槽位满 → 重建连接」还会再来一轮)。那段时间
|
|
194
|
+
活动条原本已经亮成绿色的「实时接收中(docker events)」,一个事件都还没收到。现在绿点
|
|
195
|
+
只认 `ready`(= 管道真的起来了);此前停在「正在连接事件流…」/「连接中断,正在自动
|
|
196
|
+
重连…」——两种都是实话。日志 / 统计 / 拉取三条流同一口径(见「SSE 事件协议」)。
|
|
191
197
|
- **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
|
|
192
198
|
左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
|
|
193
199
|
免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
|
|
@@ -579,7 +585,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
579
585
|
| `docker_ps` | 恒注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean` | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(同一次 `-p` 不再出现两遍,D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
|
|
580
586
|
| `docker_attention` | 恒注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
|
|
581
587
|
| `docker_inspect` | 恒注册 | `target?`、`id`(必填) | `docker inspect` 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
|
|
582
|
-
| `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?` | `docker logs --tail` 尾部;`since` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00
|
|
588
|
+
| `docker_logs` | 恒注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?`、`until?` | `docker logs --tail` 尾部;`since` / `until` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00`)圈时间窗;超上限标记 `truncated` |
|
|
589
|
+
| `docker_logs_grep` | 恒注册 | `target?`、`id`、`pattern`(必填,≤512 字符)、`regexp?`、`ignoreCase?`、`since?`、`until?`、`context?`(0~20,默认 2)、`limit?`(1~500,默认 200)、`tail?`(缺省 = 扫全部历史)、`timestamps?`(默认开) | **日志服务端检索**:宿主侧逐行过滤(不碰远端 shell、不拼 `\| grep`),返回命中行 + 前后上下文,并如实报告 `matched`(全量命中数,即使没返回)/ `scanned` / `limited` / `truncated`——「只扫到这些」与「扫完了没更多」可区分;扫描窗口**对齐到行边界**(按字节截断处的半行不当作一行返回,所以 `lines` 里每一行都是完整的行)。与 `docker_logs` 的分工:报错就在最近尾部 → `docker_logs`(便宜);埋在深处 / 特定时间窗 / 找关键词、异常码、请求 ID → 本工具。默认字面子串(grep -F 语义,零 ReDoS),`regexp: true` 才按 JS 正则 |
|
|
583
590
|
| `docker_stats` | 恒注册 | `target?`、`ids?`(逗号分隔的容器名/ID) | `docker stats --no-stream` 快照:CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs;`ids` 省略 = 全部运行中容器。实时跟随是面板能力(SSE),工具保持单值快照语义 |
|
|
584
591
|
| `docker_images` | 恒注册 | `target?` | 镜像列表(仓库:标签 / 大小 / 创建时间 / 短 ID) |
|
|
585
592
|
| `docker_events` | 恒注册 | `target?`、`since?`(docker `--since` 语法,默认 `10m`) | 容器事件快照(`docker events --since <d> --until <now>`,同样过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,`exec_*` 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
|
|
@@ -605,7 +612,8 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
605
612
|
`tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
|
|
606
613
|
静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
|
|
607
614
|
机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
|
|
608
|
-
- 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs
|
|
615
|
+
- 推荐排障顺序:`docker_targets` → `docker_ps` → `docker_logs`(尾部看不到成因就
|
|
616
|
+
`docker_logs_grep` 带 `pattern` / `since` / `until` 检索深处)→
|
|
609
617
|
`docker_inspect` → `docker_stats`;镜像排查 `docker_images` →
|
|
610
618
|
`docker_image_inspect`。
|
|
611
619
|
- `announceToAgent` 开启时,插件向 systemPrompt 注入一段能力公告(含「默认
|
|
@@ -637,7 +645,7 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
637
645
|
|
|
638
646
|
| 路由 | 方法 | 请求体 | 返回 |
|
|
639
647
|
| --- | --- | --- | --- |
|
|
640
|
-
| `/config` | GET | — | `{ok:true, config}`:配置快照(targets 只给 `passwordSet` / `passphraseSet`,另附只读的 `ttyBooks` / `ttyAvailable` / `toolsRegistered
|
|
648
|
+
| `/config` | GET | — | `{ok:true, config}`:配置快照(targets 只给 `passwordSet` / `passphraseSet`,另附只读的 `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol`——最后这个是 SSE 帧协议版本,客户端据此决定「怎样才算已就绪」,见「SSE 事件协议」) |
|
|
641
649
|
| `/config` | POST | 上表配置键的任意子集 | `{ok:true, config}`;未知键 400,非法 JSON 400 |
|
|
642
650
|
| `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
|
|
643
651
|
| `/connect-local` | POST | `{}`(请求体被忽略) | **一键连接本机**:复用或新建 `kind=local` 目标并返回 `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`;宿主没有 settings 服务时 503,保存失败时 500(都带原因且不改内存配置)。要求同源证明(它是写路径),但**不需要** `allowMutations` / `allowExec` |
|
|
@@ -674,13 +682,15 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
|
|
|
674
682
|
|
|
675
683
|
四条长流共用同一份基建(`openSseStream`):统一写头 + `flushHeaders()`、15s
|
|
676
684
|
一帧 `: ping` 心跳、活跃流登记(插件禁用 / 配置热更新 / 卸载时统一 `end` +
|
|
677
|
-
abort
|
|
685
|
+
abort)、客户端断开静默中止,以及**管道起来后先发一帧 `ready`**(下表 + 客户端的
|
|
686
|
+
「绿点只认 ready」口径)。各自只差执行器与结束原因:
|
|
678
687
|
|
|
679
688
|
每帧一行 `event:` + 一行 JSON `data:`(JSON 单行封装:换行 / 引号被转义,
|
|
680
689
|
多字节字符不会被 SSE 行边界截断),随后空行:
|
|
681
690
|
|
|
682
691
|
| 事件 | data | 说明 |
|
|
683
692
|
| --- | --- | --- |
|
|
693
|
+
| `ready` | `{}` | **执行器真的起来了**才发一帧(本机子进程 `spawn` / 远端通道已开 = `StreamHandlers.onStart`):客户端据此才认为「在收数据」。**响应头到达(`onopen`)不算**——响应头由 `flushHeaders()` 立刻写出去,而建连(SSH `acquire` / 远端开通道)可能卡十几秒,目标不可达时干脆只有 `error`。起不来就**没有** `ready` |
|
|
684
694
|
| `line` | `{"d":"..."}` / `{"e":"..."}` | stdout / stderr 分片(不保证按行切,客户端自行拼行)。日志流与拉取流用 |
|
|
685
695
|
| `stats` | `ContainerStats` | 统计流专用:每秒每个容器一帧,字段与 `/stats` 快照完全一致。服务端按**扁平 `{...}` 抽取**(`docker stats` 即便 stdout 是管道也走 TTY 渲染器,帧里混着 `ESC[H/ESC[K/ESC[J`,按行解析会整行丢掉)并折叠同一采样的重复渲染,客户端不必再解析 docker 的 PascalCase 字符串 |
|
|
686
696
|
| `event` | `{action, name, image, composeProject?, time?, exitCode?}` | 事件流专用:一帧一个容器事件(白名单外的事件行在服务端就丢了;值为 null 的字段省略) |
|
|
@@ -715,6 +725,13 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
|
|
|
715
725
|
热更新 / 卸载 → 服务端主动收尾(abort + `end`)。
|
|
716
726
|
- SSH 的长流会占用连接池里的连接(busy 计数),空闲回收(120s)不会误杀;
|
|
717
727
|
流结束后恢复回收。
|
|
728
|
+
- **「已就绪」的判据(客户端侧)**:绿点 /「实时跟随中」只认宿主那帧 `ready`——响应头到了
|
|
729
|
+
不算。此外每条流还有一条「更硬的证据」兜底(收到它就等于管道在产数据):事件流的第一条
|
|
730
|
+
真实事件、日志流的第一行、统计流的第一个采样、拉取流的第一帧进度。
|
|
731
|
+
**开发档错位**(宿主进程还跑着旧代码、浏览器却从磁盘读到新的 `client.js`)另有版本协商:
|
|
732
|
+
`/config` 的 `streamProtocol < 2`(或老到没有这个字段)说明该宿主从不发 `ready`,客户端
|
|
733
|
+
退回旧判据(响应头到了就算连上),并只重开一次那条流让回退判据生效——2026-10-11 实测:
|
|
734
|
+
旧宿主 + 新客户端原本会被钉死在「正在连接事件流…」(卡片能读到容器,活动条却在转)。
|
|
718
735
|
|
|
719
736
|
## 安全模型
|
|
720
737
|
|
|
@@ -966,6 +983,7 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
|
|
|
966
983
|
├─ 通用 SSE 长连接 openSseStream(src/index.ts,四条流共用一份基建)
|
|
967
984
|
│ ├─ loopback 围栏 + assertRef / assertImageRef + tail 夹紧(与快照路由一致)
|
|
968
985
|
│ ├─ 写头 + flushHeaders / 15s ping 心跳 / 活跃流登记 / 前端断开静默 abort
|
|
986
|
+
│ │ 管道起来后先发 ready{}(StreamHandlers.onStart:客户端只认它当「在收数据」)
|
|
969
987
|
│ ├─ /logs/stream:docker logs -f → line{"d"|"e"} + end{container-exit,code}
|
|
970
988
|
│ ├─ /stats/stream(只读):docker stats 逐行归一 → stats{ContainerStats};
|
|
971
989
|
│ │ 不会自然结束,前端断连即 abort,docker stats 自退则 end{stats-exit}
|
|
@@ -977,7 +995,7 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
|
|
|
977
995
|
├─ 镜像路由:/images/inspect(只读)· /images/remove · /images/prune(allowMutations)
|
|
978
996
|
├─ 网络 / 卷路由:/networks · /volumes 与各自的 inspect(只读)、remove / prune(allowMutations)
|
|
979
997
|
└─ agent 工具:docker_targets / docker_ps / docker_inspect /
|
|
980
|
-
docker_logs(快照语义不变)/ docker_stats / docker_events / docker_images /
|
|
998
|
+
docker_logs(快照语义不变)/ docker_logs_grep(服务端检索)/ docker_stats / docker_events / docker_images /
|
|
981
999
|
docker_image_inspect / docker_networks / docker_volumes(恒注册)
|
|
982
1000
|
+ docker_action / docker_image_remove / docker_image_prune / docker_image_pull
|
|
983
1001
|
(allowMutations)/ docker_exec(allowExec)
|
|
@@ -1036,14 +1054,17 @@ loopback 403(含三条流路由)、容器名 / 镜像引用注入尝试被
|
|
|
1036
1054
|
多字节)、本地流生命周期(假 spawn:跨 chunk 多字节、SIGTERM→SIGKILL 阶梯、
|
|
1037
1055
|
close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳过、以及
|
|
1038
1056
|
路由层的事件序列 / 心跳 / 客户端断开静默中止 / 插件禁用统一收尾,以及
|
|
1057
|
+
**`ready` 只在 `spawn` 之后发**(响应头已 flush 也一帧不发,且排在第一条日志行前面)、
|
|
1039
1058
|
**`tail=0` 只跟随不补历史**(D133)、**背压不重写已写过的帧**与**积压时丢最旧 + `skip` 通知、
|
|
1040
1059
|
不掐流**(D153)、**skip 账目精确**(D156:skip 排队期间再丢弃,两段缺口都要报——
|
|
1041
1060
|
46 块逐块对账「每一帧要么送达、要么被 skip 记账」)、
|
|
1042
1061
|
**中止长流后 `busy` 必须归零**(D164:远端不确认关通道、累积 8 次都不会把目标钉死,
|
|
1043
1062
|
且收尾后的分片不再投递)、
|
|
1044
1063
|
以及**分片合帧**(D151:十个分片只出一个 `line` 帧;未到窗口就收尾时 `end` 仍排在
|
|
1045
|
-
|
|
1046
|
-
`
|
|
1064
|
+
这些行之后)、**`ready` 帧只在管道起来后发**(统计 / 事件 / 拉取三条路由各一例:
|
|
1065
|
+
`spawn` 之前一帧都没有——响应头已经 flush 了也不算;另两例钉住「`ready` 排在第一条
|
|
1066
|
+
数据帧前面」与「起不来(`spawn` 失败)不发 `ready`」)。合帧器本身的规则(窗口合并 /
|
|
1067
|
+
攒满即推 / flush 幂等 / dispose 停表)在 `test/sse-coalesce.test.ts`(6 例)。
|
|
1047
1068
|
|
|
1048
1069
|
`test/log-window.test.ts`(17 例)覆盖**窗口化挂载的纯逻辑**(D152,`client-src/log-window.js`):
|
|
1049
1070
|
实测高度进缓存而没量过的行用估算、`layout` 非贴底时的窗口定位与上下垫高自洽、贴底时锚到
|
|
@@ -1074,7 +1095,7 @@ close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳
|
|
|
1074
1095
|
(`node packages/tty/scripts/preview.mjs` 生成),夹具或 Chrome 缺失时**跳过并 exit 0**
|
|
1075
1096
|
—— 它是 npm script,不是 vitest 必跑项。
|
|
1076
1097
|
|
|
1077
|
-
`test/streams.test.ts`(
|
|
1098
|
+
`test/streams.test.ts`(46 例)覆盖**统计流 / 事件流 / 拉取流 / 网络卷 / 通用 SSE 基建**:
|
|
1078
1099
|
`statsStream` 不带 `--no-stream`(与快照同一构造点)、`pullStream` 的
|
|
1079
1100
|
`assertImageRef` 白名单、`/stats/stream` 把逐行 JSON(含跨 chunk 的半行)归一成
|
|
1080
1101
|
`stats` 事件且形状与 `/stats` 快照一致、心跳、客户端断开静默中止、
|
|
@@ -1110,9 +1131,9 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
|
|
|
1110
1131
|
**点名 podman** 并说明「把『docker CLI』填成 podman 就能用」,同时**不该**替你
|
|
1111
1132
|
改 `dockerBin`(回头去设置卡片看,值仍是 `docker`)。
|
|
1112
1133
|
4. **只读拦截**:两个开关都关时,`/action`、`/exec`、`/images/remove`、
|
|
1113
|
-
`/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册
|
|
1114
|
-
(`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` /
|
|
1115
|
-
`images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
|
|
1134
|
+
`/images/prune`、`/images/pull/stream` 全部 403;agent 侧恒注册 13 个只读工具
|
|
1135
|
+
(`docker_targets` / `connect_local` / `ps` / `attention` / `inspect` / `logs` /
|
|
1136
|
+
`logs_grep` / `stats` / `events` / `images` / `image_inspect` / `networks` / `volumes`,数量以工具表为准),
|
|
1116
1137
|
面板的启停删 / 镜像删除 / 清理 / 拉取按钮置灰。打开「允许变更操作」后这些
|
|
1117
1138
|
路由与工具立即出现(无需重启)。
|
|
1118
1139
|
5. **日志 / 统计 / 镜像**:`tail` 与 `timestamps` / `since` 生效;统计显示
|
|
@@ -1182,6 +1203,14 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
|
|
|
1182
1203
|
形式);点标题可折叠(事件流不中断,展开后仍是累积的最新 8 条);
|
|
1183
1204
|
`docker exec` 连续几次**不产生任何事件**(`exec_*` 已被服务端白名单丢掉);
|
|
1184
1205
|
断网 / 重启 `dsh web` 后恢复时状态点短暂黄色并自动补一次全量列表刷新。
|
|
1206
|
+
**不得出现假绿**(2026-10-11 现场):把某个目标的 SSH 地址改成黑洞地址(或在目标
|
|
1207
|
+
不可达时打开面板)→ 状态点必须是**黄色**的「正在连接事件流…」,只有宿主确认
|
|
1208
|
+
`docker events` 起来(`ready` 帧)才转绿;连不上时最终落到红色的「事件流已断开」+
|
|
1209
|
+
「重连」按钮。日志 / 统计 / 拉取三条流同理(建连期间各自停在「正在连接…」,不会
|
|
1210
|
+
先说「实时跟随中 / 实时接收中 / 正在拉取」)。
|
|
1211
|
+
另有一条**不许卡死**:宿主半体仍是旧代码(例如宿主进程还没重启、`/config` 里没有
|
|
1212
|
+
`streamProtocol`)而浏览器已加载新 `client.js` 时,活动条必须退回旧判据并点亮——
|
|
1213
|
+
不能永远停在「正在连接事件流…」。
|
|
1185
1214
|
18. **网络 / 卷**:工具条分段出现「网络」「卷」。网络列表应含 `bridge` / `host` / `none`
|
|
1186
1215
|
与 compose 创建的项目网络;点某条进详情 → 概览里子网 / 网关与 `docker network
|
|
1187
1216
|
inspect` 一致、`internal` 网络带徽标,「接入的容器」页签列出容器名与 IPv4。
|
|
@@ -1205,6 +1234,23 @@ network / volume 的 ls·inspect 解析容错(字符串布尔、缺 `Mountpoin
|
|
|
1205
1234
|
目标1 的容器。从总览点 目标1 那张红色计数卡进去,同样不该看到上一个目标的失败。
|
|
1206
1235
|
反向验证:切到目标2 后立刻切回目标1(它仍然连不上)→ 横幅应写的是目标1 自己的
|
|
1207
1236
|
失败,而不是把目标2 的成功结果当成了目标1 的。
|
|
1237
|
+
21. **`docker_logs_grep` 服务端检索**(2026-10-09 真机实测的口径):起一个日志内容已知的容器
|
|
1238
|
+
(用本地已有镜像即可,不必联网):
|
|
1239
|
+
`docker run -d --rm --name dsh-grep-probe <本地镜像> 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'`
|
|
1240
|
+
然后在会话里让 agent 用 `docker_logs_grep` 检索它,**逐项看原始返回值**(别接受转述):
|
|
1241
|
+
- 找 `FATAL` → 命中 1 行、「扫描 8 行」、命中行标 `>` 且带 ±2 行上下文;
|
|
1242
|
+
- `tick [0-9]+` **不传** `regexp` → 0 命中;传 `regexp=true` → 5 命中(证明默认是**字面**匹配);
|
|
1243
|
+
- `fatal` 不传 `ignoreCase` → 0;传 `ignoreCase=true` → 1;
|
|
1244
|
+
- `tick` + `limit=1` → 「命中 5 行」但只返回 1 组窗口,并出现「命中超过上限」提示(`limited`);
|
|
1245
|
+
- `since=1h` 命中 / `until=1h` 与 `since=1s` 各 0 命中**且「扫描 0 行」**(证明 `--since`/`--until` 真透传);
|
|
1246
|
+
- 找 `WARN` → 命中行的 stream 是 `[err]`;
|
|
1247
|
+
- 检索不存在的容器 → 报 docker 原文错误,**不是**空结果。
|
|
1248
|
+
`truncated` 只能用 **>8MB** 日志逼出来(扫描侧 8MB 硬下限,调小「输出上限(KB)」没用):
|
|
1249
|
+
让容器打 ~30 万行、探针分别写在**最开头**与**最末尾** → 全量扫时前者 0 命中 + 「扫描输出超过
|
|
1250
|
+
字节上限」提示,后者命中 + 同一提示;加 `tail=5000` 后两者都不再出现该提示(证明它报的是
|
|
1251
|
+
扫描侧**真溢出**,不是「日志大就恒 true」)。另外**返回的每一行都必须是完整行**(带时间戳
|
|
1252
|
+
前缀):字节闸的切口会切在行中间,`lineAlign` 负责丢掉那个半行(见 ROADMAP 的真机验收补正)
|
|
1253
|
+
—— 看到 `g line 174798` 这种没时间戳的碎片,就是这条回归了。
|
|
1208
1254
|
|
|
1209
1255
|
## 版本 / 许可证
|
|
1210
1256
|
|
package/client-src/index.js
CHANGED
|
@@ -1998,6 +1998,27 @@ function canReconnectEvents(status) {
|
|
|
1998
1998
|
return status === 'closed'
|
|
1999
1999
|
}
|
|
2000
2000
|
|
|
2001
|
+
/**
|
|
2002
|
+
* 这条宿主**会不会发 `ready` 帧**(`/config` 的 `streamProtocol`,见 src/index.ts)。
|
|
2003
|
+
*
|
|
2004
|
+
* 为什么需要版本协商:客户端半体与宿主半体同包同版本,正常永远一致;但开发档里会出现
|
|
2005
|
+
* 「宿主进程还跑着旧代码(模块已在内存里)、浏览器却从磁盘读到新的 client.js」——旧宿主
|
|
2006
|
+
* 从不发 `ready`,新客户端若死等就会把用户钉在「正在连接事件流…」上(2026-10-11 用户在
|
|
2007
|
+
* test profile 上实测到:卡片能读到容器,活动条却永远在转)。
|
|
2008
|
+
*
|
|
2009
|
+
* 判据:
|
|
2010
|
+
* - `streamProtocol >= 2` → 发 ready(本版宿主);
|
|
2011
|
+
* - 字段存在但更小(0 / 1)→ 老宿主:退回旧判据(响应头到了就算连上);
|
|
2012
|
+
* - `config` 还没加载(null)→ 按**发 ready** 处理(真宿主一定会答 `/config`),
|
|
2013
|
+
* 加载完成时由 Panel 里那段 effect 纠正(hostReadyFrameRef)。
|
|
2014
|
+
* - 字段整个缺失(比本包更老的宿主)→ 按「不发 ready」处理:它不可能有这帧。
|
|
2015
|
+
*/
|
|
2016
|
+
function hostSendsReadyFrame(config) {
|
|
2017
|
+
if (config === null || typeof config !== 'object') return true
|
|
2018
|
+
const value = Number(config.streamProtocol)
|
|
2019
|
+
return Number.isFinite(value) ? value >= 2 : false
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2001
2022
|
/**
|
|
2002
2023
|
* 从事件流的 `error` 事件里解出**服务端给的原因**(D164)。
|
|
2003
2024
|
*
|
|
@@ -3773,6 +3794,8 @@ window.__ModuleLoader__.load({
|
|
|
3773
3794
|
let payload = null
|
|
3774
3795
|
try { payload = JSON.parse(event.data) } catch { return }
|
|
3775
3796
|
if (payload === null || typeof payload !== 'object') return
|
|
3797
|
+
// 真的来了一个采样 = 管道在产数据:旧宿主(不发 ready)下这就是「已就绪」的证据
|
|
3798
|
+
setStatsStatus('open')
|
|
3776
3799
|
const cpu = typeof payload.cpuPercent === 'number' ? payload.cpuPercent : null
|
|
3777
3800
|
const mem = typeof payload.memPercent === 'number' ? payload.memPercent : null
|
|
3778
3801
|
setStats(payload)
|
|
@@ -3812,10 +3835,14 @@ window.__ModuleLoader__.load({
|
|
|
3812
3835
|
es.addEventListener('stats', onStats)
|
|
3813
3836
|
es.addEventListener('end', onEnd)
|
|
3814
3837
|
es.addEventListener('error', onError)
|
|
3815
|
-
|
|
3838
|
+
/*
|
|
3839
|
+
* 统计流的「已在跟随」同样只认 `ready`(宿主把 `docker stats` 真起来了);
|
|
3840
|
+
* `onopen` 只证明响应头到了,目标连不上时它会先亮成「实时跟随中(docker stats)」。
|
|
3841
|
+
*/
|
|
3842
|
+
es.addEventListener('ready', () => {
|
|
3816
3843
|
setStatsStatus('open')
|
|
3817
3844
|
setStatsNotice('')
|
|
3818
|
-
}
|
|
3845
|
+
})
|
|
3819
3846
|
return close
|
|
3820
3847
|
}, [active, tab, statsFollow, props.target, item.id])
|
|
3821
3848
|
|
|
@@ -4665,6 +4692,8 @@ window.__ModuleLoader__.load({
|
|
|
4665
4692
|
if (payload === null || typeof payload !== 'object') return
|
|
4666
4693
|
const text = typeof payload.d === 'string' ? payload.d : (typeof payload.e === 'string' ? payload.e : '')
|
|
4667
4694
|
if (text === '') return
|
|
4695
|
+
// 真的来了逐层进度 = `docker pull` 在跑:旧宿主(不发 ready)下这就是就绪证据
|
|
4696
|
+
setStatus('open')
|
|
4668
4697
|
const merged = mergeProgress(linesRef.current, text, pendingRef.current)
|
|
4669
4698
|
linesRef.current = merged.lines
|
|
4670
4699
|
pendingRef.current = merged.pending
|
|
@@ -4699,7 +4728,8 @@ window.__ModuleLoader__.load({
|
|
|
4699
4728
|
es.addEventListener('line', onLine)
|
|
4700
4729
|
es.addEventListener('end', onEnd)
|
|
4701
4730
|
es.addEventListener('error', onError)
|
|
4702
|
-
|
|
4731
|
+
// 拉取流同理:`ready` = 宿主的 `docker pull` 真起来了(onopen 只证明响应头到了)
|
|
4732
|
+
es.addEventListener('ready', () => { setStatus('open') })
|
|
4703
4733
|
return () => {
|
|
4704
4734
|
close()
|
|
4705
4735
|
pendingRef.current = ''
|
|
@@ -5902,6 +5932,18 @@ window.__ModuleLoader__.load({
|
|
|
5902
5932
|
const eventsOpenedRef = useRef({ opened: false })
|
|
5903
5933
|
/** 上一次建流时看到的**手动**重连令牌(D160):用来认出「这次 effect 跑是用户点的重连」。 */
|
|
5904
5934
|
const eventsReconnectRef = useRef(0)
|
|
5935
|
+
/**
|
|
5936
|
+
* 宿主会不会发 `ready` 帧(`/config` 的 `streamProtocol`,见 hostSendsReadyFrame)。
|
|
5937
|
+
*
|
|
5938
|
+
* 默认 true(config 还没加载时按新宿主):旧宿主靠各自的兜底判据点亮,不会把用户钉在
|
|
5939
|
+
* 「正在连接…」上。config 到达后由下面那段 effect 纠正。
|
|
5940
|
+
*/
|
|
5941
|
+
const hostReadyFrameRef = useRef(true)
|
|
5942
|
+
/**
|
|
5943
|
+
* 事件流的响应头已经收到、但还没拿到「已就绪」(`ready`)——只用于「config 晚到、
|
|
5944
|
+
* 而宿主是旧的」这种开发档错位:那种组合下要补一次重开,让 onopen 的回退判据生效。
|
|
5945
|
+
*/
|
|
5946
|
+
const eventsLinkedRef = useRef(false)
|
|
5905
5947
|
/** 打开中的容器详情:{ id, tab, item }(item 为快照,列表刷新后优先用新数据)。 */
|
|
5906
5948
|
const [detail, setDetail] = usePanelState('detail', null)
|
|
5907
5949
|
const [refreshToken, setRefreshToken] = useState(0)
|
|
@@ -6555,6 +6597,11 @@ window.__ModuleLoader__.load({
|
|
|
6555
6597
|
let payload = null
|
|
6556
6598
|
try { payload = JSON.parse(raw.data) } catch { return }
|
|
6557
6599
|
if (payload === null || typeof payload !== 'object') return
|
|
6600
|
+
/*
|
|
6601
|
+
* 真的收到一条事件 = 管道在产数据,这是比 `ready` 更硬的证据(旧宿主 / 仿真宿主
|
|
6602
|
+
* 没有 ready 帧时靠它点亮)。放在 lastEvent 更新之前无所谓:它只看状态。
|
|
6603
|
+
*/
|
|
6604
|
+
if (eventsLinkedRef.current) applyEventsReady()
|
|
6558
6605
|
setEvents((list) => pushEvent(list, payload, EVENT_BUFFER_LIMIT))
|
|
6559
6606
|
debounced.schedule()
|
|
6560
6607
|
}
|
|
@@ -6586,7 +6633,15 @@ window.__ModuleLoader__.load({
|
|
|
6586
6633
|
es.addEventListener('event', onEvent)
|
|
6587
6634
|
es.addEventListener('end', onEnd)
|
|
6588
6635
|
es.addEventListener('error', onError)
|
|
6589
|
-
|
|
6636
|
+
/*
|
|
6637
|
+
* 「连上了」与「真的在收事件」是两件事(2026-10-11 用户现场):宿主是先把 SSE
|
|
6638
|
+
* 响应头 flush 出去、**再**去起 `docker events`(见 src/index.ts 的
|
|
6639
|
+
* `readyOnStart`),所以 `onopen` 只证明响应头到了——目标连不上 / SSH 正在重连时
|
|
6640
|
+
* `acquire()` 可能卡十几秒,活动条却已经亮成绿色的「实时接收中(docker events)」。
|
|
6641
|
+
* 现在绿点只认宿主那句「管道真的起来了」(`ready` 帧 ← StreamHandlers.onStart)。
|
|
6642
|
+
*/
|
|
6643
|
+
const applyEventsReady = () => {
|
|
6644
|
+
eventsLinkedRef.current = false
|
|
6590
6645
|
setEventsStatus('open')
|
|
6591
6646
|
// 连上了就说明上次那条拒绝原因已经过去(D164):留着会让人以为现在还坏着
|
|
6592
6647
|
setEventsRefused('')
|
|
@@ -6603,12 +6658,39 @@ window.__ModuleLoader__.load({
|
|
|
6603
6658
|
hadOpen = true
|
|
6604
6659
|
eventsOpenedRef.current.opened = true
|
|
6605
6660
|
}
|
|
6661
|
+
es.addEventListener('ready', applyEventsReady)
|
|
6662
|
+
/*
|
|
6663
|
+
* `onopen` 只说明「响应头到了」——除非**宿主是旧的**(/config 的 streamProtocol < 2,
|
|
6664
|
+
* 或老到没有这个字段):那种宿主从不发 `ready`,死等会永远停在「正在连接事件流…」。
|
|
6665
|
+
* 那种组合下退回旧判据(响应头到了就算连上),并把 `eventsLinkedRef` 记上,
|
|
6666
|
+
* 供 config 晚到时那段 effect 补一次重开(2026-10-11 用户在 test profile 上实测到)。
|
|
6667
|
+
*/
|
|
6668
|
+
es.onopen = () => {
|
|
6669
|
+
eventsLinkedRef.current = true
|
|
6670
|
+
if (!hostReadyFrameRef.current) applyEventsReady()
|
|
6671
|
+
}
|
|
6606
6672
|
return () => {
|
|
6673
|
+
eventsLinkedRef.current = false
|
|
6607
6674
|
close()
|
|
6608
6675
|
debounced.cancel()
|
|
6609
6676
|
}
|
|
6610
6677
|
}, [active, view, target, eventsReconnect])
|
|
6611
6678
|
|
|
6679
|
+
/*
|
|
6680
|
+
* 宿主版本协商(2026-10-11):`streamProtocol < 2` 的宿主从不发 `ready`。
|
|
6681
|
+
*
|
|
6682
|
+
* config 正常在挂载时就到了(早于「选目标 → 建流」),那种情况下 events effect 里的
|
|
6683
|
+
* `onopen` 直接走回退判据;这里只兜「config 晚于建流」的一拍:那时流已经收到响应头、
|
|
6684
|
+
* 界面还停在「正在连接事件流…」,重开一次让 onopen 生效。只在版本**变化**时动手,
|
|
6685
|
+
* 下一次 onopen 就会置 open,不会循环。
|
|
6686
|
+
*/
|
|
6687
|
+
useEffect(() => {
|
|
6688
|
+
const next = hostSendsReadyFrame(config)
|
|
6689
|
+
if (hostReadyFrameRef.current === next) return
|
|
6690
|
+
hostReadyFrameRef.current = next
|
|
6691
|
+
if (!next && eventsLinkedRef.current) setEventsReconnect((value) => value + 1)
|
|
6692
|
+
}, [config])
|
|
6693
|
+
|
|
6612
6694
|
useEffect(() => {
|
|
6613
6695
|
if (notice === '') return undefined
|
|
6614
6696
|
const timer = setTimeout(() => setNotice(''), 4000)
|
|
@@ -8805,6 +8887,7 @@ window.__ModuleLoader__.load({
|
|
|
8805
8887
|
debounce: makeDebounced,
|
|
8806
8888
|
canReconnect: canReconnectEvents,
|
|
8807
8889
|
errorMessage: eventsErrorMessage,
|
|
8890
|
+
hostSendsReadyFrame,
|
|
8808
8891
|
}
|
|
8809
8892
|
/*
|
|
8810
8893
|
* 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是
|
package/client-src/log-stream.js
CHANGED
|
@@ -92,6 +92,21 @@ export function subscribeLogStream(options) {
|
|
|
92
92
|
options.onStatus?.('connecting')
|
|
93
93
|
const next = new EventSource(buildUrl(tailNow))
|
|
94
94
|
source = next
|
|
95
|
+
/*
|
|
96
|
+
* 「已就绪」只认两条证据(每次建流各自一份,见下):
|
|
97
|
+
* 1. 宿主的 `ready` 帧 —— 管道(远端通道 / 子进程)真的起来了;
|
|
98
|
+
* 2. **第一行真实日志** —— 有数据就是更硬的证据,顺带兜住「宿主还是旧版本、
|
|
99
|
+
* 从不发 ready」的开发档错位(否则界面会永远停在「正在连接日志流…」)。
|
|
100
|
+
* `onopen` 一条都不算:它只证明 SSE 响应头到了。
|
|
101
|
+
*/
|
|
102
|
+
let opened = false
|
|
103
|
+
const markOpen = () => {
|
|
104
|
+
if (opened) return
|
|
105
|
+
opened = true
|
|
106
|
+
attempt = 0
|
|
107
|
+
options.onStatus?.('open')
|
|
108
|
+
options.onOpen?.()
|
|
109
|
+
}
|
|
95
110
|
next.addEventListener('line', (event) => {
|
|
96
111
|
if (source !== next) return
|
|
97
112
|
let payload = null
|
|
@@ -101,6 +116,7 @@ export function subscribeLogStream(options) {
|
|
|
101
116
|
return
|
|
102
117
|
}
|
|
103
118
|
if (payload === null || typeof payload !== 'object') return
|
|
119
|
+
markOpen()
|
|
104
120
|
if (typeof payload.d === 'string') options.onLine?.(payload.d)
|
|
105
121
|
else if (typeof payload.e === 'string') options.onLine?.(payload.e)
|
|
106
122
|
})
|
|
@@ -151,11 +167,18 @@ export function subscribeLogStream(options) {
|
|
|
151
167
|
// 连接层错误:不再交给浏览器自动重连(它会重放 tail),改走手动重连
|
|
152
168
|
reconnect()
|
|
153
169
|
})
|
|
154
|
-
|
|
170
|
+
/*
|
|
171
|
+
* 宿主的 `ready` 帧(见 open 里的两条证据)。
|
|
172
|
+
*
|
|
173
|
+
* 注意 `ready` 也要走 `source !== next` 守卫:一个被换掉的旧连接可能在关掉之前
|
|
174
|
+
* 刚好把 ready 推过来,不挡就会把新连接的状态改成 open。
|
|
175
|
+
*/
|
|
176
|
+
next.addEventListener('ready', () => {
|
|
155
177
|
if (source !== next) return
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
178
|
+
markOpen()
|
|
179
|
+
})
|
|
180
|
+
next.onopen = () => {
|
|
181
|
+
/* 响应头到了 ≠ 管道起来了:状态留在 connecting / reconnecting,等 ready 或第一行 */
|
|
159
182
|
}
|
|
160
183
|
}
|
|
161
184
|
|