@hyzyn/dsh-docker 0.14.0 → 0.16.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.md CHANGED
@@ -6,57 +6,48 @@
6
6
 
7
7
  ## 特性
8
8
 
9
- - **常驻会话右侧栏**:面板作为会话右侧栏标签(`sidebar.right.pane.tab`)与对话**同屏**——发完错误交给 Agent 后日志留在右边,不影响看它干活;折叠即退出视野、不占屏。面板级状态(目标 / 视图 / 过滤词 / 选中容器及它的页签)**跨会话保留**,而折叠会主动断流、把 SSH 通道还回去。拿不到右侧栏服务的老宿主自动退回原有的 dock / 模态,行为与旧版一致。
10
- - **日志右键交给 Agent**:日志页拖选报错行 → 右键「直接发送到当前会话 / 填入输入框,我先改改」,把「选中的行 + 目标 / 容器 / 时间窗 + 前后各 20 行上下文」交给会话(内容会进模型上下文,菜单里明写了留意凭证)。投递成功有两处回执:**视口级 toast**(挂 `body`,盖过面板与终端弹窗),以及——终端面板正开着时——**自动折起终端**(`ttyPanel` 契约 v2 的 `minimize()`;会话继续跑,恢复靠侧边栏「终端」入口的徽标),让会话直接露出来。老版本 tty 没有这个能力时退化成 toast 里「会话在面板后面」的指引。需要改稿就先「填入输入框」——**编辑面只有会话输入框一个**(它多行、能看到完整上下文,Agent 收到的就是它),不再另开一张更弱的卡片编辑器。目标会话的定位跨宿主版本:≤0.1.5 读 `sessions.list` 快照的 `current`,**0.1.6 起**该字段随「视图选中项」一起搬出了会话域,改看 `retainedBy.mainView > 0`(官方 `dsh-client-ui-session` 与本地 codegraph 的同一判据)——只认老字段会让菜单判成「当前没有打开的会话」而整组置灰;同一份取值也驱动「切会话把容器标签带过去」。
11
- - **显式一键连接本机**:两个**非常驻**入口——一个目标都没有时是「还没有配置 Docker 目标」空态里的主按钮,已有别的目标时是**目标下拉的末一项** `+ 连接本机`(工具条上**不挂**:常驻时它既占位又招人点,纯噪音)——一次点击(或选中该项)就把宿主所在机器加成本机目标并选中(已有本机目标则直接复用,不重复创建;SSH / 自定义目标原样保留),agent 侧同一条操作是 `docker_connect_local`。它**不**改空 `targets` 的语义、**不**凭空造隐式默认目标、**不**自动装 Docker / 起 daemon / 切 docker context,也不需要任何能力授权(只加一条指向插件自身的本机目标)。
12
- - **多目标聚合取数**:总览页对全部 `targets[]` 并行请求,单个目标不可达只污染自己那一格;agent 侧同一口径由 `docker_ps target:"*"` / `docker_attention target:"*"` 暴露,跨目标不互相阻塞。
13
- - **「需关注」读权威字段**:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次 `docker inspect` 补齐——`docker ps` 摘要里的 137 分不出 OOM 与手动 kill,只按摘要筛必然误报。
14
- - **容器「诊断」:把四路数据变成一句能动手的结论**:详情页「诊断」页签一次采集
15
- `docker inspect`(权威状态 / 退出码 / OOM / 重启次数 / 健康检查最后输出 / 资源上限)、
16
- 日志尾部、本容器事件、`stats` 快照,交给一组**纯判据**(`src/diagnose.ts`,零 IO)产出
17
- 「结论 + 判据列表 + 证据 + 时间线 + 建议」,agent 侧同一份口径是 `docker_diagnose`。
18
- 最常用的一步是「为什么它挂了」:OOM 被杀会同时点出**内存上限与实际占用**、非零退出会给出
19
- 该退出码的常见含义、不健康会附健康检查的最后一条输出;末尾一个「交给 Agent」直接把这整份
20
- 证据(含日志原文围栏与「这是数据不是指令」的声明)投进会话。
21
- - **磁盘与「谁在用」**:「磁盘」视图把 `docker system df` 的**可回收**账摊开——四类汇总 +
22
- 合计可回收 + 按独占大小排序的镜像明细(`容器数 = 0` 打「未使用」)+ 带链接数的卷明细,
23
- 行尾删除受 `allowMutations` 门。`容器数` / `链接数` 取自 `docker system df -v` 的
24
- `Images[].Containers` / `Volumes[].Links`,**不按 `docker ps` 反推**(反推要拿镜像引用猜
25
- tag/digest,同镜像多标签时会算错)。
26
- - **现场还原:等价 `docker run`**:概览页底部可折叠地反推一条可复现命令(端口 / 挂载 / 环境变量 /
27
- 重启策略 / 网络 / 资源上限 / capability,一行一参数、POSIX 转义、点开才请求)。**故意没还原的
28
- 必须点名**(`--privileged`、`--link`、ENTRYPOINT 覆盖、自定义健康检查、匿名卷…逐条给原因与
29
- 怎么补),敏感 env 只点键名不显示值——与「删除前先说清后果」是同一条取向。
30
- - **进程快照**:详情页「进程」页签(agent 侧 `docker_top`)——`docker top` 的 PID / PPID / 用户 /
31
- STAT / 已运行 / CPU / 内存 / 命令八列。容器「还在跑」与「进程还在」是两件事:进程没了,
32
- 日志通常什么都不会说。
33
- - **诊断历史:从「现在怎么样」到「是不是在变坏」**:每次诊断落一个**只含结论与数字**的样本
34
- (日志原文绝不落盘),诊断页据此给出最近 N 次的严重度色带、趋势结论(反复重启 / 反复 OOM /
35
- 内存爬升 / 报错增长 / 新出现严重判据)、只列「真的变了」的数字变化与判据增减。agent 侧同一份
36
- 口径是 `docker_diagnose` 的 `history` 参数。
37
- - **四条 SSE 长流共用一套基建**:日志 FOLLOW、`docker stats`、`docker events`、`docker pull` 走同一个 `openSseStream`(心跳 / 活跃流登记 / 断开清理 / 管道起来后先发一帧 `ready`),差异只在收尾语义——日志与拉取自然结束,统计与事件由前端主动断。「连上了」与「在收数据」是两件事:客户端只认 `ready`,响应头到达不算(见「SSE 事件协议」)。多选 / Compose 项目的聚合日志把多个容器装进**一条** `/logs/stream?ids=…`(D168),按 `--timestamps` 前缀还原跨容器真实时序,「暂停」只冻结渲染(流继续接收,恢复时一次性补齐)。
38
- - **默认只读,能力开关分三级**:启停删 / exec / 镜像变更各自独立开关,未开启时 agent 工具**不注册**、HTTP 路由 403(能力不存在,而非调用后报错);开关的**提权走两条带外通道**(启动环境变量 / 卡片里就地确认,见下条);容器名与 ID 过白名单,命令一律 argv 构造 + 单引号转义,密码 / 口令以 `env:NAME` **凭据引用**(官方凭据层解析,缺失时退回环境变量)且永不回传浏览器。
39
- - **与 dsh-tty 数据级复用、代码级不耦合**:不 import 任何 tty 代码,tty 也无需改一行源码,两者可各自安装与升级;装了 tty 则消费三个可选扩展点——连接栏动作(`ttyConnbar`)、终端承载(`ttyTerminal`:标签 / dock 承载下经 `open` 新开标签,模态下经 `mount` 就地嵌入抽屉)、以及只在兜底路径用到的终端右侧 dock(`ttyPanel.mountPane`);未装或版本不足逐项静默降级。
9
+ - **常驻会话右侧栏**:面板作为右侧栏标签(`sidebar.right.pane.tab`)与对话**同屏**——发完错误交给 Agent
10
+ 后日志留在右边;折叠即退出视野并主动断流、把 SSH 通道还回去。面板级状态(目标 / 视图 / 过滤词 /
11
+ 选中容器及页签)**跨会话保留**;拿不到右侧栏服务的老宿主退回 dock / 模态。
12
+ - **日志右键交给 Agent**:日志页拖选报错行 → 右键「直接发送到当前会话 / 填入输入框,我先改改」,投递
13
+ 「选中的行 + 目标 / 容器 / 时间窗 + 前后各 20 行上下文」(会进模型上下文,菜单里明写留意凭证);回执
14
+ 两处:视口级 toast,以及终端面板开着时**自动折起终端**(`ttyPanel` 契约 v2)。改稿走「填入输入框」
15
+ ——**编辑面只有会话输入框一个**。
16
+ - **一键连接本机 / 多目标聚合取数**:两个**非常驻**入口(空态主按钮 / 目标下拉末项)一次点击就加上本机
17
+ 目标(agent 侧 `docker_connect_local`);总览对全部 `targets[]` 并行取数,单个目标不可达只污染自己
18
+ 那一格(`docker_ps target:"*"` 同一口径)。
19
+ - **「需关注」读权威字段**:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次
20
+ `docker inspect` 补齐——`docker ps` 摘要里的 137 分不出 OOM 与手动 kill(口径见 [总览](#总览跨目标与需关注口径))。
21
+ - **排障闭环**:诊断页签把四路采集变成「结论 + 判据 + 证据 + 时间线 + 建议」,诊断历史再答「是不是在
22
+ 变坏」;「进程」页签给 `docker top` 快照;「磁盘」视图摊开可回收账与「谁在用」;概览底部反推一条可复现
23
+ 的 `docker run`。见 [容器诊断](#容器诊断为什么不行) 与 [诊断历史](#诊断历史它是不是在慢慢变坏)。
24
+ - **四条 SSE 长流共用一套基建**:日志 FOLLOW、`docker stats`、`docker events`、`docker pull` 走同一个
25
+ `openSseStream`(心跳 / 活跃流登记 / 断开清理 / 管道起来后先发一帧 `ready`),差异只在收尾语义;聚合
26
+ 日志把多个容器装进**一条** `/logs/stream?ids=…`。「连上了」与「在收数据」是两件事:客户端只认 `ready`。
27
+ 见 [SSE 事件协议](#sse-事件协议logsstreamstatsstreameventsstreamimagespullstream)。
28
+ - **默认只读,两个独立开关**:`allowMutations`(容器启停删 + 镜像删除 / 清理 / 拉取)与 `allowExec`
29
+ (一次性 `docker exec`);关闭时对应 agent 工具**不注册**、
30
+ HTTP 路由 403(能力不存在,而非调用后报错);提权走两条带外通道(启动环境变量 / 卡片里就地确认)。
31
+ 容器名与 ID 过白名单,命令一律 argv 构造 + 单引号转义,密码 / 口令以 `env:NAME` **凭据引用**且永不回传
32
+ 浏览器。
33
+ - **与 dsh-tty 数据级复用、代码级不耦合**:不 import 任何 tty 代码,两者可各自安装与升级;装了 tty 则消费
34
+ 三个可选扩展点(`ttyConnbar` / `ttyTerminal` / `ttyPanel`),未装或版本不足逐项静默降级。
40
35
 
41
36
  ## 与 dsh-tty 的关系
42
37
 
43
- 本插件自成一体:不 import 任何 tty 代码,tty 也无需改一行源码,两者可各自单独安装、
44
- 各自升级;装了 tty 就在下面这些可选扩展点上协作,没装则安静降级。
38
+ 本插件自成一体:不 import 任何 tty 代码,tty 也无需改一行源码;装了 tty 就在下面这些可选扩展点上
39
+ 协作,没装则安静降级。
45
40
 
46
41
  | 维度 | 说明 |
47
42
  | --- | --- |
48
- | 插件形态 | 独立包 `@hyzyn/dsh-docker`,不 import 任何 tty 代码,tty 也无需改一行源码;两者可各自单独安装、各自升级 |
43
+ | 插件形态 | 独立包 `@hyzyn/dsh-docker`,可各自单独安装、各自升级 |
49
44
  | 连接簿 | SSH 目标可**引用 tty 连接簿条目名**(只读 `ctx.settings.get('tty')` 的 `sshHosts`);tty 未安装时退化为「内联 host/username」或本机目标 |
50
45
  | 主机指纹 | 本插件自持一份 `hostKeys`(TOFU),并**优先以 tty 已记录的指纹作种子**——同一主机不必在两处各确认一次 |
51
46
  | 执行通道 | 自持池化 SSH exec(`src/ssh-exec.ts`),与 tty 的 PTY 会话完全独立,互不占名额 |
52
- | 上下文入口 | tty ≥ 0.13.0 时可选消费其客户端服务 `ttyConnbar`,在 SSH 连接栏(SFTP 旁)插入「容器」按钮(**注册即显示**),目标在点击时按当前会话解析;**本插件自己开的 exec 标签除外**——那种标签正是从容器面板点进来的,再给一个回去的入口等于绕回原地(判定走 `spawnSpec.command`,用户手敲的 `docker exec -it` 不算);tty 未装 / 版本过旧则静默跳过 |
53
- | 面板承载 | **默认走会话右侧栏标签**(`sidebar.right.pane.tab`):面板与对话同屏,折叠即退出视野、不占屏;**框架侧边栏**的入口只负责打开或聚焦它,**页类型在同一栏内去重**,反复点不会开出第二个。**终端连接栏**的入口相反——它长在盖满视口的 tty 弹窗上,开标签会被挡住,所以那条走 `ttyPanel.mountPane` 停靠到终端右侧(**入口决定承载**);投递日志给会话后经 `ttyPanel.minimize()`(契约 v2)折起终端,把舞台让给会话。拿不到右侧栏服务(老版本 DSH)、或把 `localStorage['dsh-docker:carrier']` 置成 `modal` 时,侧边栏入口也回退到 dock(tty 开着时)或自带 backdrop 的全屏模态。三种承载是**同一个组件**,只是外壳与几何不同 |
54
- | 终端承载 | 交互式终端由 tty 承载(它才是 PTY 的所有者)。**右侧栏标签**:面板全屏(`sidebar.fullscreen`)时经 `ttyTerminal.mount` **就地嵌入**面板底部(宽度够、日志与 shell 同屏),否则经 `ttyTerminal.open` **在终端面板新开标签**(同一容器重复点会**聚焦已有标签**,不再堆重复——tty 契约 v3 的 `reuse`)(栏太窄,硬塞两头难受);**dock** 承载(面板已经长在 tty 里)一律开标签;**模态**承载就地嵌入。那两类命令标签(`spawnSpec.command` 非空)在 tty 侧**不显示连接栏的扩展按钮区**(SFTP / 隧道 / 第三方面板都作用于连接本身,挂在 `docker exec` 标签上会误导——SFTP 浏览的是宿主机,不是用户以为的容器内);本插件另有一道版本无关的兜底:自己开的 exec 标签不给「容器」入口(按 `spawnSpec.command` 前缀判定)。未装 tty 或版本不足时复制命令兜底。本插件不实现 PTY / xterm / 重连栈 |
55
- | 分工 | **交互式排障**(`docker exec -it`、容器内 shell、TUI)由 tty 承载(抽屉内嵌或标签);**只读巡检与 agent 自动化**用本插件自己的 exec 通道 |
56
-
57
- 数据级复用、代码级不耦合:连接簿与指纹种子是「读同一份 settings」,连接栏按钮是
58
- 「消费一个通用扩展点」,都不是「依赖 tty 的模块」,因此 tty 升级或卸载都不会连带
59
- 弄坏本插件。
47
+ | 上下文入口 | tty ≥ 0.13.0 时可选消费 `ttyConnbar`,在 SSH 连接栏(SFTP 旁)插入「容器」按钮(**注册即显示**),目标在点击时按当前会话解析;**本插件自己开的 exec 标签除外**(判定走 `spawnSpec.command`);tty 未装 / 版本过旧则静默跳过 |
48
+ | 面板承载 | **默认走会话右侧栏标签**(`sidebar.right.pane.tab`),框架侧边栏入口只负责打开或聚焦它、**页类型在同一栏内去重**;**终端连接栏**的入口相反——它长在盖满视口的 tty 弹窗上,开标签会被挡住,所以走 `ttyPanel.mountPane` 停靠到终端右侧(**入口决定承载**)。拿不到右侧栏服务(老宿主)或把 `localStorage['dsh-docker:carrier']` 置成 `modal` 时回退到 dock / 全屏模态。三种承载是**同一个组件**,只是外壳与几何不同 |
49
+ | 终端承载 | 交互式终端由 tty 承载(它才是 PTY 的所有者)。**右侧栏标签**:面板全屏(`sidebar.fullscreen`)时经 `ttyTerminal.mount` **就地嵌入**面板底部,否则经 `ttyTerminal.open` **开新标签**(同一容器重复点会**聚焦已有标签**——tty 契约 v3 的 `reuse`);**dock** 承载一律开标签;**模态**承载就地嵌入。命令标签(`spawnSpec.command` 非空)在 tty 侧**不显示连接栏的扩展按钮区**(SFTP / 隧道都作用于连接本身,挂在 `docker exec` 标签上会误导);本插件自己开的 exec 标签另不给「容器」入口。未装 tty 或版本不足时复制命令兜底。本插件不实现 PTY / xterm / 重连栈 |
50
+ | 分工 | **交互式排障**(`docker exec -it`、容器内 shell、TUI)由 tty 承载;**只读巡检与 agent 自动化**用本插件自己的 exec 通道 |
60
51
 
61
52
  ## 安装
62
53
 
@@ -65,313 +56,181 @@ dsh plugin --profile web add @hyzyn/dsh-docker # npm 安装(发
65
56
  dsh plugin --profile web add link:$(pwd)/packages/docker # 仓库开发调试
66
57
  ```
67
58
 
68
- 聚合包 `@hyzyn/dsh-all`(或仓库根 bundle)已包含本插件,一次装齐时不必单独
69
- add。装完重启 `dsh web`,侧边栏出现「容器」入口;「Docker 容器面板」卡片维护目标与开关,
70
- **保存即热生效**(`settings/updated`
59
+ 聚合包 `@hyzyn/dsh-all`(或仓库根 bundle)已包含本插件,一次装齐时不必单独 add。装完重启 `dsh web`,
60
+ 侧边栏出现「容器」入口;「Docker 容器面板」卡片维护目标与开关,**保存即热生效**(`settings/updated`
71
61
  触发重解析,无需重启)。
72
62
 
73
- > 已在 web profile 里装过 `@hyzyn/dsh-all` 或根 bundle 时**不要**再 add 本包,
74
- > 否则插件行重复挂载,启动报 `duplicate loader entry id`。
63
+ > 已在 web profile 里装过 `@hyzyn/dsh-all` 或根 bundle 时**不要**再 add 本包,否则插件行重复挂载,
64
+ > 启动报 `duplicate loader entry id`。
75
65
 
76
66
  ## 使用
77
67
 
78
- 两个入口,同一个面板。**默认承载是会话右侧栏标签**——入口只负责打开或聚焦它,
79
- 面板与对话同屏:发完错误交给 Agent 后,日志留在右边、不影响看它干活。
68
+ 两个入口,同一个面板。**默认承载是会话右侧栏标签**——入口只负责打开或聚焦它,面板与对话同屏。
80
69
 
81
70
  ![右侧栏标签承载:会话与容器面板同屏、面板全高;折叠即退出视野,不占屏](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-tab.png)
82
71
 
83
- - **侧边栏「容器」**(总入口):任何目标都能用,包括本机 docker 与多目标切换。
84
- 已打开则聚焦(页类型在同一栏内去重),不会开出第二个「Docker 容器」标签。
85
- - **投递后去哪儿看**:右侧栏标签承载下会话就在旁边;docked / 模态下发送成功后**终端会自动
86
- 折起**(会话照旧跑着,点侧边栏「终端」入口的徽标恢复),于是你直接落在会话里——不必对着
87
- 一个盖住会话的弹窗猜「点了没有反应」。
88
- - **SSH 连接栏「容器」按钮**(上下文快捷方式,tty ≥ 0.13.0):在 tty 终端面板的
89
- SSH 标签里,连接栏 SFTP 按钮旁会出现「容器」——**注册即显示**,点击直接用
90
- **当前会话那台主机**打开面板,不用再选目标。目标解析发生在点击时:会话来自
91
- 连接簿时按条目名匹配,否则按 `host:port` 匹配已解析的目标;**没配到目标也不会
92
- 藏按钮**——面板会带一条提示告诉你会话主机(含连接簿名)该去设置卡片怎么配。
93
- 这条路径走**终端右侧的 dock**,不开右侧栏标签——按钮长在 tty 弹窗上,而弹窗盖满
94
- 视口,开标签会被整个挡住、看着像「点了没反应」。目标照旧带进面板并重挂到那台主机。
95
- 于是规则是「**入口决定承载**」:框架侧边栏点 → 右侧栏标签(与对话同屏);
96
- 终端弹窗里点 → 终端右侧 dock(与终端同屏)。
97
- dock 面板**归属点它的那个标签**(tty ≥ 0.18.4):切到别的 SSH 标签时整块收起
98
- (React 树与轮询都保活,切回来恢复原样),关掉该标签则一并收掉;别的标签上再点
99
- 「容器」会用那台主机的目标重开一块。收起 ≠ 关掉:tty 不重挂,面板状态不丢。
72
+ - **侧边栏「容器」**(总入口):任何目标都能用,包括本机 docker 与多目标切换。已打开则聚焦(页类型在
73
+ 同一栏内去重),不会开出第二个「Docker 容器」标签。
74
+ - **SSH 连接栏「容器」按钮**(上下文快捷方式,tty ≥ 0.13.0):在 tty 面板的 SSH 标签里、SFTP 旁出现,
75
+ **注册即显示**,点击直接用**当前会话那台主机**打开面板。目标在点击时解析:会话来自连接簿时按条目名
76
+ 匹配,否则按 `host:port`;**没配到目标也不藏按钮**——面板会带一条提示告诉你该怎么配。这条路径走
77
+ **终端右侧的 dock**,不开右侧栏标签(按钮长在盖满视口的 tty 弹窗上,开标签会被挡住)。于是规则是
78
+ 「**入口决定承载**」:框架侧边栏点 → 右侧栏标签;终端弹窗里点 → 终端右侧 dock。
100
79
 
101
80
  ### 承载形态
102
81
 
103
82
  | 承载 | 何时用 | 行为 |
104
83
  | --- | --- | --- |
105
- | **会话右侧栏标签**(默认) | 宿主提供 `sidebarRight` / `sidebarRightTabs` | 与会话同屏;折叠=退出视野但不丢状态(面板级状态按模块保留,见下);支持右侧栏的 fullscreen 铺满 |
106
- | **终端右侧 dock** | 从**终端连接栏**的「容器」按钮进来(按钮就在 tty 弹窗上,标签会被它挡住),或拿不到右侧栏服务 / `localStorage['dsh-docker:carrier'] = 'modal'` 且 tty ≥ 0.16 面板开着 | 挂在终端面板右侧(拖宽 / 折叠 / ✕ 由 tty 提供,终端继续可用);同一时刻只挂一个,挂上去会收掉前一个(例如 tty 自己的 SFTP);tty ≥ 0.18.4 起**归属点它的那个标签**:切走收起、切回恢复、标签关掉一并收掉(收起期间 React 树与轮询保活) |
84
+ | **会话右侧栏标签**(默认) | 宿主提供 `sidebarRight` / `sidebarRightTabs` | 与会话同屏;折叠=退出视野但不丢状态;支持右侧栏的 fullscreen 铺满 |
85
+ | **终端右侧 dock** | 从**终端连接栏**的「容器」按钮进来,或拿不到右侧栏服务 / `localStorage['dsh-docker:carrier'] = 'modal'` 且 tty ≥ 0.16 面板开着 | 挂在终端面板右侧(拖宽 / 折叠 / ✕ 由 tty 提供,终端继续可用);同一时刻只挂一个,挂上去会收掉前一个(例如 tty 自己的 SFTP);tty ≥ 0.18.4 起**归属点它的那个标签**:切走收起、切回恢复、标签关掉一并收掉(收起期间 React 树与轮询保活) |
107
86
  | **全屏模态**(兜底) | 上面两条都不成立 | 自带 backdrop、点击外部关闭;面板 z-index 高于 tty 弹窗 |
108
87
 
109
- 回滚到旧形态只要在控制台执行 `localStorage.setItem('dsh-docker:carrier', 'modal')`
110
- (`removeItem` 恢复默认)。这个开关是临时灰度用的,所以刻意没进 settings——不值得为
111
- 它连带改宿主配置结构、设置卡片与文档。
88
+ 回滚到旧形态只要在控制台执行 `localStorage.setItem('dsh-docker:carrier', 'modal')`(`removeItem`
89
+ 恢复默认)。这个开关是临时灰度用的,所以刻意没进 settings。
112
90
 
113
- **状态保留**:面板看的是主机、不是工作区,所以 view / 过滤词 / 选中容器(含概览-
114
- 日志-统计页签)/ 目标都是**跨会话保留**的;容器详情内部的日志过滤与 LINES 开关跟具体
115
- 容器绑定,不保留。**折叠标签会断开全部实时流**(把 SSH 通道还回去),展开时重连——
116
- 单容器与聚合建流本来就带 `tail`,历史会自己补回来。
91
+ **状态保留**:面板看的是主机、不是工作区,所以 view / 过滤词 / 选中容器(含概览-日志-统计页签)/ 目标
92
+ 都是**跨会话保留**的;容器详情内部的日志过滤与 LINES 开关跟具体容器绑定,不保留。**折叠标签会断开全部
93
+ 实时流**(把 SSH 通道还回去),展开时重连——建流本来就带 `tail`,历史会自己补回来。
117
94
 
118
- **跨会话粘性**:DSH 右侧栏的标签记录本身是**会话作用域**的(`sidebar.right.pane.tab` 与
119
- `rightbar.session` 都声明 `scope: 'session'`),A 会话开的标签在 B 会话里并不存在。可
120
- 容器面板看的是主机,「切个会话它就没了」是纯损失,所以插件额外记了一个「用户希望它开着」
121
- 的意图:**切会话时自动在新会话里把标签重开**,只有你点了标签的 ✕ 才停止——也就是
122
- 「面板跟人走,而不是跟会话走」。那个 ✕ 是**宿主**执行的(`sidebarRight` 自己摘标签),
123
- 所以「用户关掉了」这件事必须经 `registerCloseHandler` 登记才收得到:只靠面板自己的
124
- `onClose` 会漏掉它,于是刚关掉的标签会在每次切会话时自己回来。
95
+ **跨会话粘性**:DSH 右侧栏的标签记录本身是**会话作用域**的,A 会话开的标签在 B 会话里并不存在;而面板
96
+ 看的是主机,「切个会话它就没了」是纯损失。所以插件额外记一个「用户希望它开着」的意图:**切会话时自动
97
+ 在新会话里把标签重开**,只有你点了标签的 ✕ 才停止。那个 ✕ 是**宿主**执行的,所以必须经
98
+ `registerCloseHandler` 登记才收得到——只靠面板自己的 `onClose` 会漏掉它。
125
99
 
126
100
  **dock 兜底形态**(终端右侧栏):
127
101
 
128
102
  ![docker 面板挂进终端面板右侧 dock:终端保持可见可用](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-dock.png)
129
103
 
130
- 这条路径打开的面板**不会盖住终端**:tty ≥ 0.16 时它挂在终端面板右侧的 dock 里
131
- (可拖宽、可折叠成窄条、✕ 收起),终端照常敲命令。dock 模式下卡片「终端」按钮改为
132
- **在同一终端面板新开标签**执行 `docker exec -it`(面板已经在一个终端里了,再嵌一层
133
- 没有意义);同时**不再渲染面板自己的头部**——标题与 ✕ 由侧栏标题栏承担,刷新与只读
134
- 徽标并到工具条**末尾并靠右**(刷新中图标自己转,不再另挂 spinner):左端留给目标 /
135
- 视图 / 搜索 / 筛选这些「过滤类」控件,刷新不会被当成第一个筛选项,位置也与非 dock
136
- 模式头部里一致;520px 窄栏里不会白留一条空行。
104
+ 这条路径打开的面板**不会盖住终端**:tty ≥ 0.16 时它挂在终端面板右侧的 dock 里(可拖宽、可折叠成窄条、
105
+ ✕ 收起),终端照常敲命令。dock 模式下卡片「终端」按钮改为**在同一终端面板新开标签**执行
106
+ `docker exec -it`;同时**不再渲染面板自己的头部**——标题与 ✕ 由侧栏标题栏承担,刷新与只读徽标并到
107
+ 工具条**末尾并靠右**:左端留给目标 / 视图 / 搜索 / 筛选这些「过滤类」控件。
137
108
 
138
109
  面板内:
139
110
 
140
111
  ![容器列表:目标选择 / 搜索与状态筛选 / 卡片动作条(查看 · 变更两组)](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-panel.png)
141
112
 
142
- - **切目标有过渡与保护**:选择器一变,下方仍是上一个目标的卡片(远端一次往返最长 20s)。
143
- 过渡层的三条原则——**不推版、有方向感、少灰**:
144
- - 面板正文顶部一条 2px **不定长流光进度条**(绝对定位)给「正在取数」的全局信号;
145
- - 正文顶部**居中浮出一枚蓝色胶囊**(与 dsh-rss 的加载胶囊同一套位置与配色语言):
146
- 底色 `color-mix(accent 12%, surface)`、描边 `accent 42%`、文字与旋转图标走 `--dk-accent`;
147
- 可见文案只两段 `⟳ 正在切换到 目标2 · 当前显示:目标1`(不写成一句话、不用引号裹目标名),
148
- 完整的「为什么点不动」放 `title`,需要时 hover 就有;
149
- - 旧数据保持 **82% + 轻度去色**(不是压暗到看不清)并**锁住指针**:读起来是「另一批
150
- 内容」而不是「坏了」,同时避免照着旧列表操作——那会拿新目标当目标、用旧列表的容器
151
- ID 发命令,真能停掉对端同名容器;
152
- - 新数据 **8px 上滑 + 淡入** 200ms 落地,让「换了一批内容」看得见;动效尊重
153
- `prefers-reduced-motion`。
154
-
155
- 以上全部**绝对定位**:切换过程中首卡位置与滚动高度实测完全不变(横幅方案会把内容整块
156
- 推下去)。**切换失败则清空旧列表**并归到新目标的错误态(不继续展示别的目标的数据),
157
- 空态文案也会区分「读取失败」与「筛选过窄」。
158
- - **目标选择**:面板先选目标(来自配置 `targets`,本机 / SSH);只配置了一个
159
- 目标时默认选中它,agent 工具也可以省略 `target` 参数。
160
- **会记住上次选的目标**(存浏览器 `localStorage` 的 `dsh-docker:last-target`,不落
161
- 配置、不进 settings):下次打开面板自动选中它。优先级是**连接栏指定 > 上次记住的
162
- (且仍存在)> 列表第一个**——记住的目标被删掉/改名后会自动退回第一个,不会停在
163
- 「未知目标」上;从终端连接栏的「容器」按钮进来时,当前会话主机优先于记忆值。换目标是**整段上下文切换**:
164
- 上一个目标还在飞的请求一律作废(列表写入闸),不会出现「选择器已经是目标2、卡片
165
- 还是目标1 的容器」这种串台,也不会让旧目标的超时横幅停在新目标的页面上。
166
- - **多目标总览(只读)**:目标选择器旁的 `总览` pill(配了 ≥2 个目标才出现)——不选
167
- 目标,一屏看全部主机:上面一行**计数卡**(目标名 + 本机 / SSH 标识 + 运行中 /
168
- 已停止 / 不健康),下面**异常容器置顶表**(容器名 / 目标 / 状态 / 镜像;不健康排在
169
- 重启中之前,同级按目标在配置里的顺序,跨轮询不跳行)。取数是**并行 + 渐进落地**:
170
- 每个目标独立发一次 `POST /containers`(`all:true`,裸 `docker ps` 不返回 exited,
171
- 「已停止」会永远是 0),一个目标不可达只影响它自己那一格——卡片描红 + 顶部一条
172
- 「N 个目标不可达」横幅,其余目标的结果照常显示(刻意**不用** `Promise.all` 汇总:
173
- SSH 不可达要等 readyTimeout 20s,汇总等于让整页静默 20s)。点计数卡回到该目标的
174
- 常规容器列表,点异常行进该容器详情(返回落到**该目标**的容器列表);**总览不做任何
175
- 跨目标操作**,异常表的行上没有动作按钮。进总览时会清掉「当前这个目标」遗留的
176
- 失败横幅:总览按目标归因(红卡 + 不可达横幅),不再叠一条单目标的「操作失败」——
177
- 同一个 SSH 超时被讲两遍,看着像两台机器都挂了。轮询复用工具条的「自动刷新」开关(默认关):
178
- 这一页每轮是全 N 个目标各一次 `docker ps`,比任何单目标页都贵。没有异常显示
179
- 「一切正常」;还有目标没答完时,异常区显示「读取中…」、**那张计数卡也进入读取态**
180
- (转圈 + 压暗,不出 0/0/0——「0 个容器」会被读成「这台机器没容器」,而它只是还没答)
181
- (「还不知道」不等于「没问题」)。
182
- - **容器列表**:名称 / 状态 / 健康态 / 镜像 / 端口映射 / compose 项目与服务 /
183
- 短 ID;支持按名称或镜像**搜索**、按状态**筛选**(运行中 / 已停止 / 全部),
184
- 以及**自动刷新**(按 `pollIntervalSec` 轮询,切到镜像页会暂停且不显示该开关——
185
- 镜像变化慢,没必要每 5s 跑一次 `docker images`;切回容器页原设置照旧生效)。
186
- 工具条里「含已停止」与「自动刷新」是**成组**的两个开关,搜索框是其中唯一可伸缩的项:
187
- 宽面板下整条工具条收成一行,窄栏(dock 520px)里才按组换行——不会出现一行只有
188
- 一个复选框的孤行。
189
- - **「活动」条(docker events 事件流)**:容器列表头部的可折叠窄栏(默认展开),
190
- 跟着一条 SSE(`GET /api/dsh-docker/events/stream`,服务端跑
191
- `docker events --filter type=container`)显示最近 8 条事件(时间 + 容器名 + 动作,
192
- `die` 带退出码如 `die(137)`)。它是**事件驱动刷新**的入口:收到事件后 500ms
193
- **防抖**触发一次列表重取(不是每帧一次请求),与原有 `AUTO REFRESH` 叠加而不互斥——
194
- 轮询负责兜底,事件负责「刚发生」。事件只留在内存(环形缓冲 50 条),切页即关流,
195
- 换目标清空缓冲。白名单只留九类生命周期动作(start / die / stop / kill / oom /
196
- health_status / destroy / rename / update):`exec_*`、`archive-path`(`docker cp`)
197
- 这类噪音在服务端就丢掉了——实测一台跑批机器 24 小时 47 条事件全是 exec,白名单
198
- 命中 0,所以只被 exec 的机器上活动条是空的,这是刻意的。
199
- 断开后**要手动重连**:这条流进了 `closed` 就是终止态(EventSource 只在连接层抖动
200
- 时自愈;`docker events` 进程退出、服务端发 `end` 帧都会走 close),活动条上会出现
201
- 一个 **重连** 按钮(只在 `closed` 时出现;`unsupported` 环境不给——重连必然再失败),
202
- 工具条的 ⟳ 也一并重连。**AUTO REFRESH 轮询刻意不重连**:塞进轮询会让一条已终止的流
203
- 被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间
204
- 列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而
205
- 列表还停在错误态,看着像好了其实没好。
206
- **重连被拒时要说原因**(`D164`):服务端主动拒绝这条流时会发一帧 `event: error`
207
- 带 `message`(最典型的是长流配额满,见「已知限制」——那句话里写着该怎么办);
208
- 客户端现在把它**显示在活动条上**(「重连被拒:…」),而不是丢掉。原先只置
209
- `closed`,于是「拒绝 → 回到原位」与「点了没反应」在界面上完全不可区分,用户只能反复点。
210
- 重连成功(或重新建流)时这条原因清除。
211
- 断开时右侧那句提示是**折叠头里的单行小字**,所以刻意写短(2026-10-08 用户反馈
212
- 「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点
213
- 「重连」」——成因、上限与 `MaxSessions` 那一串写在本页,不塞进界面。
214
- **「在收事件」只认宿主那帧 `ready`**(2026-10-11 用户现场):SSE 的 `onopen` 只等于
215
- 「响应头到了」——宿主是先把响应头 flush 出去、**再去起** `docker events`,而目标不可达 /
216
- SSH 正在重连时建连可以卡十几秒(D150 的「槽位满 → 重建连接」还会再来一轮)。那段时间
217
- 活动条原本已经亮成绿色的「实时接收中(docker events)」,一个事件都还没收到。现在绿点
218
- 只认 `ready`(= 管道真的起来了);此前停在「正在连接事件流…」/「连接中断,正在自动
219
- 重连…」——两种都是实话。日志 / 统计 / 拉取三条流同一口径(见「SSE 事件协议」)。
220
- - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——每张卡片
221
- 左侧出现勾选框,点卡片本体变成**勾选 / 取消**(不再进详情;动作条暂时收起,
222
- 免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+
223
- `聚合日志` + `取消`。`聚合日志` 至少需要 2 个容器;选到第 3 个时给一条软提示,
224
- 超过 **3 个**按钮置灰并提示上限(**上限怎么来的见「已知限制」的聚合上限**,
225
- 操作条上也写着那句说明)。点 `聚合日志`
226
- 进入聚合视图:直接复用 Compose 项目视图那套聚合日志(**一条** `/logs/stream?ids=…`
227
- 装下所有选中容器,帧里带 `id`,按 `[service]` / 容器名前缀混流,带过滤与自动滚动),
228
- 返回即退出选择态并清空。
229
- 聚合视图的内容控制与**单容器日志完全对齐**(文本过滤 + 级别门槛 + `⬇ .log` / `⬇ .md`
230
- 导出当前显示内容 + 行数统计),另有聚合独有的两项:**按时间 / 按到达**排序、**暂停**冻结
231
- (暂停时流继续接收、恢复一次性补齐)。
232
- 再点 `聚合选择` 或按 **Esc** 同样退出并清空。勾选是**临时的**:不持久化、不命名
233
- 组合、不进 settings;切目标 / 切「容器 · 镜像 · Compose」分段 / 关面板即失效,
234
- 列表刷新后已消失的容器按 id 自动剔除。
235
- - **按条件一键选中**(多选态):操作条第二行给出一排条件 chip(`全部可见 / 不健康 /
236
- 需关注 / 已停止`,以及有勾选后的 `同镜像 / 同项目`),计数**按剩余名额截断**——
237
- chip 上写 3 就真的会选中 3 个;超出上限的数量在 title 与结果提示里如实说明。
238
- 条件只在**当前筛选结果**里生效(先搜索/筛状态再一键选中)。
239
- - **容器卡片**:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 /
240
- compose,值等宽、可省略)+ 一排图标操作按钮,**按「查看 / 变更」两组用竖线分隔**:
113
+ - **切目标有过渡与保护**:选择器一变,下方仍是上一个目标的卡片(远端一次往返最长 20s)。过渡期间旧
114
+ 卡片去色并**锁住指针**(读起来是「另一批内容」而不是「坏了」,也避免照着旧列表操作——那会拿新目标
115
+ 当目标、用旧容器的 ID 发命令,真能停掉对端同名容器),新数据淡入落地;整块**绝对定位**,首卡位置与
116
+ 滚动高度不变。**切换失败则清空旧列表**并归到新目标的错误态,空态文案区分「读取失败」与「筛选过窄」。
117
+ - **目标选择**:面板先选目标(来自配置 `targets`,本机 / SSH);只配了一个时默认选中它,agent 工具也
118
+ 可以省略 `target`。**会记住上次选的目标**(`localStorage` 的 `dsh-docker:last-target`,不落配置):
119
+ 优先级是**连接栏指定 > 上次记住的(且仍存在)> 列表第一个**。换目标是**整段上下文切换**:上一个目标
120
+ 还在飞的请求一律作废(列表写入闸),不会出现「选择器已是目标2、卡片还是目标1 的容器」这种串台。
121
+ - **容器列表**:名称 / 状态 / 健康态 / 镜像 / 端口映射 / compose 项目与服务 / 短 ID;支持按名称或镜像
122
+ **搜索**、按状态**筛选**、**自动刷新**(按 `pollIntervalSec` 轮询,切到镜像页会暂停且不显示该开关)。
123
+ 「含已停止」与「自动刷新」是**成组**的两个开关,搜索框是其中唯一可伸缩的项:宽面板下一行,窄栏
124
+ (dock 520px)里才按组换行。
125
+ - **「活动」条(docker events 事件流)**:容器列表头部的可折叠窄栏(默认展开),跟着一条 SSE
126
+ (`GET /api/dsh-docker/events/stream`,服务端跑 `docker events --filter type=container`)显示最近 8 条
127
+ 事件(时间 + 容器名 + 动作,`die` 带退出码)。它是**事件驱动刷新**的入口:收到事件后 500ms **防抖**
128
+ 触发一次列表重取,与 `AUTO REFRESH` 叠加而不互斥——轮询兜底,事件负责「刚发生」。事件只留在内存
129
+ (环形缓冲 50 条),切页即关流,换目标清空。白名单只留九类生命周期动作(start / die / stop / kill /
130
+ oom / health_status / destroy / rename / update):`exec_*`、`archive-path` 这类噪音在服务端就丢掉。
131
+ 断开后**要手动重连**:进了 `closed` 就是终止态,活动条上出现**重连**按钮(`unsupported` 环境不给),
132
+ 工具条的 ⟳ 也一并重连;**AUTO REFRESH 刻意不重连**(塞进轮询会让已终止的流被不断重开),重连成功后
133
+ 补一次全量列表刷新。**重连被拒要说原因**(`D164`):服务端主动拒绝时发一帧 `event: error` 带
134
+ `message`,客户端把它**显示在活动条上**——只置 `closed` 会让「拒绝 → 回到原位」与「点了没反应」不
135
+ 可区分。**「在收事件」只认宿主那帧 `ready`**(响应头到达不算)。
136
+ - **聚合选择(临时多选聚合日志)**:工具条的 `聚合选择` 进入选择态——点卡片本体变成**勾选 / 取消**
137
+ (动作条暂时收起,免得一边多选一边误点启停删),出现操作条:「已选 N 个容器」+ `聚合日志` + `取消`。
138
+ `聚合日志` 至少需要 2 个容器、最多 **3 个**(超了按钮置灰并提示上限来由)。进入后复用 Compose 项目
139
+ 视图那套聚合日志,内容控制与单容器日志完全对齐,另有**按时间 / 按到达**排序与**暂停**。勾选是**临时
140
+ 的**:不持久化、不进 settings;切目标 / 切分段 / 关面板即失效,列表刷新后已消失的容器按 id 自动剔除。
141
+ - **按条件一键选中**(多选态):操作条第二行给出条件 chip(`全部可见 / 不健康 / 需关注 / 已停止`,以及
142
+ 有勾选后的 `同镜像 / 同项目`),计数**按剩余名额截断**——chip 上写 3 就真的会选中 3 个。条件只在
143
+ **当前筛选结果**里生效。
144
+ - **容器卡片**:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 / compose)+ 一排图标按钮,
145
+ **按「查看 / 变更」两组用竖线分隔**:
241
146
 
242
147
  | 组 | 按钮 | 说明 |
243
148
  | --- | --- | --- |
244
149
  | 查看 / 进入 | 终端、日志、资源占用 | 不改容器状态,只读模式下也永远可用。**终端按钮跑 `docker exec -it` 交互式 shell,不受 `allowExec` 门控**(那条开关只管本插件的一次性 exec 通道,见「已知限制」) |
245
150
  | 变更 | 启停、重启、删除 | 按破坏性递增排列;只在 `allowMutations` 开启后可用,未开启时整组置灰 |
246
151
 
247
- 删除额外做了两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。
248
- **命令飞行期间整组锁住**:`stop` / `rm` 要等容器真的退出(最长十几秒),这段时间
249
- 确认框保持打开并显示「执行中…」(两个按钮都禁用),卡片的其它变更按钮压暗置灰、
250
- 正在跑的那个图标换成转圈,等列表刷新落地后才恢复——避免中途连点叠出
251
- stop + restart + remove 这类互相打断的命令。镜像的删除 / 清理走同一套确认框,
252
- 同样有执行中态。点卡片本体进入概览。
253
- - **容器详情(整栏视图)**:顶部为「返回 + 容器名 + 状态徽标 + 目标主机」,
254
- 下方五个标签页——概览(`docker inspect` 权威数据 + 一次性 exec + 等价 `docker run`)、日志、
255
- 统计、诊断、进程。从卡片的日志 / 统计图标可直接落到对应标签页;诊断与进程**只在该页签被打开时
256
- 才取数**(切走即不再请求,也没有轮询)。
257
- - **日志视图(紧凑两行)**:第一行 = 返回 + 容器名 + 状态 + 目标主机 +
258
- `LINES`(尾部行数)/ `TIMESTAMPS` / **`FOLLOW`(实时跟随,见下)** /
259
- `AUTO REFRESH`(开关 + 2/3/5/10s 间隔,仅日志页轮询)+ 刷新 / 关闭;
260
- 第二行 = 标签页 + **常驻**的「过滤日志」
261
- 输入框(有内容时框内浮出 ✕ 清空,Esc 也能清空)+ **级别门槛**
262
- (`全部级别 / INFO+ / WARN+ / ERROR+`(`INFO+` 就是「清静但别丢关键信息」那档:噪音几乎都在 DEBUG 及以下,而只有 `WARN+` 会把 INFO 一起滤掉))+ **导出**(`⬇ .log` / `⬇ .md`,导出的是**当前显示内容**)
263
- + 右侧固定槽的行数统计。**两行的分工是刻意的**:第一行管**传输与显示**(快照 / 流 / 轮询),
264
- 第二行管**内容**——而第二行与聚合日志**完全同一套**:同一个级别内核、同一个导出构建器、
265
- 同一份计数文案(`N 行`,有过滤时 `N / M 行`)。级别门槛的语义是「无级别前缀的行是上一条的
266
- 续行,跟随其级别」,否则 `ERROR+` 会把堆栈拦腰截断。输入框宽度与位置恒定,输入 / 清空都不会挤动这一行。日志正文按级别着色(`[INFO]` 与 `|INFO` 两种常见前缀
267
- 都能识别),时间戳压暗,过滤命中高亮;显示与导出取**同一集合**(`LINES` 选多少就多少),不再有「只显示最近 N 行」的截断。
268
- 详情视图下不再叠加列表工具条与面板头,每屏只有一个刷新入口。
269
- - **FOLLOW 实时日志流**:日志页 `FOLLOW` 开关打开后,界面从「定时拉快照」切换
270
- 为 **SSE 推送**(`GET /api/dsh-docker/logs/stream`,服务端跑
271
- `docker logs --follow`)——新日志到达即追加,不再轮询;`FOLLOW` 与
272
- `AUTO REFRESH` 互斥(开流自动停轮询、开关置灰),关闭即回到快照并立即刷新。
273
- 流式日志保留最近 **5000 行 / 4MB**(环形缓冲,行数或字节超限都丢最旧并提示
274
- 一次;没有换行符的超长输出会被强制分片,内存恒有界);分片到达按 **150ms 合帧**
275
- 渲染(话痨容器也不再逐条重绘),行 key 用单调 id。
276
- **正文按窗口化挂载(D152)**:DOM 里只留「可见 + 上下各 24 行 overscan」的几十行
277
- (实测 5000 行时约 230 个节点,此前是全量 ~10000),其余用上下垫高占位——持续洪泛
278
- 20k 行/秒时事件循环最大延迟从 70~97ms 降到 6ms、>50ms 长帧从 25~26 个降到 **0**。
279
- 行高是**实测**的(不是估算),贴底时窗口锚在列表尾部,所以「自动贴底」「上滚暂停」
280
- 「回到底部」的几何判定依旧精确;往上滚历史时按可见区顶行做**跨帧记锚**的修正——
281
- 洪泛里缓冲持续淘汰旧行、或聚合视图按时间插行,都不会跳。**换流 / 换容器 / 快照刷新
282
- 会整代作废这批实测高度与锚点(D155)**:快照行 id 是位置寻址(`'s'+index`),刷新后
283
- 同一个 id 就是另一行内容——不换代就会拿另一代的高度算垫高(实测滚动高虚高 35644px),
284
- 滚动条、跳转、「滚到顶看到第几行」全部按错的一代算。
285
- 显示层**不截断**:`LINES` 选多少就是多少——全部行都能滚到、都能导出(真正的闸是
286
- 缓冲与宿主的「输出上限(KB)」),只是不再同时挂在 DOM 里。自动滚动到底部,用户向上滚动时暂停并浮出
287
- 「回到底部」按钮;右上状态行显示连接状态,容器退出导致流自然结束时自动切回快照刷新。**断线重连由插件自己管**(不依赖
288
- EventSource 的自动重连):首连带 tail 补历史,重连一律 tail=0——只补新行、**不
289
- 重放历史**(自动重连会复用带 tail 的 URL,服务端就会把最后 tail 行当新行重推一遍,
290
- 日志里凭空多出一段重复)。**宿主侧背压(D153)**:单条流的暂存队列上限 8MB;日志与拉取
291
- 这类**文本尾部流**溢出时丢最旧的帧、留最新,并推一条 `skip{frames,bytes}` 让界面说明
292
- 「中间断了一截,跟随继续」——**不再掐流**(老策略是补一条 `end{reason:output-limit}` 收尾,
293
- 客户端只能重连,积压期间面板就是「连接中断」在打转)。skip 的账目是**精确**的:skip 帧还在
294
- 队列里没落地时又发生丢弃,服务端**原地更新**它的计数(D156,一段缺口都不吞);客户端把
295
- 多批 skip **累加**显示成「本次连接共跳过 N 批」,重连成功即清零(D157)。统计 / 事件这类结构化流仍走收尾重连
296
- (少一个采样/事件在语义上是错的,宁可让客户端补一次)。
297
- 连接 / 切页 / 关面板都会关闭 `EventSource`。
298
- - **概览**:`docker inspect` 的权威数据——状态与健康、退出码、重启次数与策略、
299
- 端口映射、挂载(含只读标记)、网络与 IP、entrypoint 与命令、最近一次健康
300
- 检查输出;下方可执行一次性 `docker exec`(需 `allowExec`)。
301
- - **日志**:`docker logs --tail` 的尾部快照(`LINES` 默认取设置卡片的「日志
302
- 默认行数」),可切时间戳与 `--since`。**两个闸要分清**:`LINES` 是**行数**上限,
303
- 设置卡片的「输出上限(KB)」是**字节**上限——两者取小,字节先超就截断并给出横幅
304
- (所以 `LINES` 选了 5000 也可能只回来一部分)。要持续观察新日志就打开上面的
305
- `FOLLOW`(同一份 argv 加 `--follow`,无总超时、不受该字节上限约束,靠连接
306
- 生命周期收尾)。
152
+ 删除额外两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。**命令飞行期间整组
153
+ 锁住**:`stop` / `rm` 要等容器真的退出(最长十几秒),确认框保持打开并显示「执行中…」,其它变更按钮
154
+ 压暗置灰、正在跑的那个换成转圈,等列表刷新落地后才恢复。镜像的删除 / 清理走同一套确认框。点卡片本体
155
+ 进入概览。
156
+ - **容器详情(整栏视图)**:顶部为「返回 + 容器名 + 状态徽标 + 目标主机」,下方五个标签页——概览
157
+ (`docker inspect` 权威数据 + 一次性 exec + 等价 `docker run`)、日志、统计、诊断、进程。从卡片的
158
+ 日志 / 统计图标可直接落到对应页签;诊断与进程**只在该页签被打开时才取数**(切走即不再请求)。
159
+ - **日志视图(紧凑两行)**:第一行 = 返回 + 容器名 + 状态 + 目标主机 + `LINES` / `TIMESTAMPS` /
160
+ **`FOLLOW`** / `AUTO REFRESH`(开关 + 2/3/5/10s 间隔,仅日志页轮询)+ 刷新 / 关闭;第二行 = 标签页 +
161
+ **常驻**的「过滤日志」输入框(有内容时浮出 ✕,Esc 也能清空)+ **级别门槛**(`全部级别 / INFO+ /
162
+ WARN+ / ERROR+`)+ **导出**(`⬇ .log` / `⬇ .md`,导出**当前显示内容**)+ 右侧固定槽的行数统计。
163
+ **两行的分工是刻意的**:第一行管传输与显示,第二行管内容——而第二行与聚合日志**完全同一套**(同一个
164
+ 级别内核、导出构建器、计数文案)。级别门槛的语义是「无级别前缀的行是上一条的续行,跟随其级别」,
165
+ 否则 `ERROR+` 会把堆栈拦腰截断。日志正文按级别着色,时间戳压暗,过滤命中高亮。
166
+ - **FOLLOW 实时日志流**:打开后从「定时拉快照」切为 **SSE 推送**(`GET /api/dsh-docker/logs/stream`,
167
+ 服务端跑 `docker logs --follow`),与 `AUTO REFRESH` 互斥(开流自动停轮询、开关置灰),关闭即回到
168
+ 快照并立即刷新。流式日志保留最近 **5000 行 / 4MB**(环形缓冲,超限丢最旧并提示一次;无换行的超长
169
+ 输出强制分片,内存恒有界),分片到达按 **150ms 合帧**渲染。**正文按窗口化挂载**(`D152`):DOM 里只
170
+ 留「可见 + 上下各 24 行 overscan」,其余用上下垫高占位;行高是实测的、贴底时窗口锚在尾部,所以
171
+ 「自动贴底 / 上滚暂停 / 回到底部」的几何判定依旧精确;**换流 / 换容器 / 快照刷新会整代作废这批实测
172
+ 高度与锚点**(`D155`:快照行 id 是位置寻址,不换代就会拿另一代的高度算垫高)。显示层**不截断**:
173
+ `LINES` 选多少就能滚到、能导出(真正的闸是缓冲与宿主的「输出上限(KB)」)。**断线重连由插件自己
174
+ 管**:首连带 tail 补历史,重连一律 `tail=0`——只补新行、**不重放历史**。**宿主侧背压**(`D153`):
175
+ 单条流的暂存队列上限 8MB;日志与拉取这类**文本尾部流**溢出时丢最旧的帧、留最新,并推一条
176
+ `skip{frames,bytes}` 让界面说明「中间断了一截,跟随继续」——**不再掐流**;skip 的账目是精确的(排队
177
+ 期间再次丢弃**原地更新**计数,`D156`;客户端把多批 skip 累加显示,重连成功即清零,`D157`)。统计 /
178
+ 事件这类结构化流仍走收尾重连。
179
+ - **概览**:`docker inspect` 的权威数据——状态与健康、退出码、重启次数与策略、端口映射、挂载(含只读
180
+ 标记)、网络与 IP、entrypoint 与命令、最近一次健康检查输出;下方可执行一次性 `docker exec`(需
181
+ `allowExec`)。
182
+ - **日志**:`docker logs --tail` 的尾部快照(`LINES` 默认取设置卡片的「日志默认行数」),可切时间戳与
183
+ `--since`。**两个闸要分清**:`LINES` 是**行数**上限,设置卡片的「输出上限(KB)」是**字节**上限——
184
+ 两者取小,字节先超就截断并给出横幅。要持续观察新日志就打开 `FOLLOW`(同一份 argv 加 `--follow`,
185
+ 无总超时、不受该字节上限约束,靠连接生命周期收尾)。
307
186
 
308
187
  ![日志页:级别着色 + 常驻过滤框 + 行数统计](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-logs.png)
309
- - **统计**:`docker stats --no-stream` 快照(CPU% / 内存用量与占比 / 网络 IO /
310
- 块 IO / PIDs),面板按 `pollIntervalSec` 轮询刷新。统计页还有 **`FOLLOW` 实时
311
- 跟随**:打开后切到 `GET /api/dsh-docker/stats/stream`(服务端跑**不带
312
- `--no-stream`** 的 `docker stats`,每秒一行),浏览器侧留 **60 个点的环形
313
- 缓冲**画 CPU / 内存**迷你趋势图**(sparkline)。与日志流不同,这条流**不会
314
- 自然结束**,关闭语义是前端主动断 `EventSource`;`docker stats` 自己退出时
315
- 服务端发 `end`(reason=`stats-exit`),界面提示并自动切回快照轮询。
316
- - **Compose 项目视图**:工具条第三段切换。把 `composeProject` / `composeService`
317
- 标签聚合成「项目 → 服务 → 容器」,每个项目一行看全运行数 / 不健康数 / 服务数与
318
- 各容器状态;点进项目可看服务表,或打开**项目级聚合日志**——**一条**
319
- `/logs/stream?ids=…` 装下项目内所有容器(帧里带 `id`),客户端按 `[service]` 前缀
320
- 混流,带自动滚动开关与过滤框。混流是到达序,不保证跨容器严格时序。某个容器的子流
321
- 失败只标注「部分出错」,其余容器的日志照常收(连接不关)。
322
- - **镜像**:`docker images` 列表(reference / 大小 / 创建时间 / 短 ID);
323
- `<none>:<none>` 的 dangling 镜像带 `dangling` 标记。搜索框与「N / M 个镜像」
324
- 计数**固定在工具条里**(不随列表滚走),表头列名在表体内吸顶。
325
- 每行有「详情 / 删除」两个动作:**详情**打开整栏视图(概览:大小 / 含父层 /
326
- 创建 / 平台 / 层数 / 入口与命令 / 暴露端口 / digest / 标签;构建历史:
327
- `docker history` 的逐层命令与大小)。**删除**(需 `allowMutations`)二次确认
328
- 后执行 `docker image rm`(不带 `-f`,镜像被引用时会失败并给出「先删相关
329
- 容器」的提示)。
330
- - **网络 / 卷**:工具条分段扩到「容器 / 镜像 / Compose / 网络 / 卷」(最后一段是「磁盘」,见「磁盘与可回收」)
331
- (窄栏里放不下时分段容器自己横向滚动,不做二级菜单)。两页都是「表格 + 整栏详情」
332
- 的同构布局:
333
- - **网络**:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览(ID / 驱动 /
334
- 范围 / 创建 / 子网 / 网关 / internal·attachable·ingress·ipv6 / 选项 / 标签)与
335
- 「接入的容器」页签(容器 / IPv4 / IPv6 / MAC)。**容器数刻意不进列表行**:
336
- `docker network inspect` 才能拿到接入列表,列表逐行 inspect 就是 N 次 docker 调用,
337
- 改成点进详情取一次。
338
- - **卷**:名称 / 驱动 / 范围 / 挂载点(超长路径限宽省略,title 给全量),详情为
339
- 名称 / 驱动 / 范围 / 挂载点 / 创建 / 选项 / 标签(卷没有反向索引,只有概览一页)。
340
- - 详情页头部有**删除**按钮,两页工具条各有 **prune 图标**,都受 `allowMutations`
341
- 门控(未开启时置灰 + title 说明),点击后二次确认;删除失败(网络还有容器接着 /
342
- 卷还被占用 / 403)在详情页就地弹横幅,不会静默。
343
- - 自动刷新与镜像页一致:这两页**不轮询**(清单变化慢,5s 一次 docker CLI 是白烧),
344
- 切页 / 切目标时才刷。
345
- - **拉取镜像(SSE 进度流)**:镜像页工具条的拉取图标(需 `allowMutations`)
346
- 打开拉取视图,输入引用后走 `GET /api/dsh-docker/images/pull/stream`
347
- (服务端跑 `docker pull`)——逐层进度(Pulling fs layer / Downloading /
348
- Extracting / Pull complete)实时出现;进度行按「层键」原地更新,TTY 下的 `\r`
349
- 刷新也不会让缓冲区越滚越长。工具条上的「清理 dangling」执行
350
- `docker image prune -f`,**只删无标签镜像**(刻意不加 `--all`,避免误删未
351
- 使用的普通镜像)。
188
+
189
+ - **统计**:`docker stats --no-stream` 快照(CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs),按
190
+ `pollIntervalSec` 轮询刷新。统计页还有 **`FOLLOW` 实时跟随**:切到
191
+ `GET /api/dsh-docker/stats/stream`(服务端跑**不带** `--no-stream` 的 `docker stats`,每秒一行),
192
+ 浏览器侧留 **60 个点**的环形缓冲画 CPU / 内存迷你趋势图。这条流**不会自然结束**,关闭语义是前端主动
193
+ 断;`docker stats` 自己退出时服务端发 `end`(reason=`stats-exit`)。
194
+ - **Compose 项目视图**:工具条第三段。把 `composeProject` / `composeService` 聚合成「项目 → 服务 →
195
+ 容器」,每个项目一行看全运行数 / 不健康数 / 服务数;点进项目可看服务表,或打开**项目级聚合日志**
196
+ ——一条 `/logs/stream?ids=…` 装下项目内所有容器(帧里带 `id`),客户端按 `[service]` 前缀混流。某个
197
+ 容器的子流失败只标注「部分出错」,其余容器的日志照常收。
198
+ - **镜像**:`docker images` 列表(reference / 大小 / 创建时间 / 短 ID),`<none>:<none>` 的 dangling
199
+ 带标记;搜索框与计数**固定在工具条里**,表头列名在表体内吸顶。每行「详情 / 删除」:**详情**打开整栏
200
+ 视图(概览 + `docker history` 构建历史);**删除**(需 `allowMutations`)二次确认后执行
201
+ `docker image rm`(不带 `-f`,被引用时会失败并给出「先删相关容器」的提示)。
202
+ - **网络 / 卷**:工具条分段扩到「容器 / 镜像 / Compose / 网络 / 卷 / 磁盘」(窄栏里分段自己横向滚动,
203
+ 不做二级菜单)。两页都是「表格 + 整栏详情」的同构布局:
204
+ - **网络**:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览与「接入的容器」页签。
205
+ **容器数刻意不进列表行**:只有 `docker network inspect` 才拿得到接入列表,列表逐行 inspect 就是
206
+ N 次 docker 调用。
207
+ - **卷**:名称 / 驱动 / 范围 / 挂载点(超长路径限宽省略,title 给全量),详情只有概览一页(卷没有
208
+ 反向索引)。
209
+ - 详情页头部有**删除**按钮,两页工具条各有 **prune 图标**,都受 `allowMutations` 门控(未开启时置灰
210
+ + title 说明)并二次确认;删除失败就地弹横幅,不会静默。这两页**不轮询**(清单变化慢)。
211
+ - **拉取镜像(SSE 进度流)**:镜像页工具条的拉取图标(需 `allowMutations`)打开拉取视图,输入引用后走
212
+ `GET /api/dsh-docker/images/pull/stream`——逐层进度实时出现;进度行按「层键」原地更新,TTY 下的 `\r`
213
+ 刷新也不会让缓冲区越滚越长。工具条的「清理 dangling」执行 `docker image prune -f`,**只删无标签
214
+ 镜像**(刻意不加 `--all`)。
352
215
 
353
216
  ![镜像页:搜索框固定在工具条,表头吸顶](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-images.png)
354
- - **一次性 exec**:`allowExec` 开启后可输入命令,等价
355
- `docker exec <容器> sh -c "<命令>"`,返回退出码与 stdout/stderr(无 TTY)。
356
- - **交互式终端(卡片第一个图标)**:跑 `docker exec -it '<容器>' sh`。按 tty 能力
357
- 三级降级——
217
+
218
+ - **一次性 exec**:`allowExec` 开启后可输入命令,等价 `docker exec <容器> sh -c "<命令>"`,返回退出码
219
+ 与 stdout/stderr(无 TTY)。
220
+ - **交互式终端(卡片第一个图标)**:跑 `docker exec -it '<容器>' sh`,按 tty 能力三级降级——
358
221
  ![exec 终端抽屉 + 日志页共存:折叠只藏起来、会话保持运行](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-exec.png)
359
222
 
360
- 1. **就地嵌入(tty ≥ 0.15,推荐)**:在面板底部开一个**终端抽屉**,由 tty 的
361
- `ttyTerminal.mount` 把终端挂进来。面板不收起,看着容器日志直接进容器敲命令,
362
- 上下文不断。抽屉会挤占正文高度,所以它可以让位而不结束会话:
363
- **折叠**(标题栏右侧的箭头,或双击顶边)只把抽屉压成一条标题栏,会话照旧跑着;
364
- **拖拽顶边**可调高度(上限为面板高度的 75%)。真正结束会话的动作只有两个——
365
- 抽屉上的 ✕,以及关闭面板;有活动会话时关闭面板会先弹确认,避免点 backdrop
366
- 空白处时误杀一个正在排障的容器 shell(全局终端面板也是「点空白 = 最小化」,同一取向)。
367
- 2. **借 tty 弹窗开标签(tty ≥ 0.14)**:tty 新开一个标签执行同一命令,随后收起本
368
- 面板(本面板 z-index 更高,不收起用户只会觉得「点了没反应」)。
369
- 3. **复制命令**:未装 tty / 版本过旧 / 内联目标用了 key·password 认证(浏览器端
370
- 没有凭证)时,退化为复制该命令并提示到终端面板粘贴。
371
-
372
- 本地目标开本地会话,SSH 目标按连接簿条目名(或 agent 认证的内联字段)走 SSH,
373
- 标签/抽屉标题为 `<容器> · exec`。能力判断走服务契约版本
374
- (`ttyTerminal.version >= 2` 才有 `mount`),不是猜函数存不存在。
223
+ 1. **就地嵌入(tty ≥ 0.15,推荐)**:面板底部开一个**终端抽屉**,由 `ttyTerminal.mount` 把终端挂
224
+ 进来,面板不收起;**折叠**(标题栏箭头或双击顶边)只把抽屉压成一条标题栏、会话照旧跑着,
225
+ **拖拽顶边**可调高度(上限 75%)。真正结束会话的只有抽屉的 ✕ 与关闭面板——有活动会话时关面板
226
+ 会先弹确认,避免点 backdrop 误杀一个正在排障的容器 shell。
227
+ 2. **借 tty 弹窗开标签(tty ≥ 0.14)**:tty 新开一个标签执行同一命令,随后收起本面板(本面板
228
+ z-index 更高,不收起用户只会觉得「点了没反应」)。
229
+ 3. **复制命令**:未装 tty / 版本过旧 / 内联目标用了 key·password 认证(浏览器端没有凭证)时,退化
230
+ 为复制该命令并提示到终端面板粘贴。
231
+
232
+ 本地目标开本地会话,SSH 目标按连接簿条目名(或内联字段)走 SSH,标题为 `<容器> · exec`。能力判断走
233
+ 服务契约版本(`ttyTerminal.version >= 2` 才有 `mount`),不是猜函数存不存在。
375
234
 
376
235
  ### 目标(本机 / SSH)
377
236
 
@@ -380,20 +239,14 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;「Docker 容
380
239
  | `local` | 宿主所在机器上的 docker CLI(`spawn` 直接执行,不经 shell) |
381
240
  | `ssh` | 经 `ssh2` 连到远程主机,在远程执行 docker CLI(argv 经单引号转义) |
382
241
 
383
- `kind=ssh` 有两种填法:
242
+ `kind=ssh` 有两种填法:① **引用 tty 连接簿条目**——`book` 填条目名(在「终端面板」的连接簿里维护),
243
+ 主机 / 端口 / 用户名 / 认证方式随之生效;设置卡片的下拉只列出 tty 已保存的条目,tty 未安装或条目不
244
+ 存在时该目标解析失败,面板与 agent 工具都会给出明确错误。② **内联字段**——`host` + `username` 必填,
245
+ 其余按需(`port` / `auth` / `keyPath` / `password` / `passphrase` / `agentForward`)。
384
246
 
385
- 1. **引用 tty 连接簿条目**:`book` 填条目名(在「终端面板」的
386
- 连接簿里维护),主机 / 端口 / 用户名 / 认证方式随之生效。设置卡片的下拉
387
- 只列出 tty 已保存的连接簿条目;tty 未安装或条目不存在时,该目标解析失败,
388
- 面板与 agent 工具都会给出明确错误。
389
- 2. **内联字段**:`host` + `username` 必填,其余按需(`port` / `auth` /
390
- `keyPath` / `password` / `passphrase` / `agentForward`)。
391
-
392
- 目标解析是**每次操作现算**的:在 tty 卡片里改了连接簿条目(换端口、改密码),
393
- 下一次操作立即用新值,无需重启。远端需要满足:装了 docker CLI,且当前账号
394
- **免 sudo** 可用 docker(通常在 `docker` 组),否则 `probe` 会原样透出
395
- `permission denied while trying to connect to the Docker daemon socket`
396
- 之类的错误。
247
+ 目标解析是**每次操作现算**的:在 tty 卡片里改了连接簿条目,下一次操作立即用新值,无需重启。远端需要
248
+ 装了 docker CLI,且当前账号**免 sudo** 可用 docker(通常在 `docker` 组),否则 `probe` 会原样透出
249
+ `permission denied while trying to connect to the Docker daemon socket` 之类的错误。
397
250
 
398
251
  #### 连接本机(一键)
399
252
 
@@ -404,257 +257,202 @@ add。装完重启 `dsh web`,侧边栏出现「容器」入口;「Docker 容
404
257
  | 一个目标都没有 | 面板正文的空态里一个 **连接本机** 主按钮(那时整块是空的,一键就是最短路径) |
405
258
  | 已经有别的目标 | **目标下拉的末一项** `+ 连接本机`——就在你找机器的地方,点开才出现 |
406
259
 
407
- 两处互斥(不会同一个入口出现两次):没有目标时走空态那个大按钮;有目标时下拉里才有那一项。
408
- **已经有本机目标时两处都不出现**(选择器里已经有那台本机了,再列一项等于重复)。
409
-
410
- agent 侧同一件事是 `docker_connect_local` 工具,HTTP 侧是 `POST /connect-local`——
411
- 所有入口共用宿主里同一份实现。
412
-
413
- 为什么不做成常驻的工具条按钮:常驻一个「连接本机」既占掉目标选择器右边的位置,又要靠
414
- 强调色招人点,而**已经有可用目标**时它是纯噪音(第一版就是常驻的,用户当场指出多余)。
415
- 但只留空态那一个入口又会漏掉「手上有几台 SSH、想再加一台本机」这种常见情况——所以
416
- 第二个入口放进下拉,只有在真的去找机器时才出现。
260
+ 两处互斥(不会同一个入口出现两次),**已经有本机目标时两处都不出现**。做成常驻的工具条按钮不划算:
261
+ **已经有可用目标**时它既占位又招人点,是纯噪音;而只留空态那一个入口又会漏掉「手上有几台 SSH、想再加
262
+ 一台本机」这种常见情况。agent 侧同一件事是 `docker_connect_local`,HTTP 侧是 `POST /connect-local`
263
+ ——所有入口共用宿主里同一份实现。
417
264
 
418
265
  | 情况 | 行为 |
419
266
  | --- | --- |
420
267
  | 已有一条 `kind=local` 目标(无论叫什么名字) | **复用它**(`created:false`),只切过去;不新建、不重写、不改名 |
421
- | 没有本机目标 | 用稳定名 `local` 追加到列表末尾;`local` 已被别的目标占用时依次试 `local-2`、`local-3`…(确定性、不冲突、不覆盖) |
268
+ | 没有本机目标 | 用稳定名 `local` 追加到列表末尾;`local` 已被占用时依次试 `local-2`、`local-3`… |
422
269
  | 已有 SSH / 自定义目标 | **原样保留**(含顺序与凭据);只在末尾追加本机那条 |
423
270
  | 重复点 / 并发点 | 幂等:最终只有一条本机目标 |
424
271
  | 设置存储不可用 / 保存失败 | 明确报错(503 / 500 + 原因),**不改内存配置**——不会出现「界面上加上了、刷新就没了」 |
425
272
 
426
- 写盘成功之后**顺带做一次只读探测**(`docker version`),结果放进响应的 `message`:
427
- daemon 可达时给出服务端版本;不可达时把成因说清楚——**docker CLI 缺失**(去装 Docker,
428
- 或把「docker CLI」改成正确路径)/ **探测超时**(daemon 卡死,或 `docker context` 指向了
429
- 连不上的远端;用 `docker context ls` 看当前指向)/ **daemon 未启动**(Linux
430
- `systemctl start docker`,macOS / Windows 启动 Docker Desktop)/ **socket 无权访问**
431
- (账号加入 `docker` 组),而不是只回一句「没有容器」。探测失败**不算这次操作失败**
432
- (目标确实加上了,`reachable:false` 单独表示),下次 daemon 起来就能直接用。
433
-
434
- 探测用的二进制**就是设置卡片里的「docker CLI」**(`dockerBin`,默认 `docker`)。
435
- CLI 缺失时会**只读地**在 `PATH` 里找一下别的容器 CLI(`docker` → `podman` → `nerdctl`),
436
- 找到就在那句话里点名(「这台机器上装了 podman:把『docker CLI』填成 podman 就能用」)——
437
- **只提示,绝不替你改配置**:静默改写 `dockerBin` 会违背本插件「不替用户决定」的取向,
438
- 而且 podman 与本插件输出格式的兼容性并未逐项验证(见「已知限制」)。比对候选时按
439
- **工具名**(basename)而不是配置里的原字符串,所以 `dockerBin` 写成
440
- `/usr/local/bin/docker` 时不会出现「把 docker 改成 docker」这种废话。
441
-
442
- 超时是**独立一档**:`probe()` 不只报退出码(`code` 为 `null` 时那句「退出码 null」对用户
443
- 零信息量),而是回 `timedOut:true` + 一句可执行的话。超时还会**真的按时返回**——
444
- SIGKILL 只作用于直接子进程,`dockerBin` 指向包装脚本时孙进程仍握着管道,会把手上的
445
- 15 秒上限拖成分钟级(真机实测 3 秒上限拖到 60 秒),所以到点先杀、再给 500ms 收敛期,
446
- 仍不 `close` 就自行放掉管道收尾。
447
-
448
- 刻意**不做**的事(保守边界):不自动安装 Docker、不启动 daemon、不切换 docker
449
- context、不改任何环境变量;空 `targets`(= 用户没配)的语义不变,不产生隐式默认目标;
450
- `clearTargets` 的防误清空逻辑一字未动。这条路径也**与能力开关无关**——它只写插件
451
- 自己的配置,不涉及任何需要授权的目标侧写操作。
273
+ 写盘成功之后**顺带做一次只读探测**(`docker version`),结果放进响应的 `message`:daemon 可达时给出
274
+ 服务端版本;不可达时说清成因——**docker CLI 缺失**(去装 Docker,或把「docker CLI」改成正确路径)/
275
+ **探测超时**(daemon 卡死,或 `docker context` 指向了连不上的远端)/ **daemon 未启动**(Linux
276
+ `systemctl start docker`,macOS / Windows 启动 Docker Desktop)/ **socket 无权访问**(账号加入 `docker`
277
+ 组)。探测失败**不算这次操作失败**(目标确实加上了,`reachable:false` 单独表示)。
278
+
279
+ 探测用的二进制**就是设置卡片里的「docker CLI」**(`dockerBin`,默认 `docker`)。CLI 缺失时会**只读地**
280
+ 在 `PATH` 里找一下别的容器 CLI(`docker` → `podman` → `nerdctl`),找到就在那句话里点名——**只提示,
281
+ 绝不替你改配置**(podman 与本插件输出格式的兼容性并未逐项验证,见「已知限制」)。比对候选时按**工具
282
+ 名**(basename)而不是配置里的原字符串。超时是**独立一档**:回 `timedOut:true` + 一句可执行的话,并且
283
+ **真的按时返回**——到点先杀、再给 500ms 收敛期,仍不 `close` 就自行放掉管道收尾(SIGKILL 只作用于直接
284
+ 子进程,`dockerBin` 指向包装脚本时孙进程会把手上的上限拖成分钟级)。
285
+
286
+ 刻意**不做**的事:不自动安装 Docker、不启动 daemon、不切换 docker context、不改任何环境变量;空
287
+ `targets`(= 用户没配)的语义不变,不产生隐式默认目标;`clearTargets` 的防误清空逻辑一字未动。这条
288
+ 路径也**与能力开关无关**——它只写插件自己的配置,不涉及任何需要授权的目标侧写操作。
452
289
 
453
290
  ### 聚合日志(多选 / Compose 项目)
454
291
 
455
- 多选容器或打开一个 Compose 项目,都能把多个容器的日志聚合成一条流:**一条**
456
- `/logs/stream?ids=…` 装下全部容器(帧里带 `id`;宿主侧仍为每个容器各跑一条
457
- `docker logs -f`),客户端按到达顺序混流。工具栏提供:
292
+ 多选容器或打开一个 Compose 项目,都能把多个容器的日志聚合成一条流:**一条** `/logs/stream?ids=…` 装下
293
+ 全部容器(帧里带 `id`;宿主侧仍为每个容器各跑一条 `docker logs -f`),客户端按到达顺序混流。工具栏提供:
458
294
 
459
295
  | 控件 | 语义 |
460
296
  | --- | --- |
461
297
  | **实时 / 已暂停** | 真暂停(见下) |
462
298
  | **时间戳** | 显示每行时间戳。时间戳**始终随流接收**(`timestamps=1`),只影响显示 |
463
299
  | **按到达 / 按时间** | `按到达`:零延迟跟随;`按时间`:用每行的容器时间戳合并成一条真时间线 |
464
- | **级别过滤** | `全部级别 / WARN+ / ERROR+`。**无级别前缀的行(堆栈等续行)继承上一条日志的级别**,所以 ERROR+ 会连它的堆栈一起保留、INFO 的续行一起滤掉;窗口开头的孤儿续行(记录头在窗口外)无从判断,保留 |
465
- | **⬇ .log / ⬇ .md** | 导出当前显示内容:`.log` 是纯行文本(`[服务] ISO时间 正文`),`.md` 带来源容器 / 行数 / 导出时间表头,可直接当工单附件 |
300
+ | **级别过滤** | `全部级别 / WARN+ / ERROR+`。**无级别前缀的行(堆栈等续行)继承上一条日志的级别**,所以 ERROR+ 会连它的堆栈一起保留;窗口开头的孤儿续行(记录头在窗口外)保留 |
301
+ | **⬇ .log / ⬇ .md** | 导出当前显示内容:`.log` 是纯行文本(`[服务] ISO时间 正文`),`.md` 带来源容器 / 行数 / 导出时间表头 |
466
302
 
467
- **按时间合并怎么做的**:SSE 各容器建连有先后,A 的首屏历史可能整批先到、B 随后才到,
468
- 只对单批排序修不了跨批逆序。实现是**回填最近 400 行**——每批新行到达时把「最近 400 行 +
469
- 新行」整体按时间戳重排(`docker logs --timestamps` 的 RFC3339 前缀为排序依据,解析后从
470
- 正文里剥掉)。这样既不为了让首屏正确而先憋一段时间(不会开页白屏 1 秒),又能事后纠正
471
- 历史错序;代价是「按时间」模式下已在屏上的最近若干行可能轻微重排(正在跟随实时输出时
472
- 建议用「按到达」)。
303
+ **按时间合并怎么做的**:SSE 各容器建连有先后,A 的首屏历史可能整批先到、B 随后才到,只对单批排序修
304
+ 不了跨批逆序。实现是**回填最近 400 行**——每批新行到达时把「最近 400 行 + 新行」整体按时间戳重排
305
+ (RFC3339 前缀为排序依据,解析后从正文里剥掉)。代价是「按时间」模式下已在屏上的最近若干行可能轻微
306
+ 重排(正在跟随实时输出时建议用「按到达」)。
473
307
 
474
308
  ### 聚合日志的「暂停」(真暂停)
475
309
 
476
310
  多选容器 / Compose 项目的聚合日志有一个 `实时 / 已暂停` 开关。**暂停是内容冻结**,不只是停止自动滚动:
477
-
478
- - 暂停期间新到的日志进入客户端缓冲,**DOM 不再追加**——读屏不会被顶走;也不会因为显示上限
479
- (2000 行)裁掉前部而跳屏;
480
- - 按钮上直接显示攒了多少行(`已暂停 +348`);
481
- - 恢复时把缓冲一次性并入(沿用 5000 行环形上限)并回到底部。
482
-
483
- 只停「自动滚动」是不够的:标签写着「已暂停」而内容还在长,用户会以为开关坏了;日志量大时
484
- 画面还会因裁前部而自己跳。
311
+ 暂停期间新到的日志进入客户端缓冲、**DOM 不再追加**(读屏不会被顶走,也不会因显示上限 2000 行裁前部
312
+ 而跳屏);按钮上直接显示攒了多少行(`已暂停 +348`);恢复时把缓冲一次性并入(沿用 5000 行环形上限)
313
+ 并回到底部。只停「自动滚动」是不够的:标签写着「已暂停」而内容还在长,用户会以为开关坏了。
485
314
 
486
315
  ### 总览(跨目标)与「需关注」口径
487
316
 
488
- 「总览」页一屏铺开全部目标:每个目标一张计数卡(运行中 / 已停止 / 不健康 / **需关注**),
489
- 下方是跨目标的「需关注容器」表(容器名 / 目标 / 状态 / **原因** / 镜像,点行进详情、点卡切到
490
- 该目标的列表)。三条设计约束:
317
+ 目标选择器旁的 `总览` pill(配了 ≥2 个目标才出现)一屏铺开全部目标:每个目标一张计数卡(运行中 /
318
+ 已停止 / 不健康 / **需关注**),下方是跨目标的「需关注容器」表(容器名 / 目标 / 状态 / **原因** /
319
+ 镜像,点行进详情、点卡切到该目标的列表)。三条设计约束:
320
+
321
+ - **渐进落地 + 失败隔离**:每个目标独立请求、独立落格;一台 SSH 不可达不会让整页静默(刻意**不用**
322
+ `Promise.all` 汇总:SSH 不可达要等 readyTimeout 20s,汇总等于让整页静默 20s)。
323
+ - **需关注口径以宿主为准**:容器列表与 `/attention` 并行请求;后者额外做一次 `docker inspect`,因此能
324
+ 识别 **OOM(OOMKilled)** 与**真实退出码**——ps 摘要里 `Exited (137)` 分不出是被 OOM 杀还是手动 kill。
325
+ 拿不到 `/attention`(老版本宿主 / 该目标失败)时退回摘要口径,并在计数上标注「需关注(粗判)」。
326
+ - **排序**:OOM > 僵死 > 不健康 > 反复重启 > 非零退出;同权重按「最近一次结束时间」倒序(行 hover 显示
327
+ 结束/启动时间、重启次数、退出码)。
491
328
 
492
- - **渐进落地 + 失败隔离**:每个目标独立请求、独立落格;一台 SSH 不可达不会让整页静默,
493
- 不可达的目标单独出横幅,其余目标结果照常可用。
494
- - **需关注口径以宿主为准**:容器列表与 `/attention` 并行请求;后者额外做一次 `docker inspect`,
495
- 因此能识别 **OOM(OOMKilled)** 与**真实退出码**——ps 摘要里 `Exited (137)` 分不出是被 OOM
496
- 杀还是手动 kill。拿不到 `/attention`(老版本宿主 / 该目标失败)时退回摘要口径,并在
497
- 计数上标注「需关注(粗判)」。
498
- - **排序**:OOM > 僵死 > 不健康 > 反复重启 > 非零退出;同权重按「最近一次结束时间」倒序,
499
- 刚崩的排在最上面(行 hover 显示结束/启动时间、重启次数、退出码)。
329
+ 总览的轮询复用工具条的「自动刷新」开关(默认关):这一页每轮是全 N 个目标各一次 `docker ps`,比任何
330
+ 单目标页都贵。没有异常显示「一切正常」;还有目标没答完时显示「读取中…」,**计数卡也进入读取态**(不出
331
+ 0/0/0——「0 个容器」会被读成「这台机器没容器」,而它只是还没答)。进总览时会清掉单目标遗留的失败横幅:
332
+ 总览按目标归因,不再叠一条单目标的「操作失败」——同一个 SSH 超时被讲两遍,看着像两台机器都挂了。
500
333
 
501
334
  ### 容器诊断(「为什么不行」)
502
335
 
503
- 容器详情多一个**诊断**页签,agent 侧是 `docker_diagnose`。它一次采集四路——`docker inspect`
504
- (权威状态 / 退出码 / OOM / 重启次数 / 健康检查最后输出 / 资源上限)、`docker logs --tail N`、
505
- `docker events --since`(**服务端**按 `--filter container=<id>` 过滤)、`docker stats --no-stream`——
506
- 然后交给一组**纯判据**(`src/diagnose.ts`,零 IO、不读时钟)产出:
336
+ 容器详情多一个**诊断**页签,agent 侧是 `docker_diagnose`。它一次采集四路——`docker inspect`(权威状态 /
337
+ 退出码 / OOM / 重启次数 / 健康检查最后输出 / 资源上限)、`docker logs --tail N`、`docker events --since`
338
+ (**服务端**按 `--filter container=<id>` 过滤)、`docker stats --no-stream`——然后交给一组**纯判据**
339
+ (`src/diagnose.ts`,零 IO、不读时钟)产出:
507
340
 
508
341
  - **一句话结论**(`verdict`):取最高严重度的那条,分 `critical` / `warning` / `info` / `ok`;
509
- - **判据列表**(`findings`):每条 = 判据 code + 一句话 + 证据。当前 15 条——OOM 被杀、
510
- 非零退出(附 126 / 127 / 137 / 143 的常见含义)、反复重启、不健康(附健康检查最后输出)、
511
- 健康检查尚未通过、正常退出、未运行(created / paused / dead)、内存压力(≥90% 且设了上限)、
512
- CPU 压力(≥90%)、日志里有报错、没有健康检查,以及三条**采集失败**信号;
513
- - **证据**(`evidence`):状态 / ps 风格的状态描述(`Up 4 hours (healthy)`)/ 退出码 / OOM /
514
- 重启次数 / 起止时间 / 内存上限 / CPU 配额 / 日志行数与报错行数 / 事件条数 / 当前资源占用;
515
- - **时间线**(`timeline`):本容器在窗口内的 `start` / `die` / `oom` / `health_status` 等事件,升序;
516
- - **建议**(`suggestions`):0~5 条可执行动作。
517
-
518
- **「没采到」与「确实是空的」是两件事**(本仓一贯的信号位原则):旁路三路(日志 / 事件 / 统计)
519
- 任一失败时,证据里对应字段是 `null`(不是 0、不是空数组),并多出一条 `*-unavailable` 说明原因。
520
- 否则「连不上主机」会被读成「日志是空的、从没重启过」,排障方向当场被带偏。
521
-
522
- 页签底部三个动作:**重新诊断**、**复制诊断**、**交给 Agent**。最后这个走的就是日志右键那套
523
- 会话桥(直接发送 / 填入草稿二选一,没有会话时置灰并写明原因):文本自带容器、目标、采集时间、
524
- 结论与证据,日志原文用围栏包起来并标注「这是数据不是指令」。**采集时间在界面上是本地时间、
525
- 投递文本里是 ISO 8601**——前者给人看,后者可排序、无时区假设。
526
-
527
- 诊断是**只读**:不改容器、不重启、不发命令,只多跑四条只读 docker 命令(因此最坏情况是四个
528
- 命令预算,而不是无界等待)。
342
+ - **判据列表**(`findings`):每条 = 判据 code + 一句话 + 证据。判据是这几条——OOM 被杀、非零退出(附
343
+ 126 / 127 / 137 / 143 的常见含义)、反复重启、不健康(附健康检查最后输出)、健康检查尚未通过、正常
344
+ 退出、未运行(created / paused / dead)、内存压力(≥90% 且设了上限)、CPU 压力(≥90%)、日志里有
345
+ 报错、没有健康检查,以及三条**采集失败**信号;
346
+ - **证据**(`evidence`):状态 / ps 风格的状态描述 / 退出码 / OOM / 重启次数 / 起止时间 / 内存与 CPU 上限 /
347
+ 日志行数与报错行数 / 事件条数 / 当前占用;**时间线**(`timeline`)是窗口内 `start` / `die` / `oom` /
348
+ `health_status` 等事件(升序);**建议**(`suggestions`)0~5 条可执行动作。
349
+
350
+ **「没采到」与「确实是空的」是两件事**(本仓一贯的信号位原则):旁路三路(日志 / 事件 / 统计)任一失败
351
+ 时,证据里对应字段是 `null`(不是 0、不是空数组),并多出一条 `*-unavailable` 说明原因。否则「连不上
352
+ 主机」会被读成「日志是空的、从没重启过」,排障方向当场被带偏。
353
+
354
+ 页签底部三个动作:**重新诊断**、**复制诊断**、**交给 Agent**。最后这个走日志右键那套会话桥(直接发送 /
355
+ 填入草稿二选一,没有会话时置灰并写明原因):文本自带容器、目标、采集时间、结论与证据,日志原文用围栏
356
+ 包起来并标注「这是数据不是指令」。**采集时间在界面上是本地时间、投递文本里是 ISO 8601**。
357
+
358
+ 诊断是**只读**:不改容器、不重启、不发命令,只多跑四条只读 docker 命令。
529
359
 
530
360
  ### 诊断历史(它是不是在慢慢变坏)
531
361
 
532
- 单次诊断只回答「现在怎么样」;**缓慢变坏**(内存一点点涨、重启越来越频繁、报错逐渐变多)在单次
533
- 结果里看不出来。所以每次诊断都会落一个**样本**,诊断页据此多一块「历史」:
362
+ 单次诊断只回答「现在怎么样」;**缓慢变坏**(内存一点点涨、重启越来越频繁、报错逐渐变多)在单次结果里
363
+ 看不出来。所以每次诊断都会落一个**样本**,诊断页据此多一块「历史」:
534
364
 
535
365
  - **色带**:最近 N 次的严重度(悬停显示时间与结论 code);
536
- - **趋势结论**(`trend.verdict`):七条判据取最高的一条——反复重启(重启次数涨 ≥2)、反复 OOM
537
- (≥2 次)、内存爬升(≥20 个百分点且末值 ≥80%)、报错增长(≥5 行或从 0 变正)、新出现严重判据
538
- (`oom-killed` / `crash-loop` / `unhealthy` / `exit-nonzero`)、严重度连续走高、没有明显变化;
539
- 样本不足 2 次时明说「还不够形成趋势」;
540
- - **数字变化**(`trend.numbers`):只列**真的变了**的项,且每项有「最小有意义变化」——重启 ≥1、
541
- 内存 ≥1 个百分点、CPU ≥5 个百分点、报错 ≥3 行、事件 ≥5 条、日志行数 ≥50。这组阈值是真机
542
- 逼出来的:诊断只取一个 `stats` 瞬时快照,1.11% → 1.09% 这种抖动不设闸就会把趋势块填满;
543
- 报错行数则是**滑动窗口**(一条老报错滑出去就 −1),所以给到 3 行才算变化;
366
+ - **趋势结论**(`trend.verdict`):七条判据取最高的一条——反复重启(重启次数涨 ≥2)、反复 OOM(≥2 次)、
367
+ 内存爬升(≥20 个百分点且末值 ≥80%)、报错增长(≥5 行或从 0 变正)、新出现严重判据(`oom-killed` /
368
+ `crash-loop` / `unhealthy` / `exit-nonzero`)、严重度连续走高、没有明显变化;样本不足 2 次时明说
369
+ 「还不够形成趋势」;
370
+ - **数字变化**(`trend.numbers`):只列**真的变了**的项,且每项有「最小有意义变化」——重启 ≥1、内存
371
+ ≥1 个百分点、CPU ≥5 个百分点、报错 ≥3 行、事件 ≥5 条、日志行数 ≥50。诊断只取一个 `stats` 瞬时快照,
372
+ 1.11% → 1.09% 这种抖动不设闸就会把趋势块填满;报错行数是**滑动窗口**,所以给到 3 行才算变化;
544
373
  - **判据增减**:「新出现」/「已消失」两行(`oom-killed` 是新出现的,那就是恶化信号)。
545
374
 
546
- **存什么、不存什么**(刻意的取舍):样本**只存结论与数字**——严重度、结论 code、判据 code 列表、
547
- 状态 / 退出码 / OOM / 重启次数 / 内存与 CPU 占用 / 报错行数 / 事件条数 / 日志行数 / 时间。
548
- **绝不落盘**:日志原文(`logTail`)与每条判据的 detail(里面会嵌日志行,可能带凭证)。所以这份文件
549
- 可以放心 `cat`,但它也因此**不能**用来回放某次排障现场——要原文就重新诊断一次。
375
+ **存什么、不存什么**:样本**只存结论与数字**(严重度、结论 code、判据 code 列表、状态 / 退出码 / OOM /
376
+ 重启次数 / 内存与 CPU 占用 / 报错行数 / 事件条数 / 日志行数 / 时间)。**绝不落盘**:日志原文(`logTail`)
377
+ 与每条判据的 detail(里面会嵌日志行,可能带凭证)——所以这份文件可以放心 `cat`,但它也因此**不能**用来
378
+ 回放某次排障现场。
550
379
 
551
- 落盘:`<DSH 主目录>/dsh-docker/diagnosis-history.jsonl`(目录 `0700`、文件 `0600`),一行一条。
552
- 上限:**每个容器 50 条**、整文件 **2000 条**,超了丢最旧并**原子重写**(临时文件 + rename)。
553
- **30 秒去重**:同一容器、30 秒内、且严重度 + 结论 + 判据集合 + 重启次数完全相同时**不追加**
554
- (连点「重新诊断」不该在趋势里制造假波动),界面会写明「本次与上次一致,30 秒内不重复记录」。
555
- `DSH_DOCKER_HISTORY_DIR` 可覆写目录(**测试 / 排障专用**),空串视为未设。
556
-
557
- 三条已知边界:① **容器改名 = 开一段新历史**(索引键是「目标 + 容器名」)——比让两段历史混在一起、
558
- 趋势说出没发生过的变化好;② **并发追加没加锁**(两个诊断同时写可能丢一次更新),历史是辅助信息,
559
- 为它加文件锁不值;③ **写盘失败只降级不报错**:诊断照常返回、`recorded = false`、宿主 stdout 留一行。
380
+ 落盘:`<DSH 主目录>/dsh-docker/diagnosis-history.jsonl`(目录 `0700`、文件 `0600`),一行一条。上限:
381
+ **每个容器 50 条**、整文件 **2000 条**,超了丢最旧并**原子重写**(临时文件 + rename)。**30 秒去重**:同一
382
+ 容器、30 秒内、且严重度 + 结论 + 判据集合 + 重启次数完全相同时**不追加**。`DSH_DOCKER_HISTORY_DIR` 可
383
+ 覆写目录(测试 / 排障专用),空串视为未设。三条边界:① **容器改名 = 开一段新历史**(索引键是「目标 +
384
+ 容器名」);② **并发追加没加锁**(可能丢一次更新);③ **写盘失败只降级不报错**:诊断照常返回、
385
+ `recorded = false`、宿主 stdout 留一行。
560
386
 
561
387
  ### 磁盘与可回收(存储治理)
562
388
 
563
- **磁盘**是工具条分段的最后一段(容器 / 镜像 / Compose / 网络 / 卷 / 磁盘),agent 侧是 `docker_disk_usage`。
564
- 数据来自两条命令:`docker system df`(四类汇总)与 `docker system df -v`(逐项明细)。
389
+ **磁盘**是工具条分段的最后一段(容器 / 镜像 / Compose / 网络 / 卷 / 磁盘),agent 侧是
390
+ `docker_disk_usage`。数据来自两条命令:`docker system df`(四类汇总)与 `docker system df -v`(逐项明细)。
565
391
 
566
- - 四张汇总卡:镜像 / 容器 / 本地卷 / 构建缓存各自的**条目数、活跃数、占用、可回收**
567
- (分类名保留 docker 原文,界面负责翻译);
392
+ - 四张汇总卡:镜像 / 容器 / 本地卷 / 构建缓存各自的**条目数、活跃数、占用、可回收**(分类名保留 docker
393
+ 原文,界面负责翻译);
568
394
  - **合计可回收**:四类 `reclaimable` 的字节和(任一行解析不出就给 `—`,宁可不说也不给错数);
569
- - **镜像明细**(按独占大小降序):仓库:标签 / 大小 / 独占 / **容器数**——`containers === 0`
570
- 打「未使用」徽标,行尾删除图标(受 `allowMutations` 门、走既有确认框);
395
+ - **镜像明细**(按独占大小降序):仓库:标签 / 大小 / 独占 / **容器数**——`containers === 0` 打「未使用」
396
+ 徽标,行尾删除图标(受 `allowMutations` 门、走既有确认框);
571
397
  - **卷明细**(按大小降序):名称 / 驱动 / 大小 / **链接数**——`links === 0` 同样打「未使用」。
572
398
 
573
- 三条刻意的取舍:
574
-
575
- - **先排序再截断**:真机实测 259 个卷里 258 个是 0B,先截断会把 100 个名额全给空卷,明细表
576
- 看起来「全是没用的」而真正占空间的反而没进来;
577
- - **明细上限 100 行**,超了在表头写「明细已截断,只显示前 100 行」(工具侧另按 20 行截,
578
- 两个 `truncated` 取或)——截断要留痕,不静默少列;
579
- - **老 docker 只降级不报错**:不支持 `-v --format '{{json .}}'`、或输出被截断/形状不对时,
580
- `degraded: true` + 一句人话原因,汇总卡照常显示、明细表**不渲染**(渲染空表会让人以为「没有」)。
399
+ 三条刻意的取舍:**先排序再截断**(真机实测 259 个卷里 258 个是 0B,先截断会把 100 个名额全给空卷);
400
+ **明细上限 100 行**并在表头留痕(工具侧另按 20 行截,两个 `truncated` 取或);**老 docker 只降级不报错**
401
+ (不支持 `-v --format '{{json .}}'` 或输出形状不对时 `degraded: true` + 人话原因,汇总卡照常显示、明细表
402
+ **不渲染**——渲染空表会让人以为「没有」)。
581
403
 
582
- 磁盘页**不轮询**(`system df -v` 比 `docker images` 贵),也**没有 prune 按钮**:逐行删除 +
583
- 确认框比「一键清空一类」安全,而那类入口已经在镜像 / 卷页的工具条上各有一个。
404
+ 磁盘页**不轮询**(`system df -v` 比 `docker images` 贵),也**没有 prune 按钮**:逐行删除 + 确认框比
405
+ 「一键清空一类」安全,而那类入口已经在镜像 / 卷页的工具条上各有一个。
584
406
 
585
407
  ### 等价 `docker run`(现场还原)
586
408
 
587
- 概览页底部有一块可折叠的**等价 docker run**,agent 侧是 `docker_run_command`。它从 `docker inspect`
588
- 的**原始行**反推一条可复现命令——**不扩 `ContainerDetail`**,因为那会把 env 顺带灌进
589
- `docker_inspect` 的模型上下文。覆盖:名称、重启策略、端口映射(没绑 hostPort 的用 `--expose`)、
590
- bind / 命名卷 / tmpfs、非默认网络、环境变量、用户 / 工作目录 / 主机名(等于默认值时省略)、
591
- 内存与 CPU 上限、cap-add / cap-drop / device,以及镜像与命令本身。
409
+ 概览页底部有一块可折叠的**等价 docker run**,agent 侧是 `docker_run_command`。它从 `docker inspect` 的
410
+ **原始行**反推一条可复现命令——**不扩 `ContainerDetail`**,因为那会把 env 顺带灌进 `docker_inspect` 的
411
+ 模型上下文。覆盖:名称、重启策略、端口映射(没绑 hostPort 的用 `--expose`)、bind / 命名卷 / tmpfs、
412
+ 非默认网络、环境变量、用户 / 工作目录 / 主机名(等于默认值时省略)、内存与 CPU 上限、cap-add / cap-drop /
413
+ device,以及镜像与命令本身。**点开才请求**,一行一个参数、POSIX 单引号转义,可直接复制。
592
414
 
593
- **点开才请求**(折叠区展开才打 `/run-command`),一行一个参数、POSIX 单引号转义,可直接复制。
594
-
595
- 三条刻意的取舍:
596
-
597
- - **环境变量原值保留**(改写过就不是等价命令),命中
598
- `/(PASS|PWD|SECRET|TOKEN|KEY|CRED|AUTH|PRIVATE)/i` 的**键名**收进 `secretKeys`:界面在命令上方
599
- 给一条「含疑似凭证」的警告,**只点键名、不显示值**;命令本身不脱敏——脱敏后的命令跑不起来,
600
- 而它的用途正是「拿出去跑」;
601
- - **故意没还原的必须点名**(`omitted`):`--privileged`、`--link`、非默认日志驱动、
602
- `--pid/--ipc/--uts`、`--sysctl`、`--add-host`、`--security-opt`、特殊挂载类型、匿名卷、
603
- ENTRYPOINT 覆盖、自定义健康检查——逐条给出「为什么没写」与「怎么自己补上」,**绝不静默丢**;
604
- - **compose 管理的容器只是近似**:网络别名 / labels / depends_on 不在命令里,界面直说。
415
+ 三条刻意的取舍:**环境变量原值保留**(改写过就不是等价命令),命中
416
+ `/(PASS|PWD|SECRET|TOKEN|KEY|CRED|AUTH|PRIVATE)/i` 的**键名**收进 `secretKeys`——界面在命令上方给一条
417
+ 「含疑似凭证」的警告,**只点键名、不显示值**,命令本身不脱敏(脱敏后的命令跑不起来);**故意没还原的
418
+ 必须点名**(`omitted`:`--privileged`、`--link`、非默认日志驱动、`--pid/--ipc/--uts`、`--sysctl`、
419
+ `--add-host`、`--security-opt`、特殊挂载类型、匿名卷、ENTRYPOINT 覆盖、自定义健康检查——逐条给出「为什么
420
+ 没写」与「怎么补」,**绝不静默丢**);**compose 管理的容器只是近似**(网络别名 / labels / depends_on
421
+ 不在命令里,界面直说)。
605
422
 
606
423
  ## 配置(保存即热生效)
607
424
 
608
- 未获宿主授权时,设置卡片里那两个开关**点它会就地发起一次授权**:面板给出一条「在宿主终端执行」
609
- 的命令,执行完十秒内自动解锁并替你打开开关(免重启);面板里另有「另一种方式(最强)」说明
610
- 启动环境变量的做法。已授权时它们就是普通开关,**关掉**永远可用(宿主侧不拦降权)。
611
- 「配置开着但没授权」会显示「未生效:未获宿主授权」徽标——这个状态最容易被当成插件坏了,
612
- 所以必须有个名字。**一个能力一行**:开关后面跟这个能力自己的授权状态与**授权时刻**(`已授权 · 2026-09-26 22:31:08`),
613
- 撤销按钮右对齐——两个能力的授权是分开的,并排放会看不出哪个撤销管哪个能力。
614
-
615
- **两个能力的确认也能一次粘贴搞定**:面板开着时再点另一个开关不会顶掉前一块面板(两个能力的
616
- 授权本来就互不影响),两块面板**纵向堆叠**,顶部多出一个「复制全部(N 条)」——它把仍 pending 的
617
- 命令按**发起顺序**用**换行符**连接后一次复制,用户去宿主终端**粘贴一次**即可:`bash` / `zsh` /
618
- PowerShell / cmd 粘贴多行都逐行执行,所以刻意不用 `&&`(PowerShell 5.1 不认)或 `;`(cmd 不认)
619
- 连接。某条过期后,合并按钮只复制仍 pending 的那些;全部解锁(或全部收起)后它自己消失。两个
620
- challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后全部开关自动解锁」、**不承诺同一时刻**
621
- ——倒计时仍是逐面板显示,每块面板的复制 / 重新生成 / 收起也照旧只管自己那一个能力。
622
-
623
- 为什么显示时刻:带外授权是**持久**的——宿主重启后它**直接生效、不再有任何一次确认**。也就是说
624
- 「上个月授权的能力今天一开机就开着」这件事不会有任何提示,除非界面与日志说出来。所以宿主启动时会
625
- 逐条打一行 `[dsh-docker] elevation: load capability=… via=file grantedAt=…`(不含 nonce 与路径),
626
- 卡片上也把它显示出来。启动环境变量授权**没有**时刻(它就是启动环境的一部分,编一个假时间更糟),
627
- 那一行显示的是「由启动环境变量授权;要撤销需在启动环境里去掉它并重启宿主」。
425
+ 未获宿主授权时,设置卡片里那两个开关**点它会就地发起一次授权**:面板给出一条「在宿主终端执行」的命令,
426
+ 执行完十秒内自动解锁并替你打开开关(免重启);面板里另有「另一种方式(最强)」说明启动环境变量的做法。
427
+ 已授权时它们就是普通开关,**关掉**永远可用(宿主侧不拦降权)。「配置开着但没授权」会显示「未生效:未获
428
+ 宿主授权」徽标——这个状态最容易被当成插件坏了,所以必须有个名字。**一个能力一行**:开关后面跟这个能力
429
+ 自己的授权状态与**授权时刻**(`已授权 · 2026-09-26 22:31:08`),撤销按钮右对齐。
628
430
 
629
- ![设置卡片:目标 CRUD、能力开关与参数,保存即热生效](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-setting.png)
431
+ **两个能力的确认也能一次粘贴搞定**:再点另一个开关不会顶掉前一块面板,两块面板**纵向堆叠**,顶部多出
432
+ 一个「复制全部(N 条)」。
630
433
 
631
- 设置面在宿主里的**位置**随 DSH 版本变,但**始终是同一份表单**(同一个 settings 命名空间、
632
- 同一个写入通道),配置数据与行为逐字节相同:
434
+ 为什么显示时刻:带外授权是**持久**的——宿主重启后它直接生效、不再有任何一次确认,所以宿主启动时会逐条
435
+ 打一行 `[dsh-docker] elevation: load capability=… via=file grantedAt=…`(不含 nonce 与路径),卡片上
436
+ 也显示出来。启动环境变量授权**没有**时刻,那一行写的是「由启动环境变量授权;要撤销需在启动环境里去掉它
437
+ 并重启宿主」。
633
438
 
634
- | 宿主 | 设置面在哪 |
635
- |---|---|
636
- | `0.2.0-rc.1` 起 | **插件详情页「说明」正下方**(`plugins.bundle.config`,key = **本包自己的** bundle 包名)——设置就在详情页里,不必再点一次「>」进二级页 |
637
- | `0.1.6` 线 | 侧边栏「插件」→ 该 bundle 的**行** → 「>」行详情(`plugins.row.config`) |
638
- | 以上各版 | 设置 → **插件配置** 里的同一条目(`settings.kit.item`,由 `@hyzyn/dsh-kit-settings` 提供) |
439
+ ![设置卡片:目标 CRUD、能力开关与参数,保存即热生效](https://cdn.jsdelivr.net/gh/hyzyn/dsh-plugin-kit@main/docs/dsh-plugin-kit-docker-setting.png)
639
440
 
640
- 新宿主上 bundle 槽可用时**不再注册本包那份 row 槽**——同一份表单两个入口只会让人以为有两套设置;
641
- 旧宿主(bundle 槽不存在)自动回退到行详情,功能一点不减。**聚合包 `@hyzyn/dsh-all` 例外**:它的
642
- `plugins.bundle.config` key 是所有插件共享的(重复注册会直接抛错),所以挂不了内联位,它在聚合
643
- 安装下继续走 row 入口。
441
+ 设置面在宿主里的**位置**随 DSH 版本变,但**始终是同一份表单**(同一个 settings 命名空间、同一条写入
442
+ 通道,配置数据与行为完全相同)——各代际的落点与四条注册契约的唯一归宿在
443
+ [conventions.md § 客户端设置面](../../docs/conventions.md#客户端设置面内联优先)。
644
444
 
645
- 配置落在 settings 命名空间 `docker`,即 `~/.dsh/settings.yaml` 的 `docker:`
646
- 段(`$DSH_HOME/settings.yaml`;DSH 的 settings 文件由宿主 `dsh-settings-file`
647
- 提供)。插件行里的 composition 配置作为 schema `base` 打底,settings 层覆盖
648
- 它;HTTP `POST /api/dsh-docker/config` 是卡片的写入通道,只接受下表这些键
649
- (未知键返回 400)。
445
+ 配置落在 settings 命名空间 `docker`,即 `~/.dsh/settings.yaml` 的 `docker:` 段
446
+ (`$DSH_HOME/settings.yaml`)。插件行里的 composition 配置作为 schema `base` 打底,settings 层覆盖它;
447
+ HTTP `POST /api/dsh-docker/config` 是卡片的写入通道,只接受下表这些键(未知键返回 400)。
650
448
 
651
449
  | 项 | 默认 | 说明 |
652
450
  | --- | --- | --- |
653
451
  | `enabled` | true | 关闭整个插件(**保存即热生效**:工具立即注销、公告撤下、除 `/config` 外的数据路由 403;`/config` 始终可读写——设置卡片就是重新启用的入口。与 tty 不同:tty 是重启生效) |
654
452
  | `announceToAgent` | true | 是否向 agent 注入能力公告(systemPrompt section `plugin:dsh-docker`) |
655
- | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
453
+ | `dockerBin` | `docker` | docker CLI 可执行名或路径(podman 可填 `podman`);只允许字母、数字与 `_ . / \ : -` 及内部空格,且不能以 `-` 开头(**Windows 盘符与 `\` 必须放行**)。**「连接本机」的探测用的就是它**;填错时那句话会点名 `PATH` 里找到的别的容器 CLI,但**不会**替你改这个值 |
656
454
  | `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,对应工具不注册) |
657
- | —(能力授权) | 未授权 | `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-一条请求经过什么) |
455
+ | —(能力授权) | 未授权 | `allowMutations` / `allowExec` 有**两条提权通道**:① **启动环境变量**(`DSH_DOCKER_ALLOW_MUTATIONS` / `DSH_DOCKER_ALLOW_EXEC`,值为 `1` / `true` / `yes` / `on`)——判定源是宿主的**启动环境快照**,只认继承来的 `process` 层:写项目 `.env` 或 `~/.dsh/env.yml` **不算**授权;② **就地提权**(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效。HTTP 侧永远可以**关掉**它们(紧急刹车不能依赖重启),但给 `true` 而无授权会被 400 拒绝并说清两条路。配置里的 `true` **不算授权**(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。**升级影响**:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。**为什么**:回环围栏与同源证明都拦不住跨站页面与页内脚本,而 docker socket 等价目标主机 root;细节(含拦不住谁)见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么) |
658
456
  | `allowExec` | false | 允许**一次性** `docker exec`(**容器详情抽屉**「概览」页签里的一次性命令框,与 `docker_exec` 工具;关闭时 `/exec` 返回 403)。**不管**卡片「终端」按钮的 `docker exec -it` 交互式 shell——那条由 tty 承载(见「已知限制」) |
659
457
  | `execTimeoutSec` | 30 | exec 默认超时秒数(1~120) |
660
458
  | `pollIntervalSec` | 5 | 面板统计刷新间隔秒数(1~60) |
@@ -681,17 +479,12 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
681
479
  | `passphrase` | `''` | 私钥口令;**建议填 `env:NAME`** 凭据引用 |
682
480
  | `agentForward` | false | 是否转发本机 ssh-agent(`SSH_AUTH_SOCK` 存在时生效) |
683
481
 
684
- `password` / `passphrase` 里的 `env:NAME` 是一个**凭据引用**(这正是官方模式:配置只持引用、
685
- 值归 provider),**连接时才解析**,顺序是:
686
-
687
- 1. **官方凭据层**(`ctx.credentials`,由 `@deepseek-ai/dsh-credentials` 提供)——它会叠
688
- `file`(`$DSH_HOME/.credentials.yaml`)/ `env` / `project-env` / `user-env` 各层,且
689
- 「每次操作重新解析」,所以**改完下一个操作即生效、不必重启宿主**;
690
- 2. 服务不可用(老宿主 / 未装该 bundle)或它没有这个引用时,退回 `process.env[NAME]`。
691
-
692
- 两边都没有会明确报错并**点名两个来源**(服务报错也一并带上——否则"凭据服务坏了"会伪装成
693
- "你没配",那是最难查的一类)。这两个值**永不回传浏览器**:
694
- 配置快照里只给 `passwordSet` / `passphraseSet` 两个布尔位。
482
+ `password` / `passphrase` 里的 `env:NAME` 是**凭据引用**(配置只持引用、值归 provider),**连接时才
483
+ 解析**:① **官方凭据层**(`ctx.credentials`)——叠 `file`(`$DSH_HOME/.credentials.yaml`)/ `env` /
484
+ `project-env` / `user-env` 各层,且「每次操作重新解析」,所以**改完下一个操作即生效、不必重启宿主**;
485
+ ② 服务不可用(老宿主 / 未装该 bundle)或它没有这个引用时,退回 `process.env[NAME]`。两边都没有会明确
486
+ 报错并**点名两个来源**(服务报错也一并带上——否则"凭据服务坏了"会伪装成"你没配")。这两个值**永不回传
487
+ 浏览器**:配置快照里只给 `passwordSet` / `passphraseSet` 两个布尔位。
695
488
 
696
489
  ### `hostKeys[]`(SSH 主机指纹,TOFU)
697
490
 
@@ -702,108 +495,100 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
702
495
  | `fingerprints` | **指纹集合**(必填,至少一条):`hostHash: 'sha256'` 回调收到的原样十六进制指纹。同一 host:port 可含多把主机密钥(rsa / ed25519 …),命中任意一条即视为匹配 |
703
496
  | `fingerprint` | 旧版单指纹字段(字符串):只作迁移输入,清洗时并入 `fingerprints`,新配置不要再用 |
704
497
 
705
- 首次连接自动记录并落盘;之后每次连接必须匹配,**指纹变更直接拒绝连接**,
706
- 错误信息带「删除该主机记录再重连」的指引。记录列表在设置卡片里可删除重置
707
- (删除会以显式的 `hostKeysRemove: [{host, port}]` 提交,不会被并集合并悄悄撤销)。
498
+ 首次连接自动记录并落盘;之后每次连接必须匹配,**指纹变更直接拒绝连接**,错误信息带「删除该主机记录再
499
+ 重连」的指引。记录列表在设置卡片里可删除重置(删除会以显式的 `hostKeysRemove: [{host, port}]` 提交,
500
+ 不会被并集合并悄悄撤销)。
708
501
 
709
502
  ## agent 工具
710
503
 
711
504
  | 工具 | 注册条件 | 参数 | 作用 / 典型用法 |
712
505
  | --- | --- | --- | --- |
713
- | `docker_targets` | 恒注册 | `probe?: boolean` | 列出目标(name / kind / label);`probe:true` 逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)。其他工具的 `target` 取自这里 |
714
- | `docker_connect_local` | 恒注册 | —(无参数) | **连接本机**:确保存在一条 `kind=local` 目标并选中它(已存在则复用,不重复创建;SSH / 自定义目标原样保留),随后只读探测 daemon:返回 `{name, kind, created, saved, reachable, serverVersion?}`。没有任何目标时其余工具不注册(见下方「零目标」那条),用它一步补上。**不需要任何能力授权**(只写插件自己的配置 + 只读探测);不会安装 Docker / 启动 daemon / 切 context / 改环境变量。daemon 不可达时在结果文本里说清成因(CLI 缺失 / 探测超时 / daemon 未启动 / socket 无权;CLI 缺失时还会点名 PATH 里找到的别的容器 CLI,但不会替你改 dockerBin),不是只报空列表 |
715
- | `docker_ps` | 有目标即注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean`、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(同一次 `-p` 不再出现两遍,D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
716
- | `docker_attention` | 有目标即注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
717
- | `docker_diagnose` | 有目标即注册 | `target?`、`id`(必填)、`logTail?`(1~500,默认 80)、`since?`(docker 语法,默认 `1h`)、`history?`(0~50,默认 10,0 = 不要历史) | **容器「为什么不行」**:一次采集 inspect + 日志尾部 + 本容器事件(服务端 `--filter container`)+ `stats` 快照,产出 `verdict`(一句话结论)/ `findings`(判据表,每条带 code、severity、title、detail)/ `evidence`(退出码、OOM、重启次数、内存与 CPU 上限、日志与事件条数、当前占用)/ `timeline` / `suggestions`。**只读**;「没采到」给 `null` 并附 `*-unavailable` 判据,绝不写成 0。要更多日志 / 找关键词用 `docker_logs_grep`。返回里还带 `history`(最近 N 个**只含结论与数字**的样本 + 趋势结论 `trend`)——`trend.verdict.summary` 就是「它是不是在慢慢变坏」那一句 |
506
+ | `docker_targets` | 恒注册 | `probe?: boolean` | 列出目标(name / kind / label),**SSH 目标还会带出它引用的 tty 连接簿条目名 `book`**(内联 host/username 的目标没有这个字段;用它去终端面板 / `sftp_*` 的 `book`,名字对不上时只能靠它);`probe:true` 逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)。其他工具的 `target` 取自这里 |
507
+ | `docker_connect_local` | 恒注册 | —(无参数) | **连接本机**:确保存在一条 `kind=local` 目标并选中它(已存在则复用,不重复创建;SSH / 自定义目标原样保留),随后只读探测 daemon:返回 `{name, kind, created, saved, reachable, serverVersion?}`。没有任何目标时其余工具不注册(见下方「零目标」那条),用它一步补上。**不需要任何能力授权**;不会安装 Docker / 启动 daemon / 切 context / 改环境变量。daemon 不可达时说清成因(CLI 缺失 / 探测超时 / daemon 未启动 / socket 无权),不是只报空列表 |
508
+ | `docker_ps` | 有目标即注册 | `target?`(**传 `*` = 全部目标**)、`all?: boolean`、`name?`(容器名子串过滤)、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 列容器(名称 / 状态 / 健康 / 镜像 / 端口 / compose 项目与服务 / 短 ID);默认只列运行中,`all:true` 含已停止。`name` 是**服务端过滤**(`docker ps --filter name=`,子串匹配),过滤先于分页,翻页时总数不会漂。`target:'*'` 时按目标分组返回,**单个目标不可达不影响其他目标**(该组带 `error`)。端口是 IPv4/IPv6 双栈归并后的映射(D130);`ports` 为空**不等于**没暴露端口——host 网络容器的端口即宿主机端口,这种情况会给 `net` 字段(D131)。排障第一步 |
509
+ | `docker_attention` | 有目标即注册 | `target?`(支持 `*`)、`limit?: number` | **需关注汇总**:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 `reasons`、`exitCode`、`oomKilled`、`restartCount`。OOM 与真实退出码来自一次 `docker inspect`。排障入口:不确定从哪台/哪个容器看起时先调它 |
510
+ | `docker_diagnose` | 有目标即注册 | `target?`、`id`(必填)、`logTail?`(1~500,默认 80)、`since?`(docker 语法,默认 `1h`)、`history?`(0~50,默认 10,0 = 不要历史) | **容器「为什么不行」**:一次采集 inspect + 日志尾部 + 本容器事件(服务端 `--filter container`)+ `stats` 快照,产出 `verdict` / `findings`(每条带 code、severity、title、detail)/ `evidence` / `timeline` / `suggestions`。**只读**;「没采到」给 `null` 并附 `*-unavailable` 判据,绝不写成 0。要更多日志 / 找关键词用 `docker_logs_grep`。返回里还带 `history`(最近 N 个**只含结论与数字**的样本 + 趋势结论 `trend`)——`trend.verdict.summary` 就是「它是不是在慢慢变坏」那一句 |
718
511
  | `docker_inspect` | 有目标即注册 | `target?`、`id`(必填) | `docker inspect` 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
719
- | `docker_logs` | 有目标即注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?`、`until?` | `docker logs --tail` 尾部;`since` / `until` 用 docker 语法(如 `10m`、`2026-09-09T10:00:00`)圈时间窗;超上限标记 `truncated`;截断时(`truncated`)结果里会说明可传更小的 `tail`、加 `since`/`until`,或改用 `docker_logs_grep` |
720
- | `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` 里每一行都是完整的行);返回行数另有上限:最多 4000 行(`MAX_RESULT_LINES`),超了截到上限并给 `truncatedForSize` 与 `notes`(缩小 `limit` / 减小 `context` / 加 `since`·`until` 圈时间窗),`matched` 仍报全量。与 `docker_logs` 的分工:报错就在最近尾部 → `docker_logs`(便宜);埋在深处 / 特定时间窗 / 找关键词、异常码、请求 ID → 本工具。默认字面子串(grep -F 语义,零 ReDoS),`regexp: true` 才按 JS 正则 |
512
+ | `docker_logs` | 有目标即注册 | `target?`、`id`、`tail?`(1~5000,默认 `logTailDefault`)、`timestamps?`、`since?`、`until?` | `docker logs --tail` 尾部;`since` / `until` 用 docker 语法圈时间窗;超上限标记 `truncated`;截断时结果里会说明可传更小的 `tail`、加 `since`/`until`,或改用 `docker_logs_grep` |
513
+ | `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`——「只扫到这些」与「扫完了没更多」可区分;扫描窗口**对齐到行边界**(按字节截断处的半行不当作一行返回);返回行数另有上限:最多 4000 行(`MAX_RESULT_LINES`),超了截到上限并给 `truncatedForSize` 与 `notes`,`matched` 仍报全量。与 `docker_logs` 的分工:报错就在最近尾部 → `docker_logs`(便宜);埋在深处 / 特定时间窗 / 找关键词、异常码、请求 ID → 本工具。默认字面子串(grep -F 语义,零 ReDoS),`regexp: true` 才按 JS 正则 |
721
514
  | `docker_stats` | 有目标即注册 | `target?`、`ids?`(逗号分隔的容器名/ID) | `docker stats --no-stream` 快照:CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs;`ids` 省略 = 全部运行中容器。实时跟随是面板能力(SSE),工具保持单值快照语义 |
722
515
  | `docker_images` | 有目标即注册 | `target?`、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 镜像列表(仓库:标签 / 大小 / 创建时间 / 短 ID) |
723
516
  | `docker_top` | 有目标即注册 | `target?`、`id`(必填) | 容器内进程快照(`docker top <id> -eo pid,ppid,user,stat,etime,pcpu,pmem,cmd`):PID / PPID / 用户 / STAT / 已运行 / CPU / 内存 / 命令,字段全是 docker 打印的原文。容器「还在跑」与「进程还在」是两件事 |
724
- | `docker_events` | 有目标即注册 | `target?`、`since?`(docker `--since` 语法,默认 `10m`) | 容器事件快照(`docker events --since <d> --until <now>`,同样过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,`exec_*` 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
725
- | `docker_networks` | 有目标即注册 | `target?`、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 网络列表(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表不进列表行——详情页会连坐 inspect,列表逐行 inspect 就是 N 次 docker 调用 |
517
+ | `docker_events` | 有目标即注册 | `target?`、`since?`(docker `--since` 语法,默认 `10m`) | 容器事件快照(过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,`exec_*` 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
518
+ | `docker_networks` | 有目标即注册 | `target?`、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 网络列表(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表不进列表行——列表逐行 inspect 就是 N 次 docker 调用 |
726
519
  | `docker_volumes` | 有目标即注册 | `target?`、`limit?`(1~500,默认 100)、`offset?`(默认 0) | 卷列表(名称 / 驱动 / 范围 / 挂载点) |
727
- | `docker_disk_usage` | 有目标即注册 | `target?` | 磁盘占用与**「谁在用」**:`docker system df` 四类汇总(条目 / 活跃 / 占用 / 可回收)+ `-v` 明细(镜像的 `containers`、卷的 `links`,各截 20 行)。删之前先看 `containers` 是不是 0;汇总与明细是两条命令,明细失败只降级(`degraded`) |
728
- | `docker_run_command` | 有目标即注册 | `target?`、`id`(必填) | **只推导、不执行**:从 `docker inspect` 原始数据反推等价 `docker run` 命令文本(端口 / 挂载 / env / 重启策略 / 网络 / 资源上限 / capability)+ `warnings` / `secretKeys`(只给键名,值不脱敏)/ `omitted`(故意没还原的,逐条给原因)。compose 容器只是近似 |
729
- | `docker_image_inspect` | 有目标即注册 | `target?`、`ref`(必填) | `docker image inspect` + `docker history`:大小 / 含父层大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 构建历史(每步命令与大小) |
520
+ | `docker_disk_usage` | 有目标即注册 | `target?` | 磁盘占用与**「谁在用」**:`docker system df` 四类汇总 + `-v` 明细(镜像的 `containers`、卷的 `links`,各截 20 行)。删之前先看 `containers` 是不是 0;汇总与明细是两条命令,明细失败只降级(`degraded`) |
521
+ | `docker_run_command` | 有目标即注册 | `target?`、`id`(必填) | **只推导、不执行**:从 `docker inspect` 原始数据反推等价 `docker run` 命令文本 + `warnings` / `secretKeys`(只给键名,值不脱敏)/ `omitted`(故意没还原的,逐条给原因)。compose 容器只是近似 |
522
+ | `docker_image_inspect` | 有目标即注册 | `target?`、`ref`(必填) | `docker image inspect` + `docker history`:大小 / 含父层大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 构建历史 |
730
523
  | `docker_action` | `allowMutations` + 有目标 | `target?`、`action`(`start` \| `stop` \| `restart` \| `remove`)、`id` | 容器生命周期操作。`remove` 是破坏性的:删除容器配置与可写层(数据卷不在其中),执行前必须向用户确认目标容器 |
731
524
  | `docker_image_remove` | `allowMutations` + 有目标 | `target?`、`ref`(必填) | 删除镜像(`docker image rm`,不带 `-f`)。**破坏性**:被容器或子镜像引用时会失败;执行前须确认目标镜像并复述后果 |
732
525
  | `docker_image_prune` | `allowMutations` + 有目标 | `target?` | 清理 dangling(无标签)镜像(`docker image prune -f`)。刻意不加 `--all`,只删无标签镜像 |
733
526
  | `docker_image_pull` | `allowMutations` + 有目标 | `target?`、`ref`(必填)、`timeoutSec?`(10~1800,默认 600) | `docker pull` 快照形态(**可能耗时数分钟**);交互式看逐层进度请让用户到面板镜像页的拉取图标看 SSE 进度流 |
734
- | `docker_exec` | `allowExec` + 有目标 | `target?`、`id`、`command`(必填,经容器内 `sh -c` 执行)、`timeoutSec?`(1~120,默认 `execTimeoutSec`) | 一次性 `docker exec`,返回退出码 / stdout / stderr;无 TTY,交互式排障请让用户到 tty 面板跑 `docker exec -it <容器> sh` |
735
-
736
- - `target` 省略时回落到**唯一**已配置目标;配置了多个目标则必填,错误信息会
737
- 列出可用目标名。
738
- - **零目标时只有 `docker_targets` / `docker_connect_local` 注册**:其余工具没有可寻址对象,
739
- 注册了只会让模型白试一次;配好目标后**立即补注册**,无需重启。表中标「恒注册」的就是这两条。
740
- - **四条列表工具支持分页**:`docker_ps` / `docker_images` / `docker_networks` / `docker_volumes`
741
- 都接受 `limit`(1~500,默认 100)与 `offset`(默认 0),结果带 `total` / `truncated`,
742
- 截断时渲染文本会给出下一页的 `offset`。分页只解决「结果长到读不下去」,不改变单条
743
- docker 命令 512KB 的上限(超了照旧报错)。
744
- - 两个开关变化会**立即重注册**工具:关掉 `allowMutations` / `allowExec` 后,
745
- 对应工具(含三个镜像变更工具)从 agent 侧消失,无需重启。
746
- - **已注册的调用还受会话权限档位闸(tier gate)约束**:按类分——`read` 类(上表
747
- 标「有目标即注册」的读工具)全档放行;`docker_action` / `docker_image_remove` / `docker_image_prune` /
748
- `docker_image_pull` 归 `write` 类,`docker_exec` 归 `exec` 类,这两类在受限档(仅可查看 /
749
- 工作区内修改)下**每次调用弹授权询问**(人允许这一次才执行;无人值守即审批策略 `never`
750
- 时确定性拒绝),完全权限档零询问,Auto review 档不叠加(宿主的模型预审已在管)。宿主未组合
751
- `sandboxPolicy` / `approval` 服务时不闸(行为与引入前一致),启动日志打一行
752
- `tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:
753
- 静态开关管注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。
754
- 机制见 [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
755
- - 推荐排障顺序:`docker_targets` → `docker_ps` → **`docker_diagnose`**(一次拿到结论 + 判据 +
756
- 证据,多数「它为什么挂了 / 为什么不动」到这里就有方向了)→ `docker_logs`(尾部看不到成因就
757
- `docker_logs_grep` 带 `pattern` / `since` / `until` 检索深处)→ `docker_inspect`(原始字段)→
758
- `docker_stats` / `docker_top`(活着但不对劲:先看资源,再看进程还在不在);镜像排查
759
- `docker_images` → `docker_image_inspect`;要清磁盘先 `docker_disk_usage`(看 `containers` /
760
- `links` 谁在用),再决定删什么。
761
- - `announceToAgent` 开启时,插件向 systemPrompt 注入一段精简公告:插件与面板位置、「默认
762
- 只读」与「docker socket ≈ 目标主机 root」的约束、「`allowExec` 只管一次性 exec 通道」的
763
- 边界(卡片「终端」按钮不受它门控)、`target` 的 `*` 适用范围,以及跨工具排障顺序;它不
764
- 逐条复述工具用法——那是各工具 description 的职责。
527
+ | `docker_exec` | `allowExec` + 有目标 | `target?`、`id`、`command` / `argv`(**二选一**:`command` 经容器内 `sh -c` 执行;`argv` 是精确参数数组,宿主与容器都不做 shell 展开)、`timeoutSec?`(1~120,默认 `execTimeoutSec`) | 一次性 `docker exec`,返回退出码 / stdout / stderr。简单命令用 `command`;需要精确保留空格、引号、空参数时用 `argv`(如 `["printf","%s","a b"]`),需要管道 / 变量时显式写 `["sh","-c","…"]`。无 TTY,交互式排障请让用户到 tty 面板跑 `docker exec -it <容器> sh` |
528
+
529
+ - `target` 省略时回落到**唯一**已配置目标;配置了多个目标则必填,错误信息会列出可用目标名。
530
+ - **零目标时只有 `docker_targets` / `docker_connect_local` 注册**:其余工具没有可寻址对象,注册了只会让
531
+ 模型白试一次;配好目标后**立即补注册**,无需重启。
532
+ - **四条列表工具支持分页**:`docker_ps` / `docker_images` / `docker_networks` / `docker_volumes` 都接受
533
+ `limit`(1~500,默认 100)与 `offset`(默认 0),结果带 `total` / `truncated`,截断时给出下一页的
534
+ `offset`。分页只解决「结果长到读不下去」,不改变单条 docker 命令 512KB 的上限。
535
+ - 两个开关变化会**立即重注册**工具:关掉 `allowMutations` / `allowExec` 后,对应工具(含三个镜像变更
536
+ 工具)从 agent 侧消失,无需重启。
537
+ - **已注册的调用还受会话权限档位闸(tier gate)约束**:`read` 类(上表标「有目标即注册」的读工具)全档
538
+ 放行;`docker_action` / `docker_image_remove` / `docker_image_prune` / `docker_image_pull` 归 `write`
539
+ 类,`docker_exec` 归 `exec` 类,这两类在受限档(仅可查看 / 工作区内修改)下**每次调用弹授权询问**(人
540
+ 允许这一次才执行;无人值守即审批策略 `never` 时确定性拒绝),完全权限档零询问,Auto review 档不叠加。
541
+ 宿主未组合 `sandboxPolicy` / `approval` 服务时不闸,启动日志打一行
542
+ `tier-gate: services absent (…), per-call gate disabled`。它与上面两个静态开关是**双层**:静态开关管
543
+ 注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。机制见
544
+ [docs/permission-tier-plan.md](../../docs/permission-tier-plan.md)。
545
+ - 推荐排障顺序:`docker_targets` → `docker_ps` → **`docker_diagnose`**(一次拿到结论 + 判据 + 证据,多数
546
+ 「它为什么挂了」到这里就有方向了)→ `docker_logs`(尾部看不到成因就 `docker_logs_grep` 带 `pattern` /
547
+ `since` / `until` 检索深处)→ `docker_inspect`(原始字段)→ `docker_stats` / `docker_top`(活着但不对劲:
548
+ 先看资源,再看进程还在不在);镜像排查 `docker_images` → `docker_image_inspect`;要清磁盘先
549
+ `docker_disk_usage`(看 `containers` / `links` 谁在用),再决定删什么。
550
+ - `announceToAgent` 开启时,插件向 systemPrompt 注入一段精简公告:插件与面板位置、「默认只读」与
551
+ 「docker socket ≈ 目标主机 root」的约束、「`allowExec` 只管一次性 exec 通道」的边界(卡片「终端」按钮
552
+ 不受它门控)、`target` 的 `*` 适用范围,以及跨工具排障顺序;它不逐条复述工具用法。
765
553
 
766
554
  ## HTTP 路由(`/api/dsh-docker` 前缀,全部 loopback 围栏)
767
555
 
768
- 围栏校验 `remoteAddress`(整个 127/8 + `::1` + `::ffff:` 映射)、`Host`(字面量环回、`localhost`,
769
- 或**解析到本机的主机名 / `/etc/hosts` 别名**,解析带 500ms 超时与 60s 缓存)、`Origin` 与 `sec-fetch-site`;
556
+ 围栏校验 `remoteAddress`(整个 127/8 + `::1` + `::ffff:` 映射)、`Host`(字面量环回、`localhost`,或
557
+ **解析到本机的主机名 / `/etc/hosts` 别名**,解析带 500ms 超时与 60s 缓存)、`Origin` 与 `sec-fetch-site`;
770
558
  非本机请求一律 403 `forbidden: loopback-only`。请求体上限 1MB,响应统一 `application/json` +
771
559
  `referrer-policy: no-referrer`。
772
560
 
773
561
  **除 loopback 之外还有一道「同源证明」**:四条 SSE(`/logs/stream`、`/stats/stream`、`/events/stream`、
774
- `/images/pull/stream`)与九条写子路由(`/action`、`/images/remove|prune`、`/networks/remove|prune`、
775
- `/volumes/remove|prune`、`/exec`、`/connect-local`)要求请求带 `Origin: <同源>` 或 `Sec-Fetch-Site: same-origin`,
776
- 否则 403 `缺少同源证明`。这是为了挡住「恶意页面用 `<img src=.../images/pull/stream>` 触发一次真实拉取」
777
- 这类跨站副作用;curl / 老 Safari / 部分 WebView 不带这两个头时会撞上它(浏览器正常使用不受影响)。
778
- 只读路由不要求同源证明。
779
-
780
- **例外:桌面版靠宿主 Cookie 过闸(D139)**。DeepSeek Harness 桌面版把页面发往
781
- `dsh-app://app/api/…` 的请求转给真实宿主时会**删掉 `Origin` 与 `Sec-Fetch-Site`**、只重写
782
- `Cookie`(`app.asar/lib/main.js` 的 `forwardWebRequest`),所以「两条证明都缺省」在桌面版是**常态**。
783
- 这类转发的判定条件是**带着宿主会话 Cookie**(桌面壳用启动 URL 换来的 `set-cookie`,缺它整条转发
784
- 直接 503):**没有 `Origin`、没有 `Sec-Fetch-Site`、但有 `Cookie` → 放行**;两条都没有且**无** Cookie
785
- (裸 curl / 老 Safari)→ 仍 403。浏览器页面伪造不了 `Cookie` 头,而跨站请求一定带
786
- `sec-fetch-site: cross-site`(loopback 围栏先拒),所以拒绝分支没有被放松。修复前桌面版的这十二条
787
- 路由**全部** 403——症状是日志 / 统计 / 活动 / 拉取四条流无限「连接中断,正在自动重连…」,而只读
788
- 路由照常可用。
562
+ `/images/pull/stream`)与全部写子路由(`/action`、`/images/remove|prune`、`/networks/remove|prune`、
563
+ `/volumes/remove|prune`、`/exec`、`/elevate`(含 `/elevate/status`、`/elevate/revoke`)、`/connect-local`)要求请求带 `Origin: <同源>` 或
564
+ `Sec-Fetch-Site: same-origin`,否则 403 `缺少同源证明`。这是为了挡住「恶意页面用
565
+ `<img src=.../images/pull/stream>` 触发一次真实拉取」这类跨站副作用;curl / 老 Safari / 部分 WebView 不带
566
+ 这两个头时会撞上它(浏览器正常使用不受影响)。只读路由不要求同源证明。
567
+
568
+ **例外:桌面版靠宿主 Cookie 过闸(D139)**。桌面版把页面发往 `dsh-app://app/api/…` 的请求转给真实宿主时
569
+ 会**删掉 `Origin` 与 `Sec-Fetch-Site`**、只重写 `Cookie`(`forwardWebRequest`),所以「两条证明都缺省」
570
+ 在桌面版是**常态**。这类转发的判定条件是**带着宿主会话 Cookie**(缺它整条转发直接 503):**没有
571
+ `Origin`、没有 `Sec-Fetch-Site`、但有 `Cookie` → 放行**;两条都没有且**无** Cookie(裸 curl / 老 Safari)
572
+ → 仍 403。浏览器页面伪造不了 `Cookie` 头,而跨站请求一定带 `sec-fetch-site: cross-site`(loopback 围栏
573
+ 先拒),所以拒绝分支没有被放松;症状与排查见 [docs/troubleshooting.md](../../docs/troubleshooting.md)。
789
574
 
790
575
  | 路由 | 方法 | 请求体 | 返回 |
791
576
  | --- | --- | --- | --- |
792
- | `/config` | GET | — | `{ok:true, config}`:配置快照(targets 只给 `passwordSet` / `passphraseSet`,另附只读的 `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol`——最后这个是 SSE 帧协议版本,客户端据此决定「怎样才算已就绪」,见「SSE 事件协议」) |
577
+ | `/config` | GET | — | `{ok:true, config}`:配置快照(targets 只给 `passwordSet` / `passphraseSet`,另附只读的 `ttyBooks` / `ttyAvailable` / `toolsRegistered` / `streamProtocol`——最后这个是 SSE 帧协议版本,客户端据此决定「怎样才算已就绪」) |
793
578
  | `/config` | POST | 上表配置键的任意子集 | `{ok:true, config}`;未知键 400,非法 JSON 400 |
794
579
  | `/targets` | GET / POST | — | `{ok:true, targets:[{name, kind, label?\|error?}]}` |
795
580
  | `/connect-local` | POST | `{}`(请求体被忽略) | **一键连接本机**:复用或新建 `kind=local` 目标并返回 `{ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config}`;宿主没有 settings 服务时 503,保存失败时 500(都带原因且不改内存配置)。要求同源证明(它是写路径),但**不需要** `allowMutations` / `allowExec` |
796
581
  | `/probe` | POST | `{target?}` | `{ok:true, probe:{ok, bin, serverVersion, error, target}}` |
797
582
  | `/containers` | POST | `{target?, all?}` | `{ok:true, containers: ContainerSummary[]}`;`target:'*'` 时返回 `{ok:true, groups:[{target,label,ok,error?,data?}]}`(跨目标并发聚合) |
798
- | `/attention` | POST | `{target?, limit?}` | 单目标 `{ok:true, items: AttentionItem[], total, truncated, degraded}`(`limit` 在过滤 + 严重度排序**之后**生效,默认 100、上限 500);`target:'*'` 时 `{ok:true, groups:[{target,label,ok,error?,data:{items,total,truncated,degraded}}]}` |
583
+ | `/attention` | POST | `{target?, limit?}` | 单目标 `{ok:true, items, total, truncated, degraded}`(`limit` 在过滤 + 严重度排序**之后**生效,默认 100、上限 500);`target:'*'` 时按目标分组 |
799
584
  | `/inspect` | POST | `{target?, id}` | `{ok:true, details: ContainerDetail[]}` |
800
585
  | `/stats` | POST | `{target?, ids?: string[]}` | `{ok:true, stats: ContainerStats[]}` |
801
586
  | `/logs` | POST | `{target?, id, tail?, timestamps?, since?}` | `{ok:true, logs:{id, text, truncated}}` |
802
587
  | `/top` | POST | `{target?, id}`(必填) | `{ok:true, top:{id, name, rows:[{pid, ppid, user, stat, elapsed, cpu, mem, command}], truncated}}`——字段全是 `docker top` 打印的原文;未运行 / 不存在时走既有错误通道(不是 200 空列表) |
803
- | `/diagnose` | POST | `{target?, id}`(必填)、`logTail?`(1~500,默认 80)、`since?`(默认 `1h`)、`history?`(0~50,默认 10) | `{ok:true, diagnosis:{verdict, findings, evidence, timeline, logTail, suggestions, history:{samples, trend, recorded}}}`。`history` 是本次记录**之后**的最近 N 条样本与趋势(`recorded` 说明本次有没有真的追加——30 秒去重命中时为 `false`)。四路采集,旁路失败只降级(evidence 对应字段 `null` + `*-unavailable` 判据);非法 `since` 在引用白名单段就 400 |
588
+ | `/diagnose` | POST | `{target?, id}`(必填)、`logTail?`(1~500,默认 80)、`since?`(默认 `1h`)、`history?`(0~50,默认 10) | `{ok:true, diagnosis:{verdict, findings, evidence, timeline, logTail, suggestions, history:{samples, trend, recorded}}}`。`history` 是本次记录**之后**的最近 N 条样本与趋势(`recorded` 说明本次有没有真的追加)。四路采集,旁路失败只降级(evidence 对应字段 `null` + `*-unavailable` 判据);非法 `since` 在引用白名单段就 400 |
804
589
  | `/logs/stream` | GET | query:`target?`、`id`(单容器,与 `ids` 二选一)或 `ids`(逗号分隔的多个容器,**一条连接装下全部**,最多 32 个)、`tail?`(**0**~5000;`0` = 不补历史只跟随,客户端重连用)、`timestamps?`(`1`/`true`)、`since?` | `200 text/event-stream` 长连接,事件协议见下;参数错误 / 未知目标 / 非 loopback 返回常规 JSON 错误 |
805
- | `/stats/stream` | GET | query:`target?`、`ids?`(逗号分隔;省略 = 全部运行中) | `200 text/event-stream`:每秒一帧 `stats`(ContainerStats,形状与 /stats 快照一致);不会自然结束,靠客户端断连收尾 |
806
- | `/events/stream` | GET | query:`target?` | `200 text/event-stream`:一帧 `event` 一个容器事件(服务端已过白名单,值缺失的字段省略);不会自然结束,靠客户端断连收尾 |
590
+ | `/stats/stream` | GET | query:`target?`、`ids?`(逗号分隔;省略 = 全部运行中) | `200 text/event-stream`:每秒一帧 `stats`(形状与 /stats 快照一致);不会自然结束,靠客户端断连收尾 |
591
+ | `/events/stream` | GET | query:`target?` | `200 text/event-stream`:一帧 `event` 一个容器事件(服务端已过白名单);不会自然结束 |
807
592
  | `/images` | POST | `{target?}` | `{ok:true, images: ImageSummary[]}` |
808
593
  | `/images/inspect` | POST | `{target?, ref}`(必填) | `{ok:true, image:{ref, detail: ImageDetail, history: ImageHistoryEntry[], historyError}}`;history 优先 `--format '{{json .}}'`,老版本退回纯文本表格 |
809
594
  | `/images/remove` | POST | `{target?, ref}`(必填) | 需 `allowMutations`(否则 403);`docker image rm`(不带 `-f`);`{ok:true, result:{ref, message}}` |
@@ -817,44 +602,37 @@ challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后
817
602
  | `/volumes/inspect` | POST | `{target?, name}`(必填) | `{ok:true, volume:{name, detail: VolumeDetail}}`(挂载点 / 选项 / 标签) |
818
603
  | `/volumes/remove` | POST | `{target?, name}`(必填) | 需 `allowMutations`(否则 403);`docker volume rm`(**数据随卷一起没**);`{ok:true, result:{name, message}}` |
819
604
  | `/volumes/prune` | POST | `{target?}` | 需 `allowMutations`(否则 403);`docker volume prune -f`(**删数据**,见已知限制);`{ok:true, result:{message}}` |
820
- | `/system-df` | POST | `{target?}` | `{ok:true, disk:{summary, images, volumes, buildCache, truncated, degraded, degradedReason}}`:`summary` 是四类汇总,明细各上限 100 行(**先排序再截断**);老 docker 只降级不报错 |
821
- | `/run-command` | POST | `{target?, id}`(必填) | `{ok:true, recreate:{id, name, command, warnings, secretKeys, omitted}}`——**只推导,不执行**;`omitted` 是「故意没还原」的唯一出口 |
605
+ | `/system-df` | POST | `{target?}` | `{ok:true, disk:{summary, images, volumes, buildCache, truncated, degraded, degradedReason}}`:`summary` 四类汇总,明细各上限 100 行(**先排序再截断**);老 docker 只降级不报错 |
606
+ | `/run-command` | POST | `{target?, id}`(必填) | `{ok:true, recreate:{id, name, command, warnings, secretKeys, omitted}}`——**只推导,不执行** |
822
607
  | `/action` | POST | `{target?, action, id}` | 需 `allowMutations`(否则 403);`{ok:true, result:{id, action, message}}` |
823
608
  | `/exec` | POST | `{target?, id, command, timeoutSec?}` | 需 `allowExec`(否则 403);`{ok:true, result:{id, command, code, stdout, stderr, truncated, durationMs}}` |
824
609
 
825
- 其余子路径 404(`unknown route: ...`);`/config`、`/targets`、上述三条
826
- `*/stream` 之外的 GET 返回 405;执行失败(docker 报错、目标解析失败等)返回
827
- 500 或 400 加 `{error}` 文本。
610
+ 其余子路径 404(`unknown route: ...`);`/config`、`/targets`、上述四条 `*/stream` 之外的 GET 返回 405;
611
+ 执行失败(docker 报错、目标解析失败等)返回 500 或 400 加 `{error}` 文本。
828
612
 
829
613
  ### SSE 事件协议(`/logs/stream`、`/stats/stream`、`/events/stream`、`/images/pull/stream`)
830
614
 
831
- 四条长流共用同一份基建(`openSseStream`):统一写头 + `flushHeaders()`、15s
832
- 一帧 `: ping` 心跳、活跃流登记(插件禁用 / 配置热更新 / 卸载时统一 `end` +
833
- abort)、客户端断开静默中止,以及**管道起来后先发一帧 `ready`**(下表 + 客户端的
834
- 「绿点只认 ready」口径)。各自只差执行器与结束原因:
835
-
836
- 每帧一行 `event:` + 一行 JSON `data:`(JSON 单行封装:换行 / 引号被转义,
837
- 多字节字符不会被 SSE 行边界截断),随后空行:
615
+ 四条长流共用同一份基建(`openSseStream`):统一写头 + `flushHeaders()`、15s 一帧 `: ping` 心跳、活跃流
616
+ 登记(插件禁用 / 配置热更新 / 卸载时统一 `end` + abort)、客户端断开静默中止,以及**管道起来后先发一帧
617
+ `ready`**。每帧一行 `event:` + 一行 JSON `data:`(单行封装:换行 / 引号被转义,多字节字符不会被 SSE 行
618
+ 边界截断),随后空行:
838
619
 
839
620
  | 事件 | data | 说明 |
840
621
  | --- | --- | --- |
841
- | `ready` | `{}` | **执行器真的起来了**才发一帧(本机子进程 `spawn` / 远端通道已开 = `StreamHandlers.onStart`):客户端据此才认为「在收数据」。**响应头到达(`onopen`)不算**——响应头由 `flushHeaders()` 立刻写出去,而建连(SSH `acquire` / 远端开通道)可能卡十几秒,目标不可达时干脆只有 `error`。起不来就**没有** `ready` |
622
+ | `ready` | `{}` | **执行器真的起来了**才发一帧(本机子进程 `spawn` / 远端通道已开 = `StreamHandlers.onStart`):客户端据此才认为「在收数据」。**响应头到达(`onopen`)不算**——响应头由 `flushHeaders()` 立刻写出去,而建连(SSH `acquire` / 远端开通道)可能卡十几秒。起不来就**没有** `ready` |
842
623
  | `line` | `{"d":"..."}` / `{"e":"..."}`;多源时 `{"id":"<容器 id>","d"\|"e":"..."}` | stdout / stderr 分片(不保证按行切,客户端自行拼行)。日志流与拉取流用。**`ids=` 多源时每帧带 `id`**,客户端按它把分片派回对应容器 |
843
624
  | `source-end` | `{"id":"<容器 id>","code":N\|null}` | **仅多源**:该容器的 `docker logs -f` 自然退出(容器停止)。整条连接**不关**——其余容器照常推;`end` 要等所有源都结束 |
844
- | `source-error` | `{"id":"<容器 id>","message":"..."}` | **仅多源**:该容器这一条子流失败(spawn 错 / 远端通道被拒)。整条连接也不关,其余容器继续;客户端标注「部分出错」 |
845
- | `stats` | `ContainerStats` | 统计流专用:每秒每个容器一帧,字段与 `/stats` 快照完全一致。服务端按**扁平 `{...}` 抽取**(`docker stats` 即便 stdout 是管道也走 TTY 渲染器,帧里混着 `ESC[H/ESC[K/ESC[J`,按行解析会整行丢掉)并折叠同一采样的重复渲染,客户端不必再解析 docker 的 PascalCase 字符串 |
625
+ | `source-error` | `{"id":"<容器 id>","message":"..."}` | **仅多源**:该容器这一条子流失败。整条连接也不关,其余容器继续;客户端标注「部分出错」 |
626
+ | `stats` | `ContainerStats` | 统计流专用:每秒每个容器一帧,字段与 `/stats` 快照完全一致。服务端按**扁平 `{...}` 抽取**(`docker stats` 即便 stdout 是管道也走 TTY 渲染器,帧里混着 `ESC[H/ESC[K/ESC[J`,按行解析会整行丢掉)并折叠同一采样的重复渲染 |
846
627
  | `event` | `{action, name, image, composeProject?, time?, exitCode?}` | 事件流专用:一帧一个容器事件(白名单外的事件行在服务端就丢了;值为 null 的字段省略) |
847
628
  | `end` | `{"reason":"container-exit"\|"stats-exit"\|"events-exit"\|"pull-exit","code":N, ...}` | 执行器自然退出。日志流附容器退出码(多源时取**按 `ids` 顺序最后一个非 null** 的退出码,各容器的真实退出码在各自的 `source-end` 里)、统计流 reason=`stats-exit`、事件流 reason=`events-exit`、拉取流附 `ref` |
848
629
  | `error` | `{"message":"..."}` | 参数 / 执行失败,随后关闭;连接层断路不会发这个事件 |
849
630
 
850
- **多容器单连接(`ids=`,D168)**:聚合日志(多选 / Compose 项目日志)把 N 个容器塞进
851
- **一条** SSE——宿主为每个容器各起一条 `docker logs -f`,帧里带 `id` 分流。这样聚合视图
852
- 无论选几个容器都只占浏览器**一条**同源连接(旧实现是每容器一条 `EventSource`,叠加活动条
853
- 的事件流会直接吃满 D165 实测出来的 4 条常驻流)。单源 `id=` 的形状**逐字节未变**:帧不带
854
- `id`、不发 `source-end`/`source-error`——老客户端仍按原协议解析。`/config` 的
855
- `streamProtocol` 因此升到 **3**:客户端据此决定走单连接还是退回「每容器一条」。
856
-
857
- 四条流的差异只有「执行器 + 结束原因」:
631
+ **多容器单连接(`ids=`,D168)**:聚合日志把 N 个容器塞进**一条** SSE——宿主为每个容器各起一条
632
+ `docker logs -f`,帧里带 `id` 分流。这样聚合视图无论选几个容器都只占浏览器**一条**同源连接(旧实现是
633
+ 每容器一条 `EventSource`,叠加活动条的事件流会吃满 D165 实测出来的 4 条常驻流)。单源 `id=` 的形状
634
+ **逐字节未变**:帧不带 `id`、不发 `source-end`/`source-error`。`/config` 的 `streamProtocol` 因此升到
635
+ **3**:客户端据此决定走单连接还是退回「每容器一条」。四条流的差异只有「执行器 + 结束原因」:
858
636
 
859
637
  | 流 | 执行器 | 自然结束条件 | 关闭语义 |
860
638
  | --- | --- | --- | --- |
@@ -863,85 +641,63 @@ abort)、客户端断开静默中止,以及**管道起来后先发一帧 `re
863
641
  | `/events/stream` | `docker events`(`--filter type=container`) | daemon 侧流结束(`events-exit`) | 切页 / 切目标 / 关面板 |
864
642
  | `/images/pull/stream` | `docker pull` | 拉取完成 / 失败(`pull-exit`) | 前端离开拉取视图 |
865
643
 
866
- - 响应头:`content-type: text/event-stream; charset=utf-8`、`cache-control:
867
- no-cache`、`connection: keep-alive`,写头后立即 `flushHeaders()`(宿主 gzip
868
- 对 `text/event-stream` 显式跳过,不会缓冲)。
644
+ - 响应头:`content-type: text/event-stream; charset=utf-8`、`cache-control: no-cache`、
645
+ `connection: keep-alive`,写头后立即 `flushHeaders()`(宿主 gzip 对 `text/event-stream` 显式跳过)。
869
646
  - 心跳:每 15s 一帧 `: ping` 注释(SSE 规范里客户端忽略)。
870
- - **背压与溢出(D153)**:客户端消费不过来时帧进内存队列(每条流 ≤ 8MB);日志 / 拉取流溢出
871
- 时丢最旧的整帧、只留最新,并推一条 `skip{frames,bytes}`(`skip` 帧自身永不丢)——**流不断**;
872
- skip 排队期间再发生丢弃时**原地更新**这条 skip 的账目(D156,两段缺口只报出一段就是少报)。
873
- 统计 / 事件流溢出时补 `end{reason:output-limit}` 收尾,由客户端重连。另外 `write()` 返回
874
- false 只代表「越过高水位」,帧**不会被重写**(老实现的重复帧就是这里来的)。
875
- - **分片合帧(D151)**:日志流与拉取流把上游分片按 50ms 窗口合并后再推(同一通道严格保序、
876
- stdout 先于 stderr、攒满 256KB 立刻推),所以 `line` 帧的 `d` / `e` 里可能有**多行**——
877
- 客户端本来就是按「任意分片」切行的,语义不变,只是「一个 stdout chunk 一帧」没了(话痨
878
- 容器每秒几千帧时,每帧的固定开销全砸在浏览器主线程上)。`end` 帧一定排在窗口里最后一批
879
- `line` 之后。
880
- - 清理:客户端断开 → 立即中止执行器(本机 `SIGTERM`,2s 未退再 `SIGKILL`;
881
- SSH 关闭该 exec channel、连接池连接保留复用),不写任何帧;插件禁用 / 配置
882
- 热更新 / 卸载 → 服务端主动收尾(abort + `end`)。
883
- - SSH 的长流会占用连接池里的连接(busy 计数),空闲回收(120s)不会误杀;
884
- 流结束后恢复回收。
885
- - **「已就绪」的判据(客户端侧)**:绿点 /「实时跟随中」只认宿主那帧 `ready`——响应头到了
886
- 不算。此外每条流还有一条「更硬的证据」兜底(收到它就等于管道在产数据):事件流的第一条
887
- 真实事件、日志流的第一行、统计流的第一个采样、拉取流的第一帧进度。
888
- **开发档错位**(宿主进程还跑着旧代码、浏览器却从磁盘读到新的 `client.js`)另有版本协商:
889
- `/config` 的 `streamProtocol < 2`(或老到没有这个字段)说明该宿主从不发 `ready`,客户端
890
- 退回旧判据(响应头到了就算连上),并只重开一次那条流让回退判据生效——2026-10-11 实测:
891
- 旧宿主 + 新客户端原本会被钉死在「正在连接事件流…」(卡片能读到容器,活动条却在转)。
647
+ - **背压与溢出(D153)**:客户端消费不过来时帧进内存队列(每条流 ≤ 8MB);日志 / 拉取流溢出时丢最旧的
648
+ 整帧、只留最新,并推一条 `skip{frames,bytes}`(`skip` 帧自身永不丢)——**流不断**;skip 排队期间再发生
649
+ 丢弃时**原地更新**这条 skip 的账目(D156)。统计 / 事件流溢出时补 `end{reason:output-limit}` 收尾,由
650
+ 客户端重连。另外 `write()` 返回 false 只代表「越过高水位」,帧**不会被重写**。
651
+ - **分片合帧(D151)**:日志流与拉取流把上游分片按 50ms 窗口合并后再推(同一通道严格保序、stdout 先于
652
+ stderr、攒满 256KB 立刻推),所以 `line` 帧的 `d` / `e` 里可能有**多行**——客户端本来就按「任意分片」
653
+ 切行。`end` 帧一定排在窗口里最后一批 `line` 之后。
654
+ - 清理:客户端断开 → 立即中止执行器(本机 `SIGTERM`,2s 未退再 `SIGKILL`;SSH 关闭该 exec channel、
655
+ 连接池连接保留复用),不写任何帧;插件禁用 / 配置热更新 / 卸载 → 服务端主动收尾(abort + `end`)。
656
+ - SSH 的长流会占用连接池里的连接(busy 计数),空闲回收(120s)不会误杀;流结束后恢复回收。
657
+ - **「已就绪」的判据(客户端侧)**:绿点 /「实时跟随中」只认宿主那帧 `ready`——响应头到了不算。此外每条
658
+ 流还有一条「更硬的证据」兜底:事件流的第一条真实事件、日志流的第一行、统计流的第一个采样、拉取流的
659
+ 第一帧进度。**开发档错位**(宿主进程还跑着旧代码、浏览器却读到新的 `client.js`)另有版本协商:
660
+ `/config` 的 `streamProtocol < 2` 说明该宿主从不发 `ready`,客户端退回旧判据并只重开一次那条流。
892
661
 
893
662
  ## 安全模型
894
663
 
895
- **docker socket ≈ 目标主机的 root 权限。** 能访问 daemon 就能挂载宿主目录、
896
- 以特权模式起容器、读容器里的密钥——因此本插件按「只读优先」设计:
897
-
898
- 1. **默认只读**。`allowMutations` 未开启时,`/action`、`/images/remove`、
899
- `/images/prune`、`/images/pull/stream` 一律返回 403,面板的启停删 / 镜像
900
- 删除 / 清理 / 拉取不可用,`docker_action`、`docker_image_remove`、
901
- `docker_image_prune`、`docker_image_pull` 工具**根本不注册**;`allowExec`
902
- 未开启时,`/exec` 返回 403,`docker_exec` 工具同样不注册。两个开关互相
903
- 独立,必须在设置卡片由用户显式打开。读取类路由(`/logs/stream`、
904
- `/stats/stream`、`/events/stream`、`/images/inspect`)不受这两个开关影响。
905
- **`allowExec` 只管本插件的「一次性」exec 通道**(`/exec` 路由、`docker_exec`
906
- 工具、容器详情抽屉「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是 `docker exec -it`
907
- 交互式 shell,由 tty 承载、不经过这两个开关**,只读模式下也可用(见下面
908
- 「已知限制」里的边界说明:它不是漏了门,是刻意留的进入通道)。
909
- 2. **破坏性操作要复述后果**。`remove` 映射为 `docker rm`(**不带 `-f`**),
910
- agent 公告要求执行前向用户确认目标容器;运行中容器会报错并附
911
- 「容器仍在运行:先停止再删除」的提示,不会静默强删。
912
- 3. **凭证不落明文(建议)**。`password` / `passphrase` 支持 `env:NAME` 凭据引用,
913
- 值存在**官方凭据存储**(`$DSH_HOME/.credentials.yaml`,由凭据层的 provider 托管)里,
914
- 避免明文写进 `settings.yaml`;不必依赖 env 插件当中间人。`agent`
915
- 认证(`SSH_AUTH_SOCK`)则完全不落盘。配置快照只回「是否已设置」。
916
- 4. **主机指纹 TOFU 钉扎**。首次连接记录 sha256 指纹,之后必须一致,变更即
917
- 拒绝连接(防中间人);tty 已确认过的主机会被当作种子直接信任并复制进
918
- 本插件的记录。TOFU 的固有边界是「首次若已遭遇 MITM,记下的就是伪指纹」,
919
- 以及按 host:port 只存一条(同主机多密钥类型可能误报变更,删除记录重连
920
- 即可重新校准)。
921
- 5. **命令一律 argv 构造,绝不做字符串拼接**。容器名 / ID 先过白名单
922
- `assertRef`(`[A-Za-z0-9][A-Za-z0-9_.-]*`,≤128 字符,拒绝空格、`;`、
923
- `$()`、反引号等),镜像与容器引用同理;远程经 `shJoin` 逐参数单引号
924
- 转义后交给远端 shell,本机 `spawn(bin, args)` 不经 shell。
925
- 6. **输出有上限**。`maxOutputKb` 限制单次命令的 stdout/stderr 字节数,超出
926
- 截断并标记,避免大日志撑爆内存或 agent 上下文。
664
+ **docker socket ≈ 目标主机的 root 权限。** 能访问 daemon 就能挂载宿主目录、以特权模式起容器、读容器里的
665
+ 密钥——因此本插件按「只读优先」设计:
666
+
667
+ 1. **默认只读**。`allowMutations` 未开启时,`/action`、`/images/remove`、`/images/prune`、
668
+ `/images/pull/stream` 一律返回 403,面板的启停删 / 镜像删除 / 清理 / 拉取不可用,`docker_action`、
669
+ `docker_image_remove`、`docker_image_prune`、`docker_image_pull` 工具**根本不注册**;`allowExec` 未开启
670
+ 时,`/exec` 返回 403,`docker_exec` 工具同样不注册。两个开关互相独立,必须在设置卡片由用户显式打开。
671
+ 读取类路由(`/logs/stream`、`/stats/stream`、`/events/stream`、`/images/inspect`)不受这两个开关影响。
672
+ **`allowExec` 只管本插件的「一次性」exec 通道**(`/exec` 路由、`docker_exec` 工具、容器详情抽屉
673
+ 「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是 `docker exec -it` 交互式 shell,由 tty
674
+ 承载、不经过这两个开关**,只读模式下也可用(它不是漏了门,是刻意留的进入通道,见「已知限制」)。
675
+ 2. **破坏性操作要复述后果**。`remove` 映射为 `docker rm`(**不带 `-f`**),agent 公告要求执行前向用户
676
+ 确认目标容器;运行中容器会报错并附「容器仍在运行:先停止再删除」的提示,不会静默强删。
677
+ 3. **凭证不落明文(建议)**。`password` / `passphrase` 支持 `env:NAME` 凭据引用,值存在**官方凭据存储**
678
+ (`$DSH_HOME/.credentials.yaml`)里,避免明文写进 `settings.yaml`;`agent` 认证(`SSH_AUTH_SOCK`)
679
+ 完全不落盘。配置快照只回「是否已设置」。
680
+ 4. **主机指纹 TOFU 钉扎**。首次连接记录 sha256 指纹,之后必须一致,变更即拒绝连接(防中间人);tty 已
681
+ 确认过的主机会被当作种子直接信任并复制进本插件的记录。TOFU 的固有边界是「首次若已遭遇 MITM,记下的
682
+ 就是伪指纹」,以及按 host:port 只存一条(同主机多密钥类型可能误报变更,删除记录重连即可重新校准)。
683
+ 5. **命令一律 argv 构造,绝不做字符串拼接**。容器名 / ID 先过白名单 `assertRef`
684
+ (`[A-Za-z0-9][A-Za-z0-9_.-]*`,≤128 字符,拒绝空格、`;`、`$()`、反引号等),镜像与容器引用同理;
685
+ 远程经 `shJoin` 逐参数单引号转义后交给远端 shell,本机 `spawn(bin, args)` 不经 shell。
686
+ 6. **输出有上限**。`maxOutputKb` 限制单次命令的 stdout/stderr 字节数,超出截断并标记,避免大日志撑爆
687
+ 内存或 agent 上下文。
927
688
  7. **HTTP 只对本机开放**。全部路由走 loopback 围栏,远程浏览器无法调用。
928
- 8. **日志实时流是只读能力**。`GET /logs/stream` 与 `/logs` 一致:不受
929
- `allowMutations` / `allowExec` 门控(它不改容器状态),但同样只放行 loopback、
930
- `id` 过 `assertRef` 白名单、参数经同样的夹紧。与快照不同,长流没有
931
- `maxOutputKb` 上限(跟随被截断就失去意义),内存防护由客户端的 5000 行
932
- 环形缓冲与 2000 行着色上限承担。
933
- 9. **变更操作有逐条使用审计**。kit elevation 的五行审计回答「授权了」,这里回答
934
- 「用了」:每一次**过了能力闸**的变更 / exec 都落一行 `[dsh-docker] capability-use: …`
935
- 结构化审计行(出口是宿主 stdout),面板路由与 agent 工具两条入口都覆盖。行格式与
936
- 不记什么(403 / tier-gate ask)、以及「exec 命令会随行出现」这条代价,见「已知限制」
937
- 的使用审计条。
689
+ 8. **日志实时流是只读能力**。`GET /logs/stream` 与 `/logs` 一致:不受 `allowMutations` / `allowExec` 门控
690
+ (它不改容器状态),但同样只放行 loopback、`id` 过 `assertRef` 白名单、参数经同样的夹紧。与快照不同,
691
+ 长流没有 `maxOutputKb` 上限(跟随被截断就失去意义),内存防护由客户端的 5000 行环形缓冲承担。
692
+ 9. **变更操作有逐条使用审计**。kit elevation 的审计行回答「授权了」,这里回答「用了」:每一次**过了
693
+ 能力闸**的变更 / exec 都落一行 `[dsh-docker] capability-use: …` 结构化审计行(出口是宿主 stdout),
694
+ 面板路由与 agent 工具两条入口都覆盖。行格式、不记什么(403 / tier-gate ask)与「exec 命令会随行
695
+ 出现」这条代价,见「已知限制」的使用审计条。
938
696
 
939
697
  ## 已知限制
940
698
 
941
- - **没有交互式 TTY**:`exec` 是一次性命令(`docker exec <id> sh -c <cmd>`,
942
- 不带 `-i` / `-t`),不能跑 vim / top / 交互式 shell,也不能喂 stdin 做
943
- 对话。交互排障请到 tty 面板执行 `docker exec -it <容器> sh`(本机与 SSH
944
- 目标都可以)。
699
+ - **没有交互式 TTY**:`exec` 是一次性命令(`docker exec <id> sh -c <cmd>`,不带 `-i` / `-t`),不能跑
700
+ vim / top / 交互式 shell,也不能喂 stdin 做对话。交互排障请到 tty 面板执行 `docker exec -it <容器> sh`。
945
701
  - **`allowExec` 不是「进不了容器」的闸**:卡片「终端」按钮(`docker exec -it
946
702
  '<容器>' sh`)经 tty 的 `ttyTerminal` 承载,**不检查 `allowExec`**,只读模式下
947
703
  也照常可用——exec 被关时面板给的提示原文就是这么说的(「打开『允许 exec』,或
@@ -952,232 +708,109 @@ abort)、客户端断开静默中止,以及**管道起来后先发一帧 `re
952
708
  `allowExec` 对**页内脚本**(第三方插件的 client 半体 / XSS)本来就不是边界——它们
953
709
  走 `ttyTerminal.open` 或 WS `spawn` 帧就能跑任意命令,比本插件的一次性 exec 还强。
954
710
  这一层的威胁模型边界见 [architecture.md § 7](../../docs/architecture.md#7-一条请求经过什么)。
955
- - **事件流有断线窗口**:`docker events` 是「从现在开始」的推流,浏览器断线重连期间
956
- 发生的事件服务端已经推过、不会补发。客户端用「重连成功后先做一次全量列表刷新」
957
- 来补偿(状态对齐,不是把事件补回来);活动条里缺的那几条只能靠刷新后的最终状态
958
- 推断。要精确的完整事件历史请用 `docker_events` 工具(带 `--since` 的快照)。
959
- - **流式能力的四条边界**:日志页 `FOLLOW`(SSE + `docker logs -f`)、统计页
960
- `FOLLOW`(SSE + `docker stats` + 60 点 sparkline)、容器列表的事件流
961
- (SSE + `docker events`,驱动活动条与列表防抖刷新)、镜像页拉取进度流
962
- (SSE + `docker pull`);但 agent 工具 `docker_logs` / `docker_stats` /
963
- `docker_image_pull` 一律保持**快照语义**(单值返回模型不适合无界流)。流式
964
- 日志在浏览器侧只保留最近 5000 行(丢最旧并提示),统计只保留 60 个采样点。
965
- SSH 长流会占住连接池中的该连接(busy),同一主机上的其它命令复用同一条连接——
966
- 但**不是互不影响**:通道额度是共享的,见下一条。**统计流不会自然结束**,关闭必须
967
- 由前端主动断 `EventSource`。
968
- - **浏览器同源连接预算:docker 面板现在最多只占 1 条常驻流(D165 实测 + D168 收口)**:
969
- 宿主是 **HTTP/1.1**(没有 h2 多路复用),浏览器对**每个源**只放这么几条 TCP,而
970
- **每条 `EventSource` 独占一条连接、且不会主动释放**。吃满之后,同一个页面里
971
- **所有**普通请求(列表刷新、`/api/*`、会话加载……)一起排队——用户看到的是
972
- **整个 DSH 页面卡住**,不只是 docker 面板(实测:常驻流 0/4 条时页面请求正常,
973
- **5 条时 10 次全部超时**;完整数据与现场见 [DEFECTS.md](./DEFECTS.md) 的 D165)。
974
- docker 面板的常驻流**在任何视图下都只有 1 条**:容器列表 = 活动条事件流;详情 =
975
- 日志 FOLLOW 或统计 FOLLOW(同一时刻只有一个页签);聚合 / Compose 项目日志 =
976
- **一条** `ids=` 多容器流;拉取 = 一条进度流。**进入详情 / 聚合视图时事件流会主动断开**
977
- (列表被整段替掉、活动条不可见),回列表时重连并补一次全量刷新(D168)。
978
- **这不是「性能建议」而是硬约束**:超了不是「慢一点」,是同页面所有请求一起失效。
979
- 想自己复核:在 devtools 里连开
980
- `new EventSource('/api/dsh-docker/events/stream?target=<目标>')` 到第 5 条,再打一次
981
- 普通请求即可看到它挂住;关掉就恢复。(WebSocket **不**占这份预算——tty 终端那条 WS
982
- 与 4 条 SSE 共存时页面仍正常,实测过。)
983
- - **多标签页要按「全部插件加起来」算,不是 docker 一家**:那 6 条连接是整个页面共享的,
984
- 官方 `dsh-client-hmr`、其它插件的 SSE 都算在内。如果每个标签页里宿主自身就挂着 2 条
985
- 常驻 SSE,**3 个标签页 = 6 条**,已经到悬崖——这一层不是本插件能改的:真要治本得让
986
- 宿主换 WebSocket / h2,或让各插件共用一条多路复用连接。docker 这边已经把自家的账压到
987
- 1 条(见上一条),剩下的要按现场实际装了哪些插件去数。
988
- - **聚合上限是「可读性 + 宿主通道」的上限,不再是浏览器连接数(D168)**:聚合日志是一条
989
- 连接装下所有选中容器,所以 `PICK_MAX = 3` 不再由「4 条预算 − 1 条事件流」推出来;
990
- 保留 3 是因为一屏混三份以上日志会看不清,且每个被选容器在宿主侧仍各跑一条
991
- `docker logs -f`(SSH 目标上就是一条 channel,见下一条的 8 条额度)。操作条上写着
992
- 同一句说明。
993
- - **SSH 目标上的通道额度是共享的(`MaxSessions`)**:一个目标只维持**一条** TCP 连接,
994
- 所有长流与短命令共用这条连接上的通道,而 OpenSSH 的 `MaxSessions` 默认只有 10。
995
- 长流(日志 / 统计 / 事件)会一直占到用户关掉面板为止,聚合日志里每个被选容器仍各占
996
- **一条** channel(选中上限 3,所以最坏 3 条)——叠上刷新 / inspect 这类短命令就可能
997
- 逼近额度,于是远端拒绝时看到的是 `(SSH) Channel open failure: open failed`。所以插件
998
- 对每个 SSH 目标限制**同时最多 8 条长流**(= 10 − 2,留两条给刷新 / inspect 这类短命令),
999
- 聚合选择的上限也定在 3。超限与远端拒通道时给出的都是带指向性的提示,而不是 ssh2 的
1000
- 原始文案。若你的 sshd 调过 `MaxSessions`(`sshd -T | grep maxsessions`),当前上限是
1001
- 编译期常量,需要跟着改就提 issue。折叠右侧栏标签会主动断流、把通道还回去。
1002
- (`D150`)这条约束另有两道补救:① **短命令按目标排队** —— 每条连接同时最多 2 条
1003
- 并发(`DSH_DOCKER_SHORT_CHANNELS` 可覆盖,仅测试 / 排障),多出来的等前一条收尾,
1004
- 不再出现「一次点击(概览 inspect + 日志快照 + 统计快照)就去撞 10 的硬上限」;
1005
- ② **额度满会自愈** —— 远端回 `Channel open failure: open failed`(sshd 侧原文
1006
- `error: no more sessions`)时,插件丢掉并**关闭**那条连接、重建一条再重试一次,
1007
- 宿主日志留一行 `通道额度已满:已重建连接重试`。之所以必须重建:这条连接**不会自己
1008
- 恢复**(中止长流时发的 `signal('KILL')` 在部分 sshd 上被拒绝,而 sshd 在子进程仍活着
1009
- 时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连
1010
- (日志流按 `D133` 用 `tail=0` 续尾);③ **中止长流时插件自己的账当场结清**(`D164`)
1011
- —— `AbortSignal` 触发时除了向远端 `KILL` + 关通道,还会**直接结束这条流**,不再等远端
1012
- 回话。原先只等远端的 `close` 事件:远端不确认(上面那种被拒的 KILL,或通道静默)时,
1013
- 那条流的 `busy` 计数**永远不会减**,累积到 8 之后该目标的每条长流都被配额拒绝,
1014
- 且没有任何自愈路径(现场与完整因果见 [DEFECTS.md](./DEFECTS.md) 的 D164)。同一类坑
1015
- `run()` 在 `D112` / `D161` 已经踩过两次,现在三条收尾路径统一成「自己 settle」。
1016
- **根治办法在 sshd 一侧**:把那台主机的
1017
- `MaxSessions` 调大 —— 例如 `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行
1018
- `MaxSessions 50`,再 `sshd -t && systemctl reload ssh`(reload 只影响新连接)。
1019
- - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,
1020
- 解析器一律降级而不抛异常(例如 `State` 缺失就从 `Status` 推导状态,健康态
1021
- 从 `(healthy)` / `(unhealthy)` 提取);缺字段时对应列可能为空,需要权威
1022
- 数据请用详情(`docker inspect`)。
1023
- - **`rm` 不带 `-f`**:容器 `remove` 映射 `docker rm`、镜像 `remove` 映射
1024
- `docker image rm`,都不加 `-f`——运行中的容器、被容器或子镜像引用的镜像会
1025
- 失败并给出提示;要强制删除得去 tty 面板手动执行。
1026
- - **SSH 目标需要免 sudo 的 docker**:账号不在 docker 组时 docker 报权限错误,
1027
- 面板与工具原样透出,不做自动 sudo 提权。
1028
- - **远端未安装 docker**:`probe` 失败(`command not found` / 退出码 127),
1029
- 面板显示错误;PATH 不一致时可把 `dockerBin` 填成绝对路径。
1030
- - **`dockerBin` 的校验发生在落盘之前**:非法值直接 400 并带上原因,不会被写进
1031
- `settings.yaml`(早先的实现在 `scope.update` 之后才校验,于是非法值照样写盘、
1032
- 用户只拿到一个没有正文的 400;下次启动整段 docker 配置会静默退回默认)。
1033
- - **`dockerBin` 指向 `.cmd` / `.bat` 时本机通道起不来**:本机执行走
1034
- `runLocal` / `runLocalStream` 的裸 `spawn`,Windows 上 Node 会拒绝对 `.cmd` 的
1035
- 直接执行(`EINVAL`)。docker 官方发行的是 `docker.exe`,日常不受影响;手写
1036
- `.cmd` 包装脚本时请改用 `.exe`,或等待本机通道也切到 kit 的 `spawnPortable`。
1037
- - **Compose 项目视图只认 `com.docker.compose.project`**:`docker stack deploy` 起的
1038
- swarm 服务带的是 `com.docker.stack.namespace` / `com.docker.swarm.service.name`,
1039
- 于是它们的容器在面板里显示为「未分组」(真机实测:Ubuntu 24.04 + Docker 29.3.1 的
1040
- 三节点 swarm,stack 容器 `composeProject` 全为 `null`)。要支持得另立一套 stack
1041
- 分组语义,不能直接塞进 compose 口径。
1042
- - **`volume prune` 在 Docker 29 只回收匿名未用卷**:命名卷即使在
1043
- `docker volume ls -f dangling=true` 里,`docker volume prune -f` 也不会删它
1044
- (真机实测:命名卷留着,回 `Total reclaimed space: 0B`;匿名卷被删并列出名字)。
1045
- 这与本插件「刻意不加 `--all`、避免误删」的取向一致 —— 要删命名卷请用
1046
- `/volumes/remove`(面板的卷删除)。
1047
- - **podman 兼容靠 `dockerBin`**:填 `podman` 即可跑,但 `stats` 与
1048
- `--format '{{json .}}'` 的字段和输出格式与 docker 有差异,只能依赖解析器
1049
- 的降级路径,未逐项验证。
1050
- - **没有镜像构建 / Compose 编排变更**:镜像支持拉取 / 删除 / 清理 dangling,
1051
- 但没有 `docker build`、`docker save` / `load`、`docker push`;Compose 是**只读**
1052
- 项目视图(按项目分组 + 项目级聚合日志),不提供 `compose up` / `down` / `restart`。
1053
- - **诊断是判据,不是专家**:15 条判据覆盖的是「容器自报的客观证据」(退出码 / OOM /
1054
- 重启次数 / 健康检查 / 资源压力 / 日志里的报错行),**不理解业务**。它给出的结论是
1055
- 「证据指向什么」,不会替你判断「这个报错是否可忽略」——那一步交给「交给 Agent」之后的人或模型。
1056
- 代价也说清:诊断一次跑**四条**只读命令(inspect + logs + events + stats),在 SSH 目标上
1057
- 就是最多四条 channel(短命令闸会把并发压到 2,所以是两批),慢机器上会感觉到等。
1058
- - **事件采集给的是独立预算(8MB + 留尾)**:单容器一小时的事件里,`exec_create` /
1059
- `exec_start` / `exec_die` 这种解析阶段就被白名单丢掉的三连占绝大多数(真机实测 1h / 256 条 /
1060
- 588KB 里绝大多数是它们),按通用的 512KB 档会让时间线**永远为空**。所以诊断的事件采集单独
1061
- 放宽到 8MB 并保留尾部——窗口内事件特别多的容器,时间线只覆盖**最近的**一段。
711
+ - **事件流有断线窗口**:`docker events` 是「从现在开始」的推流,断线重连期间发生的事件服务端已经推过、不会
712
+ 补发。客户端用「重连成功后先做一次全量列表刷新」来补偿(对齐状态,不是把事件补回来);要精确的完整事件
713
+ 历史请用 `docker_events` 工具(带 `--since` 的快照)。
714
+ - **浏览器同源连接预算是硬约束,docker 面板最多只占 1 条常驻流(D165 实测 + D168)**:宿主是
715
+ **HTTP/1.1**(没有 h2 多路复用),而**每条 `EventSource` 独占一条连接、且不会主动释放**。吃满之后,同一
716
+ 个页面里**所有**普通请求(列表刷新、`/api/*`、会话加载……)一起排队——用户看到的是**整个 DSH 页面
717
+ 卡住**,不只是 docker 面板。docker 面板的常驻流**在任何视图下都只有 1 条**:列表 = 活动条事件流;详情 =
718
+ 日志 / 统计 FOLLOW(同一时刻只有一个页签);聚合 / Compose 日志 = **一条** `ids=` 多容器流;拉取 = 一条
719
+ 进度流(进入详情 / 聚合时事件流主动断开,回列表重连并补一次全量刷新)。超了不是「慢一点」,是同页面所有
720
+ 请求一起失效;完整实测数据见 [DEFECTS.md](./DEFECTS.md) 的 D165,复核方式是在 devtools 里连开
721
+ `new EventSource('/api/dsh-docker/events/stream?target=<目标>')` 到第 5 条,再打一次普通请求。
722
+ (WebSocket **不**占这份预算;流式日志 / 统计在浏览器侧的保留量见「使用」。)
723
+ - **多标签页与聚合上限**:那些连接是整个页面共享的(官方 `dsh-client-hmr`、其它插件的 SSE 都算);治本要
724
+ 宿主换 WebSocket / h2,或各插件共用一条多路复用连接。聚合上限 3 现在是「可读性 + SSH 8 条通道额度」的
725
+ 上限,不再由浏览器预算推出(D168):一屏混三份以上日志会看不清,且每个被选容器在宿主侧仍各跑一条
726
+ `docker logs -f`(SSH 目标上就是一条 channel)。`PICK_MAX = 3` 即此值。
727
+ - **SSH 目标上的通道额度是共享的(`MaxSessions`)**:一个目标只维持**一条** TCP 连接,长流与短命令共用
728
+ 这条连接上的通道,而 OpenSSH 的 `MaxSessions` 默认只有 10。长流会一直占到用户关掉面板为止,聚合日志里
729
+ 每个被选容器仍各占**一条** channel——叠上刷新 / inspect 这类短命令就可能逼近额度,远端拒绝时看到的是
730
+ `(SSH) Channel open failure: open failed`。插件对每个 SSH 目标限制**同时最多 8 条长流**(源码常量 `MAX_STREAMS_PER_TARGET`,即 10 − 2,留两
731
+ 条给短命令),聚合上限也定在 3;折叠右侧栏标签会主动断流、把通道还回去。(`D150`)两道补救:① **短命令
732
+ 按目标排队**(每条连接同时最多 2 条并发,`DSH_DOCKER_SHORT_CHANNELS` 可覆盖,仅测试 / 排障);② **额度
733
+ 满会自愈**——远端回 `no more sessions` 这类拒绝时,插件丢掉并**关闭**那条连接、重建一条再重试一次,宿主
734
+ 日志留一行 `通道额度已满:已重建连接重试`(这条连接**不会自己恢复**)。(`D164`)③ **中止长流时插件自己
735
+ 的账当场结清**—— `AbortSignal` 触发时除了向远端 `KILL` + 关通道,还会**直接结束这条流**(原先只等远端
736
+ `close`,远端不确认时那条流的 `busy` 计数**永远不会减**,累积到 8 之后该目标的每条长流都被拒绝且没有
737
+ 自愈路径)。**根治办法在 sshd 一侧**:把那台主机的 `MaxSessions` 调大——例如
738
+ `/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf` 写一行 `MaxSessions 50`,再 `sshd -t && systemctl
739
+ reload ssh`(reload 只影响新连接)。当前上限是编译期常量,需要跟着改就提 issue。
740
+ - **docker CLI 版本差异**:解析走 `--format '{{json .}}'`,字段随版本增减,解析器一律降级而不抛异常
741
+ (例如 `State` 缺失就从 `Status` 推导状态);缺字段时对应列可能为空,权威数据请用 `docker inspect`。
742
+ - **`rm` 不带 `-f`**:容器 `remove` 映射 `docker rm`、镜像 `remove` 映射 `docker image rm`,都不加 `-f`
743
+ ——运行中的容器、被引用的镜像会失败并给出提示;要强制删除得去 tty 面板手动执行。
744
+ - **SSH 目标需要免 sudo 的 docker**:账号不在 docker 组时权限错误原样透出,不做自动 sudo 提权。
745
+ **远端未安装 docker** 时 `probe` 失败(退出码 127),PATH 不一致时可把 `dockerBin` 填成绝对路径。
746
+ - **`dockerBin` 的校验发生在落盘之前**:非法值直接 400 并带上原因,不会被写进 `settings.yaml`。
747
+ **指向 `.cmd` / `.bat` 时本机通道起不来**:本机执行走裸 `spawn`,Windows 上 Node 会拒绝直接执行 `.cmd`
748
+ (`EINVAL`)——手写包装脚本请改用 `.exe`,或等待本机通道也切到 kit 的 `spawnPortable`。
749
+ - **Compose 项目视图只认 `com.docker.compose.project`**:`docker stack deploy` 起的 swarm 服务带的是
750
+ `com.docker.stack.namespace` / `com.docker.swarm.service.name`,于是这些容器显示为「未分组」。要支持得
751
+ 另立一套 stack 分组语义。
752
+ - **`volume prune` 的回收范围随版本变,且可能删数据**:Docker 29 上 `docker volume prune -f` **只回收匿名
753
+ 未用卷**(命名卷留着);而 docker ≥ 23 有 `-a/--all`,docker < 23 没有这个开关、plain prune 会把未被
754
+ 使用的**命名卷**一起删掉。本插件就不带 `--all`,确认文案写明了版本差异;要删命名卷请用
755
+ `/volumes/remove`。
756
+ - **podman 兼容靠 `dockerBin`**:填 `podman` 即可跑,但 `stats` 与 `--format '{{json .}}'` 的字段和输出
757
+ 格式与 docker 有差异,只能依赖解析器的降级路径,未逐项验证。
758
+ - **没有镜像构建 / Compose 编排变更**:镜像支持拉取 / 删除 / 清理 dangling,但没有 `docker build`、
759
+ `docker save` / `load`、`docker push`;Compose 是**只读**项目视图。**网络 / 卷的变更只有面板按钮,
760
+ 没有 agent 工具**——刻意不扩大 agent 侧的变更面。
761
+ - **诊断是判据,不是专家**:这些判据覆盖的是「容器自报的客观证据」(退出码 / OOM / 重启次数 / 健康检查 /
762
+ 资源压力 / 日志里的报错行),**不理解业务**。「这个报错是否可忽略」那一步交给「交给 Agent」之后的人或
763
+ 模型。代价:诊断一次跑**四条**只读命令,在 SSH 目标上就是最多四条 channel(短命令闸把并发压到 2,所以是
764
+ 两批),慢机器上会感觉到等。
765
+ - **事件采集给的是独立预算(8MB + 留尾)**:单容器一小时的事件里 `exec_create` / `exec_start` /
766
+ `exec_die` 这类三连占绝大多数,按通用的 512KB 档会让时间线**永远为空**。所以诊断的事件采集单独放宽到
767
+ 8MB 并保留尾部——窗口内事件特别多的容器,时间线只覆盖**最近的**一段。
1062
768
  - **等价 `docker run` 是近似,不是备份**:compose 管理的容器不带网络别名 / labels / depends_on;
1063
769
  `--privileged` / `--link` / ENTRYPOINT 覆盖 / 自定义健康检查 / 匿名卷等**故意不还原**,逐条列在
1064
- `omitted` 里。**它不能替代 `docker commit` / 卷备份**,只是「手边有一条能读懂、能改、能复现的命令」。
1065
- - **磁盘明细的两处截断各自独立**:宿主侧每类 100 行(`truncated`)、工具侧 20 行;真机上
1066
- 卷多到几千个时,`system df -v` 的输出本身也可能撞 8MB 预算(那时 `degraded: true`,
1067
- 汇总仍然可用)。`system df` 的人工可读大小是 docker 自己算的(SI 单位),
1068
- 「合计可回收」由字符串解析成字节再相加——**解析不出就整条显示 `—`**,不给近似值。
1069
- - **`volume prune` 会删数据,且行为随版本变**:docker ≥ 23 的 `docker volume prune`
1070
- 有 `-a/--all`,**不带时只删匿名卷**(本插件就不带);但 docker < 23 没有这个开关,
1071
- plain prune 会把未被使用的**命名卷**一起删掉。所以卷清理的确认文案把版本差异写明了,
1072
- 执行前请确认没有要保留的数据卷。
1073
- - **网络 / 卷的变更只有面板按钮,没有 agent 工具**:镜像的 remove / prune 有对应工具,
1074
- 网络 / 卷这轮只补了 HTTP 端点(`/networks/remove` 等)与面板按钮——刻意不扩大 agent
1075
- 侧的变更面。要让 agent 也能删,需要另外加工具(含确认约定)。
1076
- - **总览只汇总「容器概况」,且完全只读**:目标选择器旁的 `总览` 只做计数卡与异常
1077
- 置顶表,**没有任何跨目标操作**(启停删仍在单目标列表里逐个做),也不聚合日志 /
1078
- 统计 / 事件流(那些仍是单目标、单容器页面的事)。另外「异常」只含 `unhealthy` 与
1079
- `restarting`:`ContainerSummary` 里没有 `exitCode`,无法区分「崩溃退出」与「人工
1080
- 停掉」,把全部 `exited` 都算异常等于让停过一次的容器永久刷屏——死掉的容器请在
1081
- 计数卡的「已停止」里看。
1082
- - **连接栏按钮需要一条匹配的目标才有数据**:按钮在 SSH 标签上一律显示,但若会话
1083
- 主机没有对应的 `kind=ssh` 目标(连接簿名或 `host:port` 都匹配不上),点开只会看到
1084
- 「尚未配置为 Docker 目标」的提示而不是容器列表;本地标签的连接栏本身隐藏。
1085
- 目标增删后最多 30 秒内刷新(设置卡片保存会立即刷新)。
1086
- - **`enabled: false` 是热生效**:保存后工具立即注销、公告撤下、除 `/config` 外的
1087
- 数据路由一律 403(进行中的长流——日志 / 统计 / 拉取——也会被立即收尾)。
1088
- 路由对象本身不卸载、靠 403 拦截;`/config` 始终可读写,设置卡片就是重新
1089
- 启用的入口,无需重启 `dsh web`。
1090
- - **使用审计走宿主 stdout,exec 命令会随行出现**:每一次**过了能力闸**的变更 / exec
1091
- (面板的变更 / exec 路由与 agent 的 `docker_action` / `docker_image_remove` /
1092
- `docker_image_prune` / `docker_image_pull` / `docker_exec`;403 与参数校验 400 不记,
1093
- 「使用 = 过了闸」)落一行 `[dsh-docker] capability-use: capability=allowMutations|allowExec
1094
- source=http|tool action=container.remove|… target=… ref=… ok=true|false durationMs=…`,
1095
- key=value 风格与 kit elevation 的授权行一致,`capability` 与授权行直接对得上号;pull
1096
- 进度流拆 start / end 两行(宿主中途挂掉时至少 start 还在)。**出口是 `console.log`**
1097
- (docker D162:真机实测 `ctx.logger` 的行在 0.2.x 宿主上既不进 stdout 也不落盘,而审计行的
1098
- 意义全在「出问题时翻得到」;tier-gate 的日志出口同此取向)——跑 `dsh web` 的那个终端里
1099
- 就能看到、可 grep。kit elevation 的授权行同走宿主
1100
- stdout(**kit D14**,两类行同通道,可对照检索)。**代价**:exec 的命令会记进行尾 `detail`(连同退出码,
1101
- 如 `detail=code=0 cmd=ls -la /app`),整段截断 200 字符、控制字符转义成 `\n` 等字面量——
1102
- 行是给人看的,就得接受它出现在终端可见的日志流里,与 elevation「nonce 绝不进日志」是
1103
- 同一类取舍的正面。docker 报错也算一次使用(`ok=false` + `detail=` 截断后的错误文案);
1104
- tier-gate 的 ask / deny 不记(没执行不算使用,放行与否由宿主 approval 日志覆盖)。
1105
- 没有查询界面、没有独立审计文件——要检索就翻宿主 stdout。
1106
-
1107
- - **跨目标聚合的边界**:并发上限 4;**单目标预算 90s(兜底)**,而每条命令自己的超时
1108
- (30s)才是被遵守的那个——到点时**底层的子进程 / SSH 通道会被真的取消**(不是只让这一格
1109
- 返回失败),否则超时那条命令会继续占着远端会话槽(D161)。单个目标失败/超时只影响它自己
1110
- 那一格(组里带 `error`)。目标很多时总览的请求量随目标数线性增长(每目标 2 个请求),
1111
- 自动刷新会放大这个量——目标多时建议关掉自动刷新。
770
+ `omitted` 里。**它不能替代 `docker commit` / 卷备份**。
771
+ - **磁盘明细的两处截断各自独立**:宿主侧每类 100 行(`truncated`)、工具侧 20 行;真机上卷多到几千个时,
772
+ `system df -v` 的输出本身也可能撞 8MB 预算(那时 `degraded: true`,汇总仍然可用)。「合计可回收」由
773
+ 字符串解析成字节再相加——**解析不出就整条显示 `—`**。
774
+ - **总览只汇总「容器概况」,且完全只读**:只做计数卡与异常置顶表,**没有任何跨目标操作**,也不聚合日志 /
775
+ 统计 / 事件流。另外「异常」只含 `unhealthy` 与 `restarting`:`ContainerSummary` 里没有 `exitCode`,
776
+ 无法区分「崩溃退出」与「人工停掉」——死掉的容器请在计数卡的「已停止」里看。
777
+ - **连接栏按钮需要一条匹配的目标才有数据**:按钮在 SSH 标签上一律显示,但若会话主机没有对应的 `kind=ssh`
778
+ 目标(连接簿名或 `host:port` 都匹配不上),点开只会看到「尚未配置为 Docker 目标」的提示;本地标签的
779
+ 连接栏本身隐藏。目标增删后最多 30 秒内刷新(设置卡片保存会立即刷新)。
780
+ - **`enabled: false` 是热生效**:保存后工具立即注销、公告撤下、除 `/config` 外的数据路由 403(进行中的长流
781
+ 也会被立即收尾)。路由对象本身不卸载、靠 403 拦截;`/config` 始终可读写,设置卡片就是重新启用的入口,
782
+ 无需重启 `dsh web`。
783
+ - **使用审计走宿主 stdout,exec 命令会随行出现**:每一次**过了能力闸**的变更 / exec(面板路由与 agent 的
784
+ `docker_action` / `docker_image_remove` / `docker_image_prune` / `docker_image_pull` / `docker_exec`;
785
+ 403 与参数校验 400 不记,「使用 = 过了闸」)落一行 `[dsh-docker] capability-use:
786
+ capability=allowMutations|allowExec source=http|tool action=… target=… ref=… ok=true|false
787
+ durationMs=…`,key=value 风格与 kit elevation 的授权行一致;pull 进度流拆 start / end 两行。**出口是
788
+ `console.log`**(`D162`:真机实测 `ctx.logger` 的行在 0.2.x 宿主上既不进 stdout 也不落盘)——跑
789
+ `dsh web` 的那个终端里就能看到、可 grep。**代价**:exec 的命令会记进行尾 `detail`(连同退出码),整段
790
+ 截断 200 字符、控制字符转义成字面量——行是给人看的,就得接受它出现在终端可见的日志流里。docker 报错也
791
+ 算一次使用(`ok=false`);tier-gate 的 ask / deny 不记。没有查询界面、没有独立审计文件。
792
+ - **跨目标聚合的边界**:并发上限 4;**单目标预算 90s(兜底)**,而每条命令自己的超时(30s)才是被遵守的
793
+ 那个——到点时**底层的子进程 / SSH 通道会被真的取消**,否则超时那条命令会继续占着远端会话槽(D161)。
794
+ 单个目标失败/超时只影响它自己那一格。目标很多时总览的请求量线性增长(每目标 2 个请求),自动刷新会放大
795
+ 这个量。
1112
796
 
1113
797
  ## 工作原理
1114
798
 
1115
799
  ```
1116
800
  浏览器半体 (client.js)
1117
- ├─ 侧边栏「容器」入口 → 面板:目标选择 / 容器列表(搜索 + 状态筛选)/
1118
- │ 容器卡片(动作条分两组:查看=终端/日志/统计 | 变更=启停/重启/删除)/
1119
- │ 容器列表「活动」条(事件驱动刷新)+「聚合选择」多选 → 临时聚合日志/
1120
- │ 多目标总览(全部目标并行取数 + 渐进落地;计数卡 / 异常置顶表;只读)/
1121
- │ Compose 项目视图(项目分组 / 服务表 / 项目级聚合日志)/
1122
- │ 镜像列表(行内详情 · 删除)+ 镜像详情(层 / 构建历史)+ 拉取进度 /
1123
- │ 网络列表 + 网络详情(子网 / 接入的容器)+ 卷列表 + 卷详情 / 一次性 exec
1124
- │ └─ 终端抽屉:tty ≥ 0.15 时经 ttyTerminal.mount 就地嵌入 tty 的终端
1125
- │ (面板不收起;折叠/拖拽只改尺寸,会话不中断;✕ 或关面板才 dispose,
1126
- │ 有活动会话时关面板先确认 → tty 那边结束会话并拆 DOM)
1127
- │ ├─ fetch → /api/dsh-docker/*(loopback 围栏)
1128
- │ ├─ FOLLOW → EventSource /logs/stream(SSE:5000 行 / 4MB 环形缓冲、150ms 合帧、
1129
- │ │ 行 id 稳定 key、显示不截断 / 自动贴底 /
1130
- │ │ 回到底部 / 断线自动重连 / 容器退出自动回快照)
1131
- │ ├─ 统计 FOLLOW → EventSource /stats/stream(SSE:60 点环形缓冲画
1132
- │ │ CPU / 内存 sparkline;**前端主动断**,docker stats 自己退出才收流)
1133
- │ ├─ 事件 → EventSource /events/stream(SSE:活动条环形缓冲 50 条 +
1134
- │ │ 500ms 防抖触发列表重取;重连成功后补一次全量刷新)
1135
- │ ├─ 拉取 → EventSource /images/pull/stream(SSE:逐层进度按层键 upsert)
1136
- │ └─ 聚合日志 → **一条** EventSource /logs/stream?ids=…(多容器共用;帧带 id,
1137
- │ 客户端按 [service] 混流;老宿主退回每容器一条)
1138
- ├─ 可选消费 tty 的 ttyConnbar 服务 → SSH 连接栏「容器」按钮
1139
- │ (按 book 名 / host:port 匹配已配置目标,命中才出现)
1140
- ├─ 可选消费 tty 的 ttyPanel 服务(0.16.0)→ 终端面板开着时挂右侧 dock
1141
- └─ 可选消费 tty 的 ttyTerminal 服务 → 卡片「终端」按钮直接开 docker exec 标签
1142
- (tty 不可用 / 凭证不在浏览器时退回复制命令)
801
+ ├─ 侧边栏「容器」入口 → 面板(目标选择 / 容器列表 / 卡片动作 / 五页签详情 / 多目标总览 /
802
+ │ Compose · 镜像 · 网络 · 卷 · 磁盘各页)与终端抽屉(ttyTerminal.mount)
803
+ ├─ 四条 SSE(/logs/stream 含 ids=、/stats/stream、/events/stream、/images/pull/stream)
804
+ │ + 普通 fetch → /api/dsh-docker/*(loopback 围栏;写路由另需同源证明)
805
+ └─ 可选消费 tty 的 ttyConnbar / ttyPanel / ttyTerminal(未装或版本不足逐项降级)
1143
806
 
1144
807
  宿主半体 (src/index.ts)
1145
- ├─ settings 命名空间 docker(~/.dsh/settings.yaml)
1146
- │ DOCKER_SETTINGS_SCHEMA → normalizeConfig(夹紧 / 白名单键)
808
+ ├─ settings 命名空间 docker → normalizeConfig(夹紧 / 白名单键)
1147
809
  ├─ 只读复用 tty settings 的 sshHosts(连接簿)与 hostKeys(指纹种子)
1148
- ├─ resolveTarget:local → runLocal;ssh → book 查连接簿或内联字段
1149
- ├─ 每目标一个 DockerApi(src/docker.ts)
1150
- │ ├─ argv 构造 + assertRef 白名单 + 输出上限(maxOutputKb)
1151
- │ └─ 解析容错:{{json .}} 逐行/数组、字段名大小写兼容、缺字段降级
1152
- ├─ RemoteExec(src/ssh-exec.ts)
1153
- │ ├─ 懒连接池:同 user@host:port 复用一条连接,空闲 120s 回收
1154
- │ │ (每 30s 扫一次,连接超时 20s,keepalive 10s;busy>0 的长流跳过回收)
1155
- │ ├─ 非 PTY exec channel:一命令一 channel,收完 stdout/stderr 即关;
1156
- │ │ 长流(run()/stream())不设总超时与输出上限,靠 AbortSignal 停止
1157
- │ ├─ shJoin 单引号转义(远端 shell 解析);env:NAME 凭据解析(provider 优先 → 退回 env)
1158
- │ └─ hostVerifier TOFU 钉扎(首次记录、变更拒绝)
1159
- ├─ runLocal / runLocalStream:spawn(dockerBin, args)(不经 shell,本机目标)
1160
- │ 停止阶梯:SIGTERM → 2s 未退出 SIGKILL
1161
- ├─ 通用 SSE 长连接 openSseStream(src/index.ts,四条流共用一份基建)
1162
- │ ├─ loopback 围栏 + assertRef / assertImageRef + tail 夹紧(与快照路由一致)
1163
- │ ├─ 写头 + flushHeaders / 15s ping 心跳 / 活跃流登记 / 前端断开静默 abort
1164
- │ │ 管道起来后先发 ready{}(StreamHandlers.onStart:客户端只认它当「在收数据」)
1165
- │ ├─ /logs/stream:docker logs -f → line{"d"|"e"} + end{container-exit,code};
1166
- │ │ ids= 模式:一条连接 N 个执行器 → line{id,d|e} + 每容器 source-end/source-error
1167
- │ ├─ /stats/stream(只读):docker stats 逐行归一 → stats{ContainerStats};
1168
- │ │ 不会自然结束,前端断连即 abort,docker stats 自退则 end{stats-exit}
1169
- │ ├─ /events/stream(只读):docker events --filter type=container →
1170
- │ │ event{action,name,image,...}(白名单过滤);daemon 侧结束则 end{events-exit}
1171
- │ ├─ /images/pull/stream(allowMutations):docker pull → line{d|e} +
1172
- │ │ end{pull-exit,code,ref};未开启变更时 403 且不建流
1173
- │ └─ 插件禁用 / 配置热更新 / 卸载 → 四条流统一 end + abort
1174
- ├─ 镜像路由:/images/inspect(只读)· /images/remove · /images/prune(allowMutations)
1175
- ├─ 网络 / 卷路由:/networks · /volumes 与各自的 inspect(只读)、remove / prune(allowMutations)
1176
- └─ agent 工具:docker_targets / docker_ps / docker_inspect /
1177
- docker_logs(快照语义不变)/ docker_logs_grep(服务端检索)/ docker_stats / docker_events / docker_images /
1178
- docker_image_inspect / docker_networks / docker_volumes(有目标即注册)
1179
- + docker_action / docker_image_remove / docker_image_prune / docker_image_pull
1180
- (allowMutations + 有目标)/ docker_exec(allowExec + 有目标)
810
+ ├─ resolveTarget → 每目标一个 DockerApi(src/docker.ts)+ RemoteExec(src/ssh-exec.ts):
811
+ │ argv 构造 + assertRef + 输出上限;懒连接池 / 非 PTY exec channel / shJoin / TOFU
812
+ ├─ openSseStream:四条流共用一份基建(ready / 心跳 / 活跃流登记 / 背压 / 统一收尾)
813
+ └─ 路由 + agent 工具(读类恒注册,写 / exec 类按开关注册)——清单见上面两张表
1181
814
  ```
1182
815
 
1183
816
  ## 开发与验收
@@ -1189,23 +822,15 @@ pnpm --filter @hyzyn/dsh-docker smoke # 三套离线回归,都不需要
1189
822
  pnpm test # 仓库级 vitest(本包 test/ 下的用例都在里面;项数以运行输出为准)
1190
823
  ```
1191
824
 
1192
- > **改了哪一半、怎么才生效**(踩过两次的坑):
1193
- >
1194
- > - `client-src/*`(浏览器半体)→ esbuild 出 `client.js`。宿主有 client HMR 轮询各插件的
1195
- > client bundle,**热更**,刷新页面即见;
1196
- > - `src/*.ts`(宿主半体)→ tsc 出 `lib/`。**必须是新进程才生效**:运行中的 `dsh web`
1197
- > 在启动时就把 `lib/` 载进内存,之后 `lib/` 再变它也不会重载。改完 `pnpm build` 记得
1198
- > 重启:`dsh web --profile <name>`。
1199
- >
1200
- > 忘了重启的症状很迷惑:**客户端是对的、宿主是旧的**,于是错误文案、重试、配额这类宿主侧
1201
- > 逻辑全都不生效,看起来像「改了没用」。判断依据是**文案**——宿主侧新增的提示语如果没出现,
1202
- > 那就是旧进程。
1203
-
1204
- 验证面分三层:**三套离线 smoke**(不需要 docker daemon)、**本包的 vitest 套件**、
1205
- 以及**真 daemon 的手工验收清单**。逐项的覆盖范围、浏览器侧性能门槛与手工步骤见
1206
- [docs/verification.md](./docs/verification.md)——README 只留「怎么跑」,清单不在这里重复
1207
- (同一事实两处全文是债,见 [conventions § 文档分层](../../docs/conventions.md#文档分层))。
825
+ > **改了哪一半、怎么才生效**:`client-src/*`(浏览器半体)→ esbuild 出 `client.js`,宿主有 client HMR
826
+ > 轮询,**热更**、刷新页面即见;`src/*.ts`(宿主半体)→ tsc 出 `lib/`,**必须是新进程才生效**(运行中的
827
+ > `dsh web` 在启动时就把 `lib/` 载进内存),改完 `pnpm build` 记得重启。忘了重启的症状是**客户端是新的、
828
+ > 宿主是旧的**:宿主侧新增的提示语没出现,那就是旧进程。
829
+
830
+ 验证面分三层:**三套离线 smoke**(不需要 docker daemon)、**本包的 vitest 套件**、以及**真 daemon 的
831
+ 手工验收清单**。逐项的覆盖范围、浏览器侧性能门槛与手工步骤见
832
+ [docs/verification.md](./docs/verification.md)——README 只留「怎么跑」,清单不在这里重复。
1208
833
 
1209
834
  ## 版本 / 许可证
1210
835
 
1211
- `@hyzyn/dsh-docker` 0.3.1 · [Apache License 2.0](../../LICENSE)
836
+ `@hyzyn/dsh-docker`(版本以 `package.json` 为准) · [Apache License 2.0](../../LICENSE)