@hyzyn/dsh-docker 0.10.0 → 0.11.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 CHANGED
@@ -201,13 +201,23 @@ Inside the panel:
201
201
  may have fallen behind while the stream was down), including the case where the daemon never came up and the
202
202
  stream never opened at all — otherwise the strip turns green while the list still shows its error state, looking
203
203
  fixed when it is not.
204
+ **A refused reconnect must say why** (`D164`): when the server actively refuses this stream it sends an
205
+ `event: error` frame carrying a `message` (typically the long-stream quota being full — see "Known limitations";
206
+ that sentence says what to do about it). The client now **shows it on the Activity strip** ("Reconnect refused: …")
207
+ instead of discarding it. It used to just set `closed`, which made "refused → back to where it
208
+ was" indistinguishable from "the click did nothing", leaving the user to click over and over. The reason is
209
+ cleared on a successful reconnect (or when the stream is rebuilt).
210
+ The hint on the right of that header row is **small text inside a single line**, so it is deliberately short
211
+ (2026-10-08 user feedback: "verbose and stiff"): the state label on the left already says the stream is closed,
212
+ so this only says "will not recover on its own — click Reconnect". The cause, the cap and the `MaxSessions`
213
+ details live in this page, not in the UI.
204
214
  - **Select for merging (temporary multi-select merged logs)**: the toolbar's `Select for merging` enters selection
205
215
  mode — a checkbox appears on the left of every card, clicking a card body becomes **select / deselect** (it no
206
216
  longer opens details; the action bar collapses temporarily so that multi-selecting does not mis-click
207
217
  start / stop / remove), and an action bar appears between the toolbar and the list: "N containers selected" +
208
- `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; at 7–8 selected it gives a soft hint
209
- (browsers limit same-origin concurrent long connections), and above **8** the button is greyed out with a hint
210
- about the cap. Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
218
+ `merged logs` + `Cancel`. `merged logs` needs at least 2 containers; selecting the 3rd gives a soft hint, and above
219
+ **3** the button is greyed out with a hint about the cap (**where that cap comes from is in "Known limitations" —
220
+ the browser's per-origin connection budget**; the selection bar states it too). Clicking `merged logs` opens the merged view: it reuses exactly the merged logs of the Compose
211
221
  project view (one `/logs/stream` per container, mixed by the `[service]` / container-name prefix, with filtering
212
222
  and auto-scroll), and going back exits selection mode and clears it. The merged view's **content controls are
213
223
  fully aligned with the single-container log view** (text filter + level threshold + `⬇ .log` / `⬇ .md` exporting
@@ -219,7 +229,7 @@ Inside the panel:
219
229
  refresh are pruned by id.
220
230
  - **One-click selection by condition** (in selection mode): the action bar's second row offers a row of condition
221
231
  chips (`all visible / unhealthy / needs attention / stopped`, plus `same image / same project` once something is
222
- selected), with counts **truncated to the remaining slots** — a chip reading 8 really does select 8; anything over
232
+ selected), with counts **truncated to the remaining slots** — a chip reading 3 really does select 3; anything over
223
233
  the cap is stated honestly in the title and the result hint. Conditions only apply within the
224
234
  **current filtered result** (search / filter the state first, then select in one click).
225
235
  - **Container cards**: "label + value" rows matching the reference layout (image / ID / ports / created /
@@ -228,7 +238,7 @@ Inside the panel:
228
238
 
229
239
  | Group | Buttons | Description |
230
240
  | --- | --- | --- |
231
- | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode |
241
+ | View / enter | Terminal, logs, resource usage | Does not change container state; always available even in read-only mode. **The Terminal button runs an interactive `docker exec -it` shell and is not gated by `allowExec`** (that switch only covers this plugin's one-shot exec channel — see "Known limitations") |
232
242
  | Mutate | start / stop, restart, remove | Ordered by increasing destructiveness; available only with `allowMutations` on, otherwise the whole group is greyed out |
233
243
 
234
244
  Remove gets two extra protections: destructive colouring plus a gap between it and "restart", and a second
@@ -536,7 +546,7 @@ return 400).
536
546
  | `announceToAgent` | true | whether to inject a capability announcement into the agent (systemPrompt section `plugin:dsh-docker`) |
537
547
  | `dockerBin` | `docker` | the docker CLI executable name or path (`podman` works here); only letters, digits and `_ . / \ : -` plus interior spaces are allowed, and it may not start with `-` (**Windows drive letters and `\` must be allowed**, otherwise no absolute path can be entered at all). **"Connect local" probes with exactly this value**; when it is wrong (that CLI does not exist) the message names another container CLI found on `PATH`, but it does **not** rewrite this value for you |
538
548
  | `allowMutations` | false | allows **mutating operations**: container start / stop / restart / remove, image removal / dangling pruning / pulling (the panel buttons and the `docker_action`, `docker_image_remove`, `docker_image_prune`, `docker_image_pull` tools; while off, `/action`, `/images/remove`, `/images/prune`, `/images/pull/stream` return 403 and the corresponding tools are not registered) |
539
- | `allowExec` | false | allows a one-shot `docker exec` (the panel's exec input and the `docker_exec` tool; while off, `/exec` returns 403) |
549
+ | `allowExec` | false | allows a **one-shot** `docker exec` (the one-off command box on the container detail drawer's **Overview** tab, and the `docker_exec` tool; while off, `/exec` returns 403). **Does not** cover the card's Terminal button (`docker exec -it`), which tty hosts (see "Known limitations") |
540
550
  | — (capability grant) | not granted | `allowMutations` / `allowExec` have **two out-of-band channels**: ① **launch environment variables** (`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`, values `1` / `true` / `yes` / `on`) — resolved from the host's **launch snapshot**, inherited `process` layer only, so writing project `.env` or `~/.dsh/env.yml` does **not** count as a grant; ② **grant in place** (no restart): click the switch in the settings card and run the command it shows in a terminal on the host — effective within seconds, on its own row (one row per capability — the grants are per capability, so side-by-side revoke buttons would not say which one belongs to which), next to a `Granted · YYYY-MM-DD HH:mm:ss` line and a `Revoke host grant` button (a launch-environment grant has no timestamp and cannot be revoked from the UI). **Both capabilities can be confirmed with a single paste**: clicking the second switch while the first panel is open no longer replaces it (the two grants are independent anyway) — the panels stack vertically and a **Copy all (N)** button appears at the top of the group, copying the still-pending commands in the order they were started, joined by a **newline**, so one paste in a host terminal runs them all (`bash` / `zsh` / PowerShell / cmd all run a multi-line paste line by line, which is exactly why `&&` — not understood by PowerShell 5.1 — and `;` — not understood by `cmd` — are deliberately **not** used as separators). Once a command expires, Copy all copies only the still-pending ones; when everything is granted (or every panel is collapsed) the button disappears by itself. The two challenges have independent TTLs and probe cycles, so the wording only promises "every switch unlocks by itself once they run" and **never the same instant** — the countdown stays per panel, and each panel's copy / regenerate / collapse keeps acting on its own capability only. In-place grants are **persistent**: they live in `<DSH home>/dsh-kit/capability-grants.json` (0600; the confirmation dir is `<DSH home>/dsh-kit/grant-confirm/`, 0700 — kept inside the kit's own subdirectory, because mechanism names dropped into the shared home root can collide with the harness or another plugin), are read at the next boot, and then **take effect with no further confirmation** — so every boot logs one `elevation: load capability=… via=file grantedAt=…` line per loaded grant. Over HTTP they can always be **turned off** (the emergency brake must not depend on a restart), but setting `true` without a grant is rejected with 400 and both routes spelled out. A `true` in the config is **not** a grant (it lives in the same store HTTP writes to, so the source is indistinguishable). **Upgrade note**: switches opened through the UI before this change become off — set the variable and restart, or grant in place. **Why**: neither the loopback fence nor the same-origin proof stops cross-site pages or in-page scripts (they can just set `Sec-Fetch-Site: same-origin`), and the docker socket is root on the target host; details (including whom this does *not* stop) in [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
541
551
  | `execTimeoutSec` | 30 | default exec timeout in seconds (1–120) |
542
552
  | `pollIntervalSec` | 5 | stats refresh interval for the panel, in seconds (1–60) |
@@ -760,6 +770,11 @@ read-only first:
760
770
  off, `/exec` returns 403 and the `docker_exec` tool is likewise not registered. The two switches are
761
771
  independent and must be turned on explicitly by the user in the settings card. Read-type routes (`/logs/stream`,
762
772
  `/stats/stream`, `/events/stream`, `/images/inspect`) are unaffected by either switch.
773
+ **`allowExec` only governs this plugin's *one-shot* exec channel** (the `/exec` route, the `docker_exec`
774
+ tool, the one-off command box on the container detail drawer's Overview tab) — the card's **Terminal button runs an interactive
775
+ `docker exec -it` shell hosted by tty, which passes through neither switch** and is available even in
776
+ read-only mode (see the boundary note under "Known limitations": it is not a missing gate, it is a
777
+ deliberately kept entry path).
763
778
  2. **Destructive operations restate their consequences**. `remove` maps to `docker rm` (**without `-f`**), and the
764
779
  agent announcement requires confirming the target container with the user before running; a running container
765
780
  errors with a hint that "the container is still running: stop it before removing", never a silent force-delete.
@@ -799,6 +814,18 @@ read-only first:
799
814
  without `-i` / `-t`), so it cannot run vim / top / an interactive shell, nor feed stdin for a
800
815
  dialogue. For interactive troubleshooting run `docker exec -it <container> sh` in the tty panel (this works for
801
816
  both local and SSH targets).
817
+ - **`allowExec` is not the gate that keeps you out of the container**: the card's Terminal button
818
+ (`docker exec -it '<container>' sh`) is hosted through tty's `ttyTerminal` and **does not check
819
+ `allowExec`** — it stays available in read-only mode, exactly as the panel's own hint says when exec is
820
+ off ("turn on *Allow exec*, or copy the exec command from the card into the terminal panel to enter the
821
+ container interactively"). This is deliberate: dropping into a container to look at the scene is the most
822
+ common read-only inspection path, and the gate governs **this plugin's own** one-shot channel (`/exec` and
823
+ `docker_exec`: they change local state and can be called in bulk by agent tools), while the interactive
824
+ shell belongs to tty (user-driven; tty is the PTY's owner). **The cost, stated plainly**: once tty is
825
+ installed, `allowExec` was never a boundary against **in-page scripts** (a third-party plugin's client half
826
+ / XSS) anyway — via `ttyTerminal.open` or a WS `spawn` frame they can run arbitrary commands, which is
827
+ strictly stronger than this plugin's one-shot exec. See
828
+ [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) for that layer's threat-model boundary.
802
829
  - **The event stream has a window while disconnected**: `docker events` is a stream of "from now on", so events that
803
830
  happen while the browser is disconnected and reconnecting have already been pushed by the server and are not
804
831
  resent. The client compensates with "do a full list refresh right after a successful reconnect"
@@ -815,16 +842,45 @@ read-only first:
815
842
  A long SSH stream holds that connection in the pool (busy) while other commands on the same host still reuse the
816
843
  same connection without affecting each other. **The stats stream does not end naturally**, so closing it must be
817
844
  the frontend actively aborting the `EventSource`.
845
+ - **The browser's per-origin budget is 4 live streams (this is what really sets the merge cap — D165)**:
846
+ the host speaks **HTTP/1.1** (no h2 multiplexing), a browser allows only so many TCP connections **per origin**, and
847
+ **every `EventSource` owns one connection and never releases it voluntarily**. Once that budget is used up, **every**
848
+ ordinary request from the same page (list refreshes, `/api/*`, session loading…) queues behind it — the user sees the
849
+ **whole DSH page freeze**, not just the Docker panel.
850
+ Measured (2026-10-08, real Chrome + real host; 10 in-page requests, 2s timeout):
851
+
852
+ | Live streams | In-page requests | Out-of-process request (same moment) |
853
+ |---|---|---|
854
+ | 0 | 10/10, median 27ms | 26ms |
855
+ | 4 | 8/8, median 25ms | — |
856
+ | **5** | **0/10, all timed out** | **41ms (fine)** |
857
+
858
+ That last column is the point: **the jam is in the browser, not the host**. The live-stream budget is therefore
859
+ **4**; while the panel sits on the container list the Activity strip's event stream is always open (1 of them), so
860
+ merged logs get **3** — that is where the merge cap (`PICK_MAX = 4 − 1`) comes from, and the selection bar says so.
861
+ **This is a hard constraint, not a performance tip**: going over does not mean "a bit slower", it means every request
862
+ on that page fails together. To reproduce: open `new EventSource('/api/dsh-docker/events/stream?target=<target>')`
863
+ in devtools until the 5th one, then fire an ordinary request and watch it hang; closing them restores it.
864
+ (WebSockets do **not** draw on this budget — the tty terminal's WS coexisted fine with 4 SSE streams, measured.)
818
865
  - **The SSH channel budget is shared per target (`MaxSessions`)**: a target keeps only **one** TCP connection, and
819
866
  every long stream and short command shares that connection's channels — while OpenSSH's `MaxSessions` defaults to
820
- just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel, and merged logs can
821
- take 8 at once, which uses the budget up exactly; the next "refresh list" (a short command) is then rejected by the
867
+ just 10. Long streams (logs / stats / events) hold a channel until the user closes the panel; the next
868
+ "refresh list" (a short command) can then be rejected by the
822
869
  far end with `(SSH) Channel open failure: open failed`. The plugin therefore caps each SSH target at **8 concurrent
823
- long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect), and the merged-log selection
824
- cap on an **SSH target** drops from 8 to 6 (local targets go through subprocesses and are unaffected). Going over
870
+ long streams** (= 10 − 2, leaving two for short commands such as refresh / inspect). Note the **merge cap is set by
871
+ the browser budget above, not by `MaxSessions`** — those are two independent accounts, and an earlier version
872
+ conflated them (it used 6, derived from `MaxSessions`, which was above the measured safe value). Going over
825
873
  the cap, and the far end refusing a channel, both produce pointed messages rather than ssh2's raw text. If your sshd
826
874
  tunes `MaxSessions` (`sshd -T | grep maxsessions`), the current cap is a compile-time constant — file an issue if it
827
875
  needs to follow.
876
+ Two more remedies attach to this (`D150`): short commands **queue per target** (at most 2 concurrent per connection,
877
+ `DSH_DOCKER_SHORT_CHANNELS` overrides, for testing / troubleshooting only), and a full budget **self-heals** by
878
+ dropping and `end()`ing the connection and retrying once on a fresh one. And (`D164`): aborting a long stream now
879
+ **settles it on the spot** instead of waiting for the far end — previously, if the far end never confirmed (the
880
+ `KILL` above being refused, or a silent channel), that stream's `busy` count never came down; after 8 such leaks
881
+ **every** long stream on that target was refused with no self-healing path at all (observed: 248 pinned at 8/8 with
882
+ the Activity strip's Reconnect appearing to do nothing). `run()` had already hit this same trap twice (`D112` /
883
+ `D161`); all three teardown paths now settle themselves.
828
884
  - **Docker CLI version differences**: parsing goes through `--format '{{json .}}'`, and fields come and go between
829
885
  versions; the parser always degrades instead of throwing (for example, a missing `State` has the state derived from
830
886
  `Status`, and health is extracted from `(healthy)` / `(unhealthy)`); with fields missing the corresponding columns
@@ -1023,12 +1079,14 @@ ring buffer / action labels / debounce** (pure logic through the `__events` test
1023
1079
  the entry label when the sidebar is collapsed (`data-sidebar-collapsed`).
1024
1080
  Verification that needs a real daemon follows the manual checklist below.
1025
1081
 
1026
- `test/logs-stream.test.ts` (37 cases, run by the root `pnpm test`) covers four layers of the live log stream:
1082
+ `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:
1027
1083
  `logsStream`'s argv construction and `assertRef` allowlist, the single-line JSON encapsulation of SSE frames
1028
1084
  (newlines / multi-byte), the local stream lifecycle (fake spawn: multi-byte across chunks, the SIGTERM→SIGKILL
1029
1085
  ladder, close resolve, spawn error) plus the SSH long stream's busy-count pairing / sweeper skip, and the route
1030
1086
  layer's event sequence / heartbeat / silent abort when the client disconnects / uniform wrap-up when the plugin is
1031
- disabled — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
1087
+ disabled — plus **the `busy` count must return to zero after a long stream is aborted** (D164: neither a far end that
1088
+ never confirms the channel close nor eight such leaks in a row may pin the target, and chunks arriving after teardown
1089
+ are not delivered) — plus **chunk coalescing** (D151: ten chunks produce a single `line` frame; when the stream ends before the
1032
1090
  window elapses, `end` still comes after those rows) and **exact skip accounting** (D156: frames dropped while a queued
1033
1091
  `skip` has not been written yet must still be reported — a 46-frame ledger where every frame is either delivered or
1034
1092
  accounted for). The coalescer's own rules (window merge / flush on the size cap /
package/README.md CHANGED
@@ -180,11 +180,20 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
180
180
  被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间
181
181
  列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而
182
182
  列表还停在错误态,看着像好了其实没好。
183
+ **重连被拒时要说原因**(`D164`):服务端主动拒绝这条流时会发一帧 `event: error`
184
+ 带 `message`(最典型的是长流配额满,见「已知限制」——那句话里写着该怎么办);
185
+ 客户端现在把它**显示在活动条上**(「重连被拒:…」),而不是丢掉。原先只置
186
+ `closed`,于是「拒绝 → 回到原位」与「点了没反应」在界面上完全不可区分,用户只能反复点。
187
+ 重连成功(或重新建流)时这条原因清除。
188
+ 断开时右侧那句提示是**折叠头里的单行小字**,所以刻意写短(2026-10-08 用户反馈
189
+ 「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点
190
+ 「重连」」——成因、上限与 `MaxSessions` 那一串写在本页,不塞进界面。
183
191
  - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
184
192
  左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
185
193
  免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
186
- `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到 7~8 个时给一条软提示
187
- (浏览器同源并发长连接有限制),超过 **8 个**按钮置灰并提示上限。点 `聚合日志`
194
+ `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到第 3 个时给一条软提示,
195
+ 超过 **3 个**按钮置灰并提示上限(**上限怎么来的见「已知限制」的浏览器同源连接预算**,
196
+ 操作条上也写着那句说明)。点 `聚合日志`
188
197
  进入聚合视图:直接复用 Compose 项目视图那套聚合日志(每容器一条 `/logs/stream`,
189
198
  按 `[service]` / 容器名前缀混流,带过滤与自动滚动),返回即退出选择态并清空。
190
199
  聚合视图的内容控制与**单容器日志完全对齐**(文本过滤 + 级别门槛 + `⬇ .log` / `⬇ .md`
@@ -195,14 +204,14 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;设置 →
195
204
  列表刷新后已消失的容器按 id 自动剔除。
196
205
  - **按条件一键选中**(多选态):操作条第二行给出一排条件 chip(`全部可见 / 不健康 /
197
206
  需关注 / 已停止`,以及有勾选后的 `同镜像 / 同项目`),计数**按剩余名额截断**——
198
- chip 上写 8 就真的会选中 8 个;超出上限的数量在 title 与结果提示里如实说明。
207
+ chip 上写 3 就真的会选中 3 个;超出上限的数量在 title 与结果提示里如实说明。
199
208
  条件只在**当前筛选结果**里生效(先搜索/筛状态再一键选中)。
200
209
  - **容器卡片**:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 /
201
210
  compose,值等宽、可省略)+ 一排图标操作按钮,**按「查看 / 变更」两组用竖线分隔**:
202
211
 
203
212
  | 组 | 按钮 | 说明 |
204
213
  | --- | --- | --- |
205
- | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用 |
214
+ | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用。**终端按钮跑 `docker exec -it` 交互式 shell,不受 `allowExec` 门控**(那条开关只管本插件的一次性 exec 通道,见「已知限制」) |
206
215
  | 变更 | 启停、重启、删除 | 按破坏性递增排列;只在 `allowMutations` 开启后可用,未开启时整组置灰 |
207
216
 
208
217
  删除额外做了两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。
@@ -510,7 +519,7 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
510
519
  | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
511
520
  | `allowMutations` | false | 允许**变更操作**:容器 start / stop / restart / remove、镜像删除 / dangling 清理 / 拉取(面板按钮与 `docker_action`、`docker_image_remove`、`docker_image_prune`、`docker_image_pull` 工具;关闭时 `/action`、`/images/remove`、`/images/prune`、`/images/pull/stream` 返回 403,对应工具不注册) |
512
521
  | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效;两个能力各点一次后,面板组顶部会给一个「复制全部(N 条)」(仍 pending 的命令按发起顺序换行连接),终端粘贴一次即可全部解锁(各自探测周期内先后生效,不承诺同一时刻)。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 `Sec-Fetch-Site: same-origin`),而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
513
- | `allowExec` | false | 允许一次性 `docker exec`(面板 exec 输入与 `docker_exec` 工具;关闭时 `/exec` 返回 403) |
522
+ | `allowExec` | false | 允许**一次性** `docker exec`(**容器详情抽屉**「概览」页签里的一次性命令框,与 `docker_exec` 工具;关闭时 `/exec` 返回 403)。**不管**卡片「终端」按钮的 `docker exec -it` 交互式 shell——那条由 tty 承载(见「已知限制」) |
514
523
  | `execTimeoutSec` | 30 | exec 默认超时秒数(1~120) |
515
524
  | `pollIntervalSec` | 5 | 面板统计刷新间隔秒数(1~60) |
516
525
  | `logTailDefault` | 200 | 日志默认尾部行数(1~5000) |
@@ -719,6 +728,10 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
719
728
  未开启时,`/exec` 返回 403,`docker_exec` 工具同样不注册。两个开关互相
720
729
  独立,必须在设置卡片由用户显式打开。读取类路由(`/logs/stream`、
721
730
  `/stats/stream`、`/events/stream`、`/images/inspect`)不受这两个开关影响。
731
+ **`allowExec` 只管本插件的「一次性」exec 通道**(`/exec` 路由、`docker_exec`
732
+ 工具、容器详情抽屉「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是 `docker exec -it`
733
+ 交互式 shell,由 tty 承载、不经过这两个开关**,只读模式下也可用(见下面
734
+ 「已知限制」里的边界说明:它不是漏了门,是刻意留的进入通道)。
722
735
  2. **破坏性操作要复述后果**。`remove` 映射为 `docker rm`(**不带 `-f`**),
723
736
  agent 公告要求执行前向用户确认目标容器;运行中容器会报错并附
724
737
  「容器仍在运行:先停止再删除」的提示,不会静默强删。
@@ -755,6 +768,16 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
755
768
  不带 `-i` / `-t`),不能跑 vim / top / 交互式 shell,也不能喂 stdin 做
756
769
  对话。交互排障请到 tty 面板执行 `docker exec -it <容器> sh`(本机与 SSH
757
770
  目标都可以)。
771
+ - **`allowExec` 不是「进不了容器」的闸**:卡片「终端」按钮(`docker exec -it
772
+ '<容器>' sh`)经 tty 的 `ttyTerminal` 承载,**不检查 `allowExec`**,只读模式下
773
+ 也照常可用——exec 被关时面板给的提示原文就是这么说的(「打开『允许 exec』,或
774
+ 直接复制卡片上的 exec 命令到终端面板交互式进入容器」)。这是刻意的:只读巡检时
775
+ 进容器看现场是最常用的一条路径,闸门管的是**本插件自己**那条一次性通道
776
+ (`/exec` 与 `docker_exec`:改本地状态、可被 agent 工具批量调用),交互式 shell
777
+ 归 tty(用户主动操作、它自己才是 PTY 的所有者)。**代价要说清**:装上 tty 之后,
778
+ `allowExec` 对**页内脚本**(第三方插件的 client 半体 / XSS)本来就不是边界——它们
779
+ 走 `ttyTerminal.open` 或 WS `spawn` 帧就能跑任意命令,比本插件的一次性 exec 还强。
780
+ 这一层的威胁模型边界见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么)。
758
781
  - **事件流有断线窗口**:`docker events` 是「从现在开始」的推流,浏览器断线重连期间
759
782
  发生的事件服务端已经推过、不会补发。客户端用「重连成功后先做一次全量列表刷新」
760
783
  来补偿(状态对齐,不是把事件补回来);活动条里缺的那几条只能靠刷新后的最终状态
@@ -768,6 +791,26 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
768
791
  SSH 长流会占住连接池中的该连接(busy),同一主机上的其它命令复用同一条连接——
769
792
  但**不是互不影响**:通道额度是共享的,见下一条。**统计流不会自然结束**,关闭必须
770
793
  由前端主动断 `EventSource`。
794
+ - **浏览器的同源连接预算是 4 条常驻流(这是聚合上限的真正依据,D165)**:
795
+ 宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对**每个源**只放这么几条 TCP,而
796
+ **每条 `EventSource` 独占一条连接、且不会主动释放**。吃满之后,同一个页面里
797
+ **所有**普通请求(列表刷新、`/api/*`、会话加载……)一起排队——用户看到的是
798
+ **整个 DSH 页面卡住**,不只是 docker 面板。
799
+ 实测(2026-10-08,真 Chrome + 真宿主,页内连打 10 次普通请求、2 秒超时):
800
+
801
+ | 常驻流 | 页内请求 | 进程外请求(同一时刻) |
802
+ |---|---|---|
803
+ | 0 条 | 10/10 成功,中位 27ms | 26ms |
804
+ | 4 条 | 8/8 成功,中位 25ms | — |
805
+ | **5 条** | **0/10 全部超时** | **41ms(正常)** |
806
+
807
+ 最后一列是关键:**堵在浏览器,不在宿主**。所以常驻流预算取 **4 条**;面板停在容器页时
808
+ 活动条的事件流恒开(占 1 条),聚合日志因此只能用 **3 条**——这就是聚合上限
809
+ (`PICK_MAX = 4 − 1`)的由来,操作条上写着同一句说明。
810
+ **这不是「性能建议」而是硬约束**:超了不是「慢一点」,是同页面所有请求一起失效。
811
+ 想自己复核:按上表形状在 devtools 里连开 `new EventSource('/api/dsh-docker/events/stream?target=<目标>')`
812
+ 到第 5 条,再打一次普通请求即可看到它挂住;关掉就恢复。
813
+ (WebSocket **不**占这份预算——tty 终端那条 WS 与 4 条 SSE 共存时页面仍正常,实测过。)
771
814
  - **SSH 目标上的通道额度是共享的(`MaxSessions`)**:一个目标只维持**一条** TCP 连接,
772
815
  所有长流与短命令共用这条连接上的通道,而 OpenSSH 的 `MaxSessions` 默认只有 10。
773
816
  长流(日志 / 统计 / 事件)会一直占到用户关掉面板为止,聚合日志还能一次占 8 条——
@@ -786,7 +829,13 @@ abort)、客户端断开静默中止。各自只差执行器与结束原因:
786
829
  宿主日志留一行 `通道额度已满:已重建连接重试`。之所以必须重建:这条连接**不会自己
787
830
  恢复**(中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒绝,而 sshd 在子进程仍活着
788
831
  时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连
789
- (日志流按 `D133` 用 `tail=0` 续尾)。**根治办法在 sshd 一侧**:把那台主机的
832
+ (日志流按 `D133` 用 `tail=0` 续尾);③ **中止长流时插件自己的账当场结清**(`D164`)
833
+ —— `AbortSignal` 触发时除了向远端 `KILL` + 关通道,还会**直接结束这条流**,不再等远端
834
+ 回话。原先只等远端的 `close` 事件:远端不确认(上面那种被拒的 KILL,或通道静默)时,
835
+ 那条流的 `busy` 计数**永远不会减**,累积到 8 之后该目标的每条长流都被配额拒绝,
836
+ 且没有任何自愈路径(现场:248 上恒满 8/8,活动条点「重连」毫无反应)。同一类坑
837
+ `run()` 在 `D112` / `D161` 已经踩过两次,现在三条收尾路径统一成「自己 settle」。
838
+ **根治办法在 sshd 一侧**:把那台主机的
790
839
  `MaxSessions` 调大 —— 例如 `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行
791
840
  `MaxSessions 50`,再 `sshd -t && systemctl reload ssh`(reload 只影响新连接)。
792
841
  - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,
@@ -982,7 +1031,7 @@ loopback 403(含三条流路由)、容器名 / 镜像引用注入尝试被
982
1031
  卡片 key 等于命名空间 `docker`、找不到宿主侧边栏时安静降级且卸载可重复调用,ttyConnbar 集成的四条路径(连接簿名命中 / host:port 命中 / 未配置主机不加按钮 / tty 未安装静默跳过),FOLLOW 的 SSE 订阅与「回到底部」交互(静态断言),**镜像详情 / 拉取流 / 删除 / prune 入口**、**统计 FOLLOW + sparkline 钩子**、**Compose 分组与聚合日志**、**「活动」条装配 + 事件环形缓冲 / 动作标签 / 防抖**(纯逻辑经 `__events` 测试缝),以及侧边栏折叠态(`data-sidebar-collapsed`)隐藏入口标签的样式规则。
983
1032
  需要真 daemon 的验证走下面的手工清单。
984
1033
 
985
- `test/logs-stream.test.ts`(37 例,随根 `pnpm test` 跑)覆盖日志实时流的四层:
1034
+ `test/logs-stream.test.ts`(例数不写死,`vitest run` 的输出是唯一真相源)覆盖日志实时流的四层:
986
1035
  `logsStream` 的 argv 构造与 `assertRef` 白名单、SSE 帧的单行 JSON 封装(换行 /
987
1036
  多字节)、本地流生命周期(假 spawn:跨 chunk 多字节、SIGTERM→SIGKILL 阶梯、
988
1037
  close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳过、以及
@@ -990,6 +1039,8 @@ close resolve、spawn error)与 SSH 长流的 busy 计数配对 / sweeper 跳
990
1039
  **`tail=0` 只跟随不补历史**(D133)、**背压不重写已写过的帧**与**积压时丢最旧 + `skip` 通知、
991
1040
  不掐流**(D153)、**skip 账目精确**(D156:skip 排队期间再丢弃,两段缺口都要报——
992
1041
  46 块逐块对账「每一帧要么送达、要么被 skip 记账」)、
1042
+ **中止长流后 `busy` 必须归零**(D164:远端不确认关通道、累积 8 次都不会把目标钉死,
1043
+ 且收尾后的分片不再投递)、
993
1044
  以及**分片合帧**(D151:十个分片只出一个 `line` 帧;未到窗口就收尾时 `end` 仍排在
994
1045
  这些行之后)。合帧器本身的规则(窗口合并 / 攒满即推 / flush 幂等 / dispose 停表)在
995
1046
  `test/sse-coalesce.test.ts`(6 例)。
@@ -1595,6 +1595,15 @@
1595
1595
  .dk_chipQuiet { border-color: var(--dk-border); color: var(--dk-label-3); }
1596
1596
  .dk_pickNotice { margin-left: auto; }
1597
1597
 
1598
+ /*
1599
+ * 上限的来由(D165):它是浏览器同源连接预算量出来的,不是随手定的数字。
1600
+ * 与 notice 共用行尾——notice 是「刚发生的事」(选择结果),排在更靠右的位置。
1601
+ */
1602
+ .dk_pickBudget { color: var(--dk-label-3); }
1603
+ /* 只有它一个时贴右,与 notice 同位置(两个都在时 notice 更靠右) */
1604
+ .dk_pickBudget:last-child { margin-left: auto; }
1605
+ .dk_pickBudget + .dk_pickNotice { margin-left: var(--dk-gap-md); }
1606
+
1598
1607
  .dk_pickCount {
1599
1608
  font-size: 12px;
1600
1609
  line-height: 18px;
@@ -62,10 +62,11 @@ const I18N_ZH = {
62
62
  'status.restarting': '重启中',
63
63
  'status.removing': '删除中',
64
64
  'status.unknown': '未知',
65
- 'hint.pickMaxLocal': '最多 {max} 个容器,浏览器并发长连接有限制',
66
- 'hint.pickMaxSsh': '最多 {max} 个容器(SSH 目标上一条连接要同时装实时流与刷新等短命令)',
67
- 'hint.pickMany': '连接数较多,浏览器并发长连接有限制',
65
+ 'hint.pickMaxLocal': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
66
+ 'hint.pickMaxSsh': '最多 {max} 个容器——再多会占满浏览器的同源连接,整个页面都会卡',
67
+ 'hint.pickMany': '已接近上限,再选下去整个页面会变卡',
68
68
  'hint.pickAtLeastTwo': '至少选择 2 个容器',
69
+ 'hint.pickBudget': '浏览器每个站点最多同时保持 4 条实时流(含活动条的事件流),再多页面就会卡住',
69
70
  'option.presetAll': '全部可见',
70
71
  'status.unhealthy': '不健康',
71
72
  'status.attention': '需关注',
@@ -361,7 +362,8 @@ const I18N_ZH = {
361
362
  'status.backToListRefresh': ',列表回到 AUTO REFRESH / 手动刷新;活动条上点「重连」可重新订阅',
362
363
  'btn.reconnectEvents': '重连',
363
364
  'hint.reconnectEvents': '重新打开容器事件流(docker events);列表刷新按钮也会一并重连',
364
- 'hint.eventsClosedNoAuto': '事件流断开后不会自己回来(EventSource 只在连接层抖动时自愈;docker events 进程退出属于终止态)——点它重连,或点右上角刷新列表',
365
+ 'hint.eventsClosedNoAuto': '不会自动恢复,点「重连」',
366
+ 'hint.eventsReconnectRefused': '重连被拒:{message}',
365
367
  'hint.conversationHidden': ' · 会话在面板后面:关掉或最小化面板/终端即可看到',
366
368
  'msg.copied': '已复制:',
367
369
  'error.copyManual': '复制失败,请手动执行:',
@@ -481,7 +483,9 @@ const I18N_ZH = {
481
483
  'field.execTimeout': 'exec 超时(秒)',
482
484
  'section.capabilities': '能力开关(默认关闭)',
483
485
  'check.allowMutations': '允许变更操作(容器启停删、镜像拉取 / 删除 / 清理)',
484
- 'check.allowExec': '允许 exec(在容器内执行命令)',
486
+ // 限定到「一次性」通道:旧文案「(在容器内执行命令)」会被读成「exec 整体要授权」,
487
+ // 而卡片「终端」按钮的 docker exec -it 不受它管(D163)。设置卡片是用户第一眼看到的地方。
488
+ 'check.allowExec': '允许一次性 exec(面板命令框与 docker_exec;交互式进容器不由它管)',
485
489
  'hint.socketRoot': 'docker socket 等价于目标主机的 root 权限。开启后,浏览器面板与 agent 都能执行对应操作,请只在可信环境下打开。',
486
490
  'hint.capabilityNotGranted': '⚠ 宿主尚未授权:这两个开关现在打不开,点它会给出一条就地确认的命令(免重启)。关掉它们随时可用。',
487
491
  'badge.notEffective': '未生效:未获宿主授权',
@@ -549,10 +553,11 @@ const I18N_EN = {
549
553
  'status.restarting': 'Restarting',
550
554
  'status.removing': 'Removing',
551
555
  'status.unknown': 'Unknown',
552
- 'hint.pickMaxLocal': 'At most {max} containers; browsers limit concurrent long-lived connections',
553
- 'hint.pickMaxSsh': 'At most {max} containers (a single SSH connection must also carry live streams and short refresh commands)',
554
- 'hint.pickMany': 'Many streams — browsers limit concurrent long-lived connections',
556
+ 'hint.pickMaxLocal': 'At most {max} containers — more would exhaust the browser\'s per-origin connections and stall the whole page',
557
+ 'hint.pickMaxSsh': 'At most {max} containers — more would exhaust the browser\'s per-origin connections and stall the whole page',
558
+ 'hint.pickMany': 'Close to the limit — selecting more will slow the whole page down',
555
559
  'hint.pickAtLeastTwo': 'Select at least 2 containers',
560
+ 'hint.pickBudget': 'A browser keeps at most 4 live streams per origin (including the Activity strip\'s event stream); more will stall the page',
556
561
  'option.presetAll': 'All visible',
557
562
  'status.unhealthy': 'Unhealthy',
558
563
  'status.attention': 'Needs attention',
@@ -848,7 +853,8 @@ const I18N_EN = {
848
853
  'status.backToListRefresh': ', the list returns to AUTO REFRESH / manual refresh; hit “Reconnect” on the activity bar to resubscribe',
849
854
  'btn.reconnectEvents': 'Reconnect',
850
855
  'hint.reconnectEvents': 'Reopen the container event stream (docker events); the list refresh button reconnects it too',
851
- 'hint.eventsClosedNoAuto': 'A closed event stream does not come back on its own (EventSource only self-heals connection blips; the docker events process exiting is terminal) — click to reconnect, or hit refresh in the top right',
856
+ 'hint.eventsClosedNoAuto': 'Will not recover on its own — click Reconnect',
857
+ 'hint.eventsReconnectRefused': 'Reconnect refused: {message}',
852
858
  'hint.conversationHidden': ' · the session is behind this panel: close or minimise the panel/terminal to see it',
853
859
  'msg.copied': 'Copied: ',
854
860
  'error.copyManual': 'Copy failed, run it manually: ',
@@ -968,7 +974,7 @@ const I18N_EN = {
968
974
  'field.execTimeout': 'exec timeout (seconds)',
969
975
  'section.capabilities': 'Capability switches (off by default)',
970
976
  'check.allowMutations': 'Allow changes (start/stop/remove containers, pull / remove / prune images)',
971
- 'check.allowExec': 'Allow exec (run commands inside containers)',
977
+ 'check.allowExec': 'Allow one-shot exec (panel command box and docker_exec; interactive container access is not gated by it)',
972
978
  'hint.socketRoot': 'The docker socket is equivalent to root on the target host. Once enabled, both the browser panel and the agent can run these operations — only turn it on in a trusted environment.',
973
979
  'hint.capabilityNotGranted': '⚠ Not granted by the host: these two switches cannot be turned on right now — clicking one gives you a command to confirm in place (no restart). Turning them off always works.',
974
980
  'badge.notEffective': 'Not effective: not granted by the host',
@@ -1553,17 +1559,42 @@ const ICON_VOLUME =
1553
1559
  * 不进 settings,也不新增任何服务端字段,刷新后按 id 对账即可。
1554
1560
  */
1555
1561
 
1556
- /** 超过这个数就给「浏览器并发长连接有限制」的软提示(仍可聚合)。 */
1557
- const PICK_SOFT_MAX = 6
1558
- /** 硬上限:再多就置灰——同源长连接排队后,聚合流反而会「看起来卡住」。 */
1559
- const PICK_MAX = 8
1562
+ /*
1563
+ * 浏览器**同源并发连接**预算(D165,2026-10-08 实测)。
1564
+ *
1565
+ * 为什么这是硬约束而不是「性能建议」:宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对
1566
+ * 每个源只放这么多条 TCP;而**每条 EventSource 独占一条连接、且永不主动释放**。吃满之后
1567
+ * 同一个页面里**所有**普通请求(`/api/*`、列表刷新、会话加载……)全部排队——用户看到的是
1568
+ * 「整个 DSH 页面卡住」,不只是 docker 面板。
1569
+ *
1570
+ * 实测(真 Chrome + test profile 宿主,页内连打 10 次普通请求、2s 超时):
1571
+ * 常驻流 4 条 → 6/6 成功、中位 29ms;常驻流 5 条 → **0/6 全部超时**。
1572
+ * 宿主侧同时刻实测 41ms(进程外请求正常)——**堵在浏览器,不在宿主**。
1573
+ * 所以预算是 4 条常驻流,且要**连同事件流一起算**:面板停在容器页时事件流恒开
1574
+ * (见下方 events effect),聚合日志只能用掉剩下的 3 条。
1575
+ *
1576
+ * 这个数**不是猜的**,是量出来的;改它之前请重跑那组实验(README「已知限制」有复现步骤)。
1577
+ */
1578
+ const SAME_ORIGIN_STREAM_BUDGET = 4
1579
+ /** 容器页常开的那条事件流(活动条)。它在预算里,不能被聚合日志挤掉。 */
1580
+ const RESERVED_STREAMS = 1
1581
+ /** 聚合日志可达的硬上限 = 预算 − 事件流。 */
1582
+ const PICK_MAX = SAME_ORIGIN_STREAM_BUDGET - RESERVED_STREAMS
1560
1583
  /**
1561
- * SSH 目标的硬上限:一个目标只维持**一条** TCP 连接,通道额度(OpenSSH `MaxSessions`
1562
- * 默认 10)要同时装下聚合流、统计流、事件流与「刷新列表」这类短命令。留出余量之后
1563
- * 聚合最多 6 条——正好等于软提示线,于是 SSH 上不再有「可点但已偏多」的区间。
1564
- * 本地目标走子进程,没有这个约束,仍是 {@link PICK_MAX}。
1584
+ * 超过这个数就给软提示(仍可聚合)。
1585
+ *
1586
+ * 与硬上限之间刻意**留一档**:硬上限是「再点就连页面都卡」的红线,而用户在 3 条时
1587
+ * 已经在消耗大部分预算——给一句提醒,但不禁用。
1565
1588
  */
1566
- const PICK_MAX_SSH = 6
1589
+ const PICK_SOFT_MAX = 2
1590
+ /**
1591
+ * SSH 目标为什么不再单独降一档:真正卡住页面的是**浏览器同源预算**(上面那条,与目标
1592
+ * 类型无关),而不是 SSH 的 `MaxSessions`。旧代码把上限按「SSH 要同时装实时流与短命令」
1593
+ * 设成 6——那条例由针对的是**宿主侧**通道额度,与浏览器这一层是两笔独立的账,写成同一个
1594
+ * 数字正好把两者都算错(6 > 实测安全值)。现在两者各归各:浏览器预算管上限,
1595
+ * `MaxSessions` 由宿主侧的 `MAX_STREAMS_PER_TARGET` 管。
1596
+ */
1597
+ const PICK_MAX_SSH = PICK_MAX
1567
1598
 
1568
1599
  /**
1569
1600
  * 目标下拉里要不要列「+ 连接本机」那一项。
@@ -1628,8 +1659,8 @@ function pickReconcile(ids, containers) {
1628
1659
 
1629
1660
  /**
1630
1661
  * 「按条件一键选择」的预设(0.15.0)。
1631
- * 为什么需要:跨 30+ 容器里挑 4 个不健康的,手动点既慢又容易漏;而聚合日志的上限是
1632
- * 8 条流,所以条件选择必须能**按上限截断并如实告知略过了几个**。
1662
+ * 为什么需要:跨 30+ 容器里挑几个不健康的,手动点既慢又容易漏;而聚合日志有上限
1663
+ * (见 {@link PICK_MAX}),所以条件选择必须能**按上限截断并如实告知略过了几个**。
1633
1664
  * `needsBase` 的项要先有勾选(拿第一个勾选的容器当基准:同镜像 / 同项目)。
1634
1665
  */
1635
1666
  const PICK_PRESETS = [
@@ -1967,6 +1998,36 @@ function canReconnectEvents(status) {
1967
1998
  return status === 'closed'
1968
1999
  }
1969
2000
 
2001
+ /**
2002
+ * 从事件流的 `error` 事件里解出**服务端给的原因**(D164)。
2003
+ *
2004
+ * 为什么必须解出来:这条流的 `event: error` 带着 data 时(服务端主动拒绝 / 收尾),原文案
2005
+ * 是宿主侧唯一一句可执行的话——例如长流配额满时的「关掉部分实时跟随、把聚合容器数减到 6 个
2006
+ * 以内」。老代码把它整个丢掉、只置 `closed`,于是**点「重连」看起来毫无反应**:拒绝 →
2007
+ * 状态回 `closed` → 界面与点击前逐像素相同,用户只能反复点。统计流一直是对的
2008
+ * (`setStatsError(message)`),这里补齐同一套语义。
2009
+ *
2010
+ * 连接层错误(无 data 的普通 Event)不是「服务端说了什么」,返回空串——那种情况
2011
+ * EventSource 会自己重连,不该冒充成一次被拒绝的重连。
2012
+ *
2013
+ * 抽成纯函数而不是写在组件闭包里:离线冒烟的 React 桩把 `useState` 冻在初值,组件里
2014
+ * 遍历渲染树取不到真实调用结果,判定必须能直接驱动(与 canReconnectEvents 同一个理由)。
2015
+ */
2016
+ function eventsErrorMessage(raw) {
2017
+ if (raw === null || raw === undefined) return ''
2018
+ const data = raw.data
2019
+ if (typeof data !== 'string' || data === '') return ''
2020
+ try {
2021
+ const payload = JSON.parse(data)
2022
+ if (payload !== null && typeof payload === 'object' && typeof payload.message === 'string') {
2023
+ return payload.message
2024
+ }
2025
+ } catch {
2026
+ /* 畸形载荷:按「没有可读原因」处理,不要拿半截 JSON 当文案 */
2027
+ }
2028
+ return data
2029
+ }
2030
+
1970
2031
  /** 新事件放最前(活动条按时间倒序读),超出上限丢最旧。 */
1971
2032
  function pushEvent(list, event, limit) {
1972
2033
  const next = [event, ...list]
@@ -5480,6 +5541,19 @@ window.__ModuleLoader__.load({
5480
5541
  * `unsupported` 刻意不给这个按钮:那个环境根本没有 EventSource,重连必然还是失败。
5481
5542
  */
5482
5543
  const canReconnect = canReconnectEvents(props.status)
5544
+ /*
5545
+ * 断开时右侧那句提示(D164):**被拒过就优先说原因**。「事件流已断开」只说状态,
5546
+ * 用户据此以为点一下就好;而真正拦着重连的是服务端那句话(例如长流配额满),
5547
+ * 不显示出来,「重连」按钮点多少次都像假的。
5548
+ *
5549
+ * 文案要**短**(2026-10-08 用户反馈「啰嗦又生硬」):这里是折叠头右侧的单行小字,
5550
+ * 长句会挤成多行墙。状态行左边已经写着「事件流已断开」,这句不再复述,
5551
+ * 只说「不会自愈 + 点哪」;成因与上限写进 README,不塞进界面。
5552
+ */
5553
+ const refused = typeof props.refused === 'string' ? props.refused : ''
5554
+ const hintText = canReconnect
5555
+ ? (refused === '' ? t('hint.eventsClosedNoAuto') : t('hint.eventsReconnectRefused', { message: refused }))
5556
+ : (events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length }))
5483
5557
  return jsxs('div', { className: 'dk_activity', 'data-open': open ? '1' : '0', children: [
5484
5558
  /*
5485
5559
  * 折叠按钮与「重连」是**兄弟**,不是嵌套——按钮里嵌按钮既不是合法 HTML,
@@ -5497,10 +5571,8 @@ window.__ModuleLoader__.load({
5497
5571
  jsx('span', { className: 'dk_activityState', 'data-state': props.status ?? '', children: props.statusText ?? '' }),
5498
5572
  jsx('span', { className: 'dk_headerSpacer' }),
5499
5573
  // 断开时右侧那句「暂无事件」是误导(不是「没动静」,是「听不见了」),
5500
- // 换成一句说明为什么需要手动重连
5501
- jsx('span', { className: 'dk_hint', children: canReconnect
5502
- ? t('hint.eventsClosedNoAuto')
5503
- : (events.length === 0 ? t('list.noEvents') : t('list.recentEvents', { recent: recent.length, total: events.length })) }),
5574
+ // 换成一句说明为什么需要手动重连;**被拒过就改成说原因**(D164)
5575
+ jsx('span', { className: 'dk_hint', children: hintText }),
5504
5576
  jsx('span', { className: 'dk_activityChevron', dangerouslySetInnerHTML: { __html: ICON_CHEVRON } }),
5505
5577
  ],
5506
5578
  }, 'head'),
@@ -5570,6 +5642,7 @@ window.__ModuleLoader__.load({
5570
5642
  onClick: props.onClear,
5571
5643
  children: t('btn.clear'),
5572
5644
  }, 'clear') : null,
5645
+ props.budget === undefined ? null : jsx('span', { className: 'dk_hint dk_pickBudget', children: props.budget }),
5573
5646
  props.notice === '' ? null : jsx('span', { className: 'dk_hint dk_pickNotice', children: props.notice }),
5574
5647
  ] }) : null,
5575
5648
  ] })
@@ -5799,6 +5872,12 @@ window.__ModuleLoader__.load({
5799
5872
  /** 面板是否可见(S3):折叠的 tab 不建流;模态 / dock 形态恒为 true。 */
5800
5873
  const active = usePanelActive()
5801
5874
  const [eventsStatus, setEventsStatus] = useState('')
5875
+ /**
5876
+ * 上一次被服务端**拒绝**的原因(D164):配额满这类拒绝在界面上必须看得见,
5877
+ * 否则「点重连 → 被拒 → 回到 closed」在视觉上与「点了没反应」不可区分。
5878
+ * 重连**成功**时清空(那说明这个原因已经过去了)。
5879
+ */
5880
+ const [eventsRefused, setEventsRefused] = useState('')
5802
5881
  /**
5803
5882
  * 手动重连令牌(D160):事件流断开后是**终止态**(EventSource 只在连接层抖动时自愈;
5804
5883
  * `docker events` 进程退出 / 服务端发 `end` 帧都会走 close(),之后没有东西会再开流)。
@@ -6458,6 +6537,9 @@ window.__ModuleLoader__.load({
6458
6537
  */
6459
6538
  let hadOpen = sameTarget && (eventsOpenedRef.current.opened || manualReconnect)
6460
6539
  setEventsStatus('connecting')
6540
+ // 每次重新建流都先把上一条拒绝原因清掉(D164):这是「又试了一次」,不是「一直坏着」;
6541
+ // 若这次仍被拒,onError 会立刻把它写回来。
6542
+ setEventsRefused('')
6461
6543
  const debounced = makeDebounced(EVENTS_REFRESH_DEBOUNCE_MS, () => {
6462
6544
  const load = loadContainersRef.current
6463
6545
  if (load !== null) load()
@@ -6486,8 +6568,15 @@ window.__ModuleLoader__.load({
6486
6568
  close()
6487
6569
  }
6488
6570
  const onError = (raw) => {
6489
- if (typeof raw.data === 'string' && raw.data !== '') {
6571
+ /*
6572
+ * 服务端主动说了原因(带 data 的 error 帧):**显式收尾**,并把那句话留下来
6573
+ * (D164)。老代码把它丢掉、只置 `closed`,于是「重连」点了看起来毫无反应——
6574
+ * 拒绝 → 回 `closed` → 界面与点击前一样。统计流一直是读出 message 的。
6575
+ */
6576
+ const message = eventsErrorMessage(raw)
6577
+ if (message !== '') {
6490
6578
  setEventsStatus('closed')
6579
+ setEventsRefused(message)
6491
6580
  close()
6492
6581
  return
6493
6582
  }
@@ -6499,6 +6588,8 @@ window.__ModuleLoader__.load({
6499
6588
  es.addEventListener('error', onError)
6500
6589
  es.onopen = () => {
6501
6590
  setEventsStatus('open')
6591
+ // 连上了就说明上次那条拒绝原因已经过去(D164):留着会让人以为现在还坏着
6592
+ setEventsRefused('')
6502
6593
  if (hadOpen) {
6503
6594
  // 重连补偿**不走事件驱动的那条防抖**(D93):debounced 是 500ms 尾沿防抖,
6504
6595
  // 每条事件都 schedule() 一次;容器多 + healthcheck 时事件持续 >2 条/秒,
@@ -7340,6 +7431,9 @@ window.__ModuleLoader__.load({
7340
7431
  info: pickInfo,
7341
7432
  presets: pickPresetList,
7342
7433
  max: pickMax,
7434
+ // 上限为什么是 3 得写在脸上(D165):它是量出来的浏览器连接预算,
7435
+ // 不写清楚,看起来就只是一个随手定的数字,用户只会觉得「怎么才 3 个」。
7436
+ budget: t('hint.pickBudget'),
7343
7437
  notice: pickNotice,
7344
7438
  onPreset: applyPreset,
7345
7439
  onClear: () => { setPickedIds([]); setPickNotice('') },
@@ -7386,6 +7480,7 @@ window.__ModuleLoader__.load({
7386
7480
  events,
7387
7481
  status: eventsStatus,
7388
7482
  statusText: eventsStatusText(),
7483
+ refused: eventsRefused,
7389
7484
  open: activityOpen,
7390
7485
  onToggle: () => setActivityOpen((value) => !value),
7391
7486
  // 只重开事件流,不顺带刷列表:列表有自己的 ⟳ 与 AUTO REFRESH,
@@ -8682,8 +8777,11 @@ window.__ModuleLoader__.load({
8682
8777
  }
8683
8778
  exports.__pick = {
8684
8779
  MAX: PICK_MAX,
8685
- /** SSH 目标的上限(= MAX 之外更紧的一档):一条连接要同时装实时流与短命令。 */
8780
+ /** SSH 目标的上限(D165 后与本地同值:卡页面的是浏览器同源预算)。 */
8686
8781
  SSH_MAX: PICK_MAX_SSH,
8782
+ /** 同源常驻流预算与其中留给事件流的名额(上限的推导依据,测试直接钉住)。 */
8783
+ BUDGET: SAME_ORIGIN_STREAM_BUDGET,
8784
+ RESERVED: RESERVED_STREAMS,
8687
8785
  PRESETS: PICK_PRESETS,
8688
8786
  presetCounts: pickPresetCounts,
8689
8787
  apply: pickApply,
@@ -8706,6 +8804,7 @@ window.__ModuleLoader__.load({
8706
8804
  timeText: eventTimeText,
8707
8805
  debounce: makeDebounced,
8708
8806
  canReconnect: canReconnectEvents,
8807
+ errorMessage: eventsErrorMessage,
8709
8808
  }
8710
8809
  /*
8711
8810
  * 同一类测试缝:总览的折叠逻辑与**正文渲染**都是纯函数(正文刻意写成普通函数而不是