@hyzyn/dsh-docker
中文 | English
DSH 侧边栏「容器」面板:本机与 SSH 主机的容器一屏巡检——读日志、看资源、进容器、启停删,默认只读。
特性
- 常驻会话右侧栏:面板作为右侧栏标签(
sidebar.right.pane.tab)与对话同屏——发完错误交给 Agent 后日志留在右边;折叠即退出视野并主动断流、把 SSH 通道还回去。面板级状态(目标 / 视图 / 过滤词 / 选中容器及页签)跨会话保留;拿不到右侧栏服务的老宿主退回 dock / 模态。 - 日志右键交给 Agent:日志页拖选报错行 → 右键「直接发送到当前会话 / 填入输入框,我先改改」,投递
「选中的行 + 目标 / 容器 / 时间窗 + 前后各 20 行上下文」(会进模型上下文,菜单里明写留意凭证);回执
两处:视口级 toast,以及终端面板开着时自动折起终端(
ttyPanel契约 v2)。改稿走「填入输入框」 ——编辑面只有会话输入框一个。 - 一键连接本机 / 多目标聚合取数:两个非常驻入口(空态主按钮 / 目标下拉末项)一次点击就加上本机
目标(agent 侧
docker_connect_local);总览对全部targets[]并行取数,单个目标不可达只污染自己 那一格(docker_ps target:"*"同一口径)。 - 「需关注」读权威字段:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次
docker inspect补齐——docker ps摘要里的 137 分不出 OOM 与手动 kill(口径见 总览)。 - 排障闭环:诊断页签把四路采集变成「结论 + 判据 + 证据 + 时间线 + 建议」,诊断历史再答「是不是在
变坏」;「进程」页签给
docker top快照;「磁盘」视图摊开可回收账与「谁在用」;概览底部反推一条可复现 的docker run。见 容器诊断 与 诊断历史。 - 四条 SSE 长流共用一套基建:日志 FOLLOW、
docker stats、docker events、docker pull走同一个openSseStream(心跳 / 活跃流登记 / 断开清理 / 管道起来后先发一帧ready),差异只在收尾语义;聚合 日志把多个容器装进一条/logs/stream?ids=…。「连上了」与「在收数据」是两件事:客户端只认ready。 见 SSE 事件协议。 - 默认只读,两个独立开关:
allowMutations(容器启停删 + 镜像删除 / 清理 / 拉取)与allowExec(一次性docker exec);关闭时对应 agent 工具不注册、 HTTP 路由 403(能力不存在,而非调用后报错);提权走两条带外通道(启动环境变量 / 卡片里就地确认)。 容器名与 ID 过白名单,命令一律 argv 构造 + 单引号转义,密码 / 口令以env:NAME凭据引用且永不回传 浏览器。 - 与 dsh-tty 数据级复用、代码级不耦合:不 import 任何 tty 代码,两者可各自安装与升级;装了 tty 则消费
三个可选扩展点(
ttyConnbar/ttyTerminal/ttyPanel),未装或版本不足逐项静默降级。
与 dsh-tty 的关系
本插件自成一体:不 import 任何 tty 代码,tty 也无需改一行源码;装了 tty 就在下面这些可选扩展点上 协作,没装则安静降级。
| 维度 | 说明 |
|---|---|
| 插件形态 | 独立包 @hyzyn/dsh-docker,可各自单独安装、各自升级 |
| 连接簿 | SSH 目标可引用 tty 连接簿条目名(只读 ctx.settings.get('tty') 的 sshHosts);tty 未安装时退化为「内联 host/username」或本机目标 |
| 主机指纹 | 本插件自持一份 hostKeys(TOFU),并优先以 tty 已记录的指纹作种子——同一主机不必在两处各确认一次 |
| 执行通道 | 自持池化 SSH exec(src/ssh-exec.ts),与 tty 的 PTY 会话完全独立,互不占名额 |
| 上下文入口 | tty ≥ 0.13.0 时可选消费 ttyConnbar,在 SSH 连接栏(SFTP 旁)插入「容器」按钮(注册即显示),目标在点击时按当前会话解析;本插件自己开的 exec 标签除外(判定走 spawnSpec.command);tty 未装 / 版本过旧则静默跳过 |
| 面板承载 | 默认走会话右侧栏标签(sidebar.right.pane.tab),框架侧边栏入口只负责打开或聚焦它、页类型在同一栏内去重;终端连接栏的入口相反——它长在盖满视口的 tty 弹窗上,开标签会被挡住,所以走 ttyPanel.mountPane 停靠到终端右侧(入口决定承载)。拿不到右侧栏服务(老宿主)或把 localStorage['dsh-docker:carrier'] 置成 modal 时回退到 dock / 全屏模态。三种承载是同一个组件,只是外壳与几何不同 |
| 终端承载 | 交互式终端由 tty 承载(它才是 PTY 的所有者)。右侧栏标签:面板全屏(sidebar.fullscreen)时经 ttyTerminal.mount 就地嵌入面板底部,否则经 ttyTerminal.open 开新标签(同一容器重复点会聚焦已有标签——tty 契约 v3 的 reuse);dock 承载一律开标签;模态承载就地嵌入。命令标签(spawnSpec.command 非空)在 tty 侧不显示连接栏的扩展按钮区(SFTP / 隧道都作用于连接本身,挂在 docker exec 标签上会误导);本插件自己开的 exec 标签另不给「容器」入口。未装 tty 或版本不足时复制命令兜底。本插件不实现 PTY / xterm / 重连栈 |
| 分工 | 交互式排障(docker exec -it、容器内 shell、TUI)由 tty 承载;只读巡检与 agent 自动化用本插件自己的 exec 通道 |
安装
dsh plugin --profile web add @hyzyn/dsh-docker # npm 安装(发布后)
dsh plugin --profile web add link:$(pwd)/packages/docker # 仓库开发调试
聚合包 @hyzyn/dsh-all(或仓库根 bundle)已包含本插件,一次装齐时不必单独 add。装完重启 dsh web,
侧边栏出现「容器」入口;「Docker 容器面板」卡片维护目标与开关,保存即热生效(settings/updated
触发重解析,无需重启)。
已在 web profile 里装过
@hyzyn/dsh-all或根 bundle 时不要再 add 本包,否则插件行重复挂载, 启动报duplicate loader entry id。
使用
两个入口,同一个面板。默认承载是会话右侧栏标签——入口只负责打开或聚焦它,面板与对话同屏。

- 侧边栏「容器」(总入口):任何目标都能用,包括本机 docker 与多目标切换。已打开则聚焦(页类型在 同一栏内去重),不会开出第二个「Docker 容器」标签。
- SSH 连接栏「容器」按钮(上下文快捷方式,tty ≥ 0.13.0):在 tty 面板的 SSH 标签里、SFTP 旁出现,
注册即显示,点击直接用当前会话那台主机打开面板。目标在点击时解析:会话来自连接簿时按条目名
匹配,否则按
host:port;没配到目标也不藏按钮——面板会带一条提示告诉你该怎么配。这条路径走 终端右侧的 dock,不开右侧栏标签(按钮长在盖满视口的 tty 弹窗上,开标签会被挡住)。于是规则是 「入口决定承载」:框架侧边栏点 → 右侧栏标签;终端弹窗里点 → 终端右侧 dock。
承载形态
| 承载 | 何时用 | 行为 |
|---|---|---|
| 会话右侧栏标签(默认) | 宿主提供 sidebarRight / sidebarRightTabs |
与会话同屏;折叠=退出视野但不丢状态;支持右侧栏的 fullscreen 铺满 |
| 终端右侧 dock | 从终端连接栏的「容器」按钮进来,或拿不到右侧栏服务 / localStorage['dsh-docker:carrier'] = 'modal' 且 tty ≥ 0.16 面板开着 |
挂在终端面板右侧(拖宽 / 折叠 / ✕ 由 tty 提供,终端继续可用);同一时刻只挂一个,挂上去会收掉前一个(例如 tty 自己的 SFTP);tty ≥ 0.18.4 起归属点它的那个标签:切走收起、切回恢复、标签关掉一并收掉(收起期间 React 树与轮询保活) |
| 全屏模态(兜底) | 上面两条都不成立 | 自带 backdrop、点击外部关闭;面板 z-index 高于 tty 弹窗 |
回滚到旧形态只要在控制台执行 localStorage.setItem('dsh-docker:carrier', 'modal')(removeItem
恢复默认)。这个开关是临时灰度用的,所以刻意没进 settings。
状态保留:面板看的是主机、不是工作区,所以 view / 过滤词 / 选中容器(含概览-日志-统计页签)/ 目标
都是跨会话保留的;容器详情内部的日志过滤与 LINES 开关跟具体容器绑定,不保留。折叠标签会断开全部
实时流(把 SSH 通道还回去),展开时重连——建流本来就带 tail,历史会自己补回来。
跨会话粘性:DSH 右侧栏的标签记录本身是会话作用域的,A 会话开的标签在 B 会话里并不存在;而面板
看的是主机,「切个会话它就没了」是纯损失。所以插件额外记一个「用户希望它开着」的意图:切会话时自动
在新会话里把标签重开,只有你点了标签的 ✕ 才停止。那个 ✕ 是宿主执行的,所以必须经
registerCloseHandler 登记才收得到——只靠面板自己的 onClose 会漏掉它。
dock 兜底形态(终端右侧栏):

这条路径打开的面板不会盖住终端:tty ≥ 0.16 时它挂在终端面板右侧的 dock 里(可拖宽、可折叠成窄条、
✕ 收起),终端照常敲命令。dock 模式下卡片「终端」按钮改为在同一终端面板新开标签执行
docker exec -it;同时不再渲染面板自己的头部——标题与 ✕ 由侧栏标题栏承担,刷新与只读徽标并到
工具条末尾并靠右:左端留给目标 / 视图 / 搜索 / 筛选这些「过滤类」控件。
面板内:

切目标有过渡与保护:选择器一变,下方仍是上一个目标的卡片(远端一次往返最长 20s)。过渡期间旧 卡片去色并锁住指针(读起来是「另一批内容」而不是「坏了」,也避免照着旧列表操作——那会拿新目标 当目标、用旧容器的 ID 发命令,真能停掉对端同名容器),新数据淡入落地;整块绝对定位,首卡位置与 滚动高度不变。切换失败则清空旧列表并归到新目标的错误态,空态文案区分「读取失败」与「筛选过窄」。
目标选择:面板先选目标(来自配置
targets,本机 / SSH);只配了一个时默认选中它,agent 工具也 可以省略target。会记住上次选的目标(localStorage的dsh-docker:last-target,不落配置): 优先级是连接栏指定 > 上次记住的(且仍存在)> 列表第一个。换目标是整段上下文切换:上一个目标 还在飞的请求一律作废(列表写入闸),不会出现「选择器已是目标2、卡片还是目标1 的容器」这种串台。容器列表:名称 / 状态 / 健康态 / 镜像 / 端口映射 / compose 项目与服务 / 短 ID;支持按名称或镜像 搜索、按状态筛选、自动刷新(按
pollIntervalSec轮询,切到镜像页会暂停且不显示该开关)。 「含已停止」与「自动刷新」是成组的两个开关,搜索框是其中唯一可伸缩的项:宽面板下一行,窄栏 (dock 520px)里才按组换行。「活动」条(docker events 事件流):容器列表头部的可折叠窄栏(默认展开),跟着一条 SSE (
GET /api/dsh-docker/events/stream,服务端跑docker events --filter type=container)显示最近 8 条 事件(时间 + 容器名 + 动作,die带退出码)。它是事件驱动刷新的入口:收到事件后 500ms 防抖 触发一次列表重取,与AUTO REFRESH叠加而不互斥——轮询兜底,事件负责「刚发生」。事件只留在内存 (环形缓冲 50 条),切页即关流,换目标清空。白名单只留九类生命周期动作(start / die / stop / kill / oom / health_status / destroy / rename / update):exec_*、archive-path这类噪音在服务端就丢掉。 断开后要手动重连:进了closed就是终止态,活动条上出现重连按钮(unsupported环境不给), 工具条的 ⟳ 也一并重连;AUTO REFRESH 刻意不重连(塞进轮询会让已终止的流被不断重开),重连成功后 补一次全量列表刷新。重连被拒要说原因(D164):服务端主动拒绝时发一帧event: error带message,客户端把它显示在活动条上——只置closed会让「拒绝 → 回到原位」与「点了没反应」不 可区分。「在收事件」只认宿主那帧ready(响应头到达不算)。聚合选择(临时多选聚合日志):工具条的
聚合选择进入选择态——点卡片本体变成勾选 / 取消 (动作条暂时收起,免得一边多选一边误点启停删),出现操作条:「已选 N 个容器」+聚合日志+取消。聚合日志至少需要 2 个容器、最多 3 个(超了按钮置灰并提示上限来由)。进入后复用 Compose 项目 视图那套聚合日志,内容控制与单容器日志完全对齐,另有按时间 / 按到达排序与暂停。勾选是临时 的:不持久化、不进 settings;切目标 / 切分段 / 关面板即失效,列表刷新后已消失的容器按 id 自动剔除。按条件一键选中(多选态):操作条第二行给出条件 chip(
全部可见 / 不健康 / 需关注 / 已停止,以及 有勾选后的同镜像 / 同项目),计数按剩余名额截断——chip 上写 3 就真的会选中 3 个。条件只在 当前筛选结果里生效。容器卡片:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 / compose)+ 一排图标按钮, 按「查看 / 变更」两组用竖线分隔:
组 按钮 说明 查看 / 进入 终端、日志、资源占用 不改容器状态,只读模式下也永远可用。终端按钮跑 docker exec -it交互式 shell,不受allowExec门控(那条开关只管本插件的一次性 exec 通道,见「已知限制」)变更 启停、重启、删除 按破坏性递增排列;只在 allowMutations开启后可用,未开启时整组置灰删除额外两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。命令飞行期间整组 锁住:
stop/rm要等容器真的退出(最长十几秒),确认框保持打开并显示「执行中…」,其它变更按钮 压暗置灰、正在跑的那个换成转圈,等列表刷新落地后才恢复。镜像的删除 / 清理走同一套确认框。点卡片本体 进入概览。容器详情(整栏视图):顶部为「返回 + 容器名 + 状态徽标 + 目标主机」,下方五个标签页——概览 (
docker inspect权威数据 + 一次性 exec + 等价docker run)、日志、统计、诊断、进程。从卡片的 日志 / 统计图标可直接落到对应页签;诊断与进程只在该页签被打开时才取数(切走即不再请求)。日志视图(紧凑两行):第一行 = 返回 + 容器名 + 状态 + 目标主机 +
LINES/TIMESTAMPS/FOLLOW/AUTO REFRESH(开关 + 2/3/5/10s 间隔,仅日志页轮询)+ 刷新 / 关闭;第二行 = 标签页 + 常驻的「过滤日志」输入框(有内容时浮出 ✕,Esc 也能清空)+ 级别门槛(全部级别 / INFO+ / WARN+ / ERROR+)+ 导出(⬇ .log/⬇ .md,导出当前显示内容)+ 右侧固定槽的行数统计。 两行的分工是刻意的:第一行管传输与显示,第二行管内容——而第二行与聚合日志完全同一套(同一个 级别内核、导出构建器、计数文案)。级别门槛的语义是「无级别前缀的行是上一条的续行,跟随其级别」, 否则ERROR+会把堆栈拦腰截断。日志正文按级别着色,时间戳压暗,过滤命中高亮。FOLLOW 实时日志流:打开后从「定时拉快照」切为 SSE 推送(
GET /api/dsh-docker/logs/stream, 服务端跑docker logs --follow),与AUTO REFRESH互斥(开流自动停轮询、开关置灰),关闭即回到 快照并立即刷新。流式日志保留最近 5000 行 / 4MB(环形缓冲,超限丢最旧并提示一次;无换行的超长 输出强制分片,内存恒有界),分片到达按 150ms 合帧渲染。正文按窗口化挂载(D152):DOM 里只 留「可见 + 上下各 24 行 overscan」,其余用上下垫高占位;行高是实测的、贴底时窗口锚在尾部,所以 「自动贴底 / 上滚暂停 / 回到底部」的几何判定依旧精确;换流 / 换容器 / 快照刷新会整代作废这批实测 高度与锚点(D155:快照行 id 是位置寻址,不换代就会拿另一代的高度算垫高)。显示层不截断:LINES选多少就能滚到、能导出(真正的闸是缓冲与宿主的「输出上限(KB)」)。断线重连由插件自己 管:首连带 tail 补历史,重连一律tail=0——只补新行、不重放历史。宿主侧背压(D153): 单条流的暂存队列上限 8MB;日志与拉取这类文本尾部流溢出时丢最旧的帧、留最新,并推一条skip{frames,bytes}让界面说明「中间断了一截,跟随继续」——不再掐流;skip 的账目是精确的(排队 期间再次丢弃原地更新计数,D156;客户端把多批 skip 累加显示,重连成功即清零,D157)。统计 / 事件这类结构化流仍走收尾重连。概览:
docker inspect的权威数据——状态与健康、退出码、重启次数与策略、端口映射、挂载(含只读 标记)、网络与 IP、entrypoint 与命令、最近一次健康检查输出;下方可执行一次性docker exec(需allowExec)。日志:
docker logs --tail的尾部快照(LINES默认取设置卡片的「日志默认行数」),可切时间戳与--since。两个闸要分清:LINES是行数上限,设置卡片的「输出上限(KB)」是字节上限—— 两者取小,字节先超就截断并给出横幅。要持续观察新日志就打开FOLLOW(同一份 argv 加--follow, 无总超时、不受该字节上限约束,靠连接生命周期收尾)。

- 统计:
docker stats --no-stream快照(CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs),按pollIntervalSec轮询刷新。统计页还有FOLLOW实时跟随:切到GET /api/dsh-docker/stats/stream(服务端跑不带--no-stream的docker stats,每秒一行), 浏览器侧留 60 个点的环形缓冲画 CPU / 内存迷你趋势图。这条流不会自然结束,关闭语义是前端主动 断;docker stats自己退出时服务端发end(reason=stats-exit)。 - Compose 项目视图:工具条第三段。把
composeProject/composeService聚合成「项目 → 服务 → 容器」,每个项目一行看全运行数 / 不健康数 / 服务数;点进项目可看服务表,或打开项目级聚合日志 ——一条/logs/stream?ids=…装下项目内所有容器(帧里带id),客户端按[service]前缀混流。某个 容器的子流失败只标注「部分出错」,其余容器的日志照常收。 - 镜像:
docker images列表(reference / 大小 / 创建时间 / 短 ID),<none>:<none>的 dangling 带标记;搜索框与计数固定在工具条里,表头列名在表体内吸顶。每行「详情 / 删除」:详情打开整栏 视图(概览 +docker history构建历史);删除(需allowMutations)二次确认后执行docker image rm(不带-f,被引用时会失败并给出「先删相关容器」的提示)。 - 网络 / 卷:工具条分段扩到「容器 / 镜像 / Compose / 网络 / 卷 / 磁盘」(窄栏里分段自己横向滚动,
不做二级菜单)。两页都是「表格 + 整栏详情」的同构布局:
- 网络:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览与「接入的容器」页签。
容器数刻意不进列表行:只有
docker network inspect才拿得到接入列表,列表逐行 inspect 就是 N 次 docker 调用。 - 卷:名称 / 驱动 / 范围 / 挂载点(超长路径限宽省略,title 给全量),详情只有概览一页(卷没有 反向索引)。
- 详情页头部有删除按钮,两页工具条各有 prune 图标,都受
allowMutations门控(未开启时置灰- title 说明)并二次确认;删除失败就地弹横幅,不会静默。这两页不轮询(清单变化慢)。
- 网络:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览与「接入的容器」页签。
容器数刻意不进列表行:只有
- 拉取镜像(SSE 进度流):镜像页工具条的拉取图标(需
allowMutations)打开拉取视图,输入引用后走GET /api/dsh-docker/images/pull/stream——逐层进度实时出现;进度行按「层键」原地更新,TTY 下的\r刷新也不会让缓冲区越滚越长。工具条的「清理 dangling」执行docker image prune -f,只删无标签 镜像(刻意不加--all)。

一次性 exec:
allowExec开启后可输入命令,等价docker exec <容器> sh -c "<命令>",返回退出码 与 stdout/stderr(无 TTY)。交互式终端(卡片第一个图标):跑
docker exec -it '<容器>' sh,按 tty 能力三级降级——
- 就地嵌入(tty ≥ 0.15,推荐):面板底部开一个终端抽屉,由
ttyTerminal.mount把终端挂 进来,面板不收起;折叠(标题栏箭头或双击顶边)只把抽屉压成一条标题栏、会话照旧跑着, 拖拽顶边可调高度(上限 75%)。真正结束会话的只有抽屉的 ✕ 与关闭面板——有活动会话时关面板 会先弹确认,避免点 backdrop 误杀一个正在排障的容器 shell。 - 借 tty 弹窗开标签(tty ≥ 0.14):tty 新开一个标签执行同一命令,随后收起本面板(本面板 z-index 更高,不收起用户只会觉得「点了没反应」)。
- 复制命令:未装 tty / 版本过旧 / 内联目标用了 key·password 认证(浏览器端没有凭证)时,退化 为复制该命令并提示到终端面板粘贴。
本地目标开本地会话,SSH 目标按连接簿条目名(或内联字段)走 SSH,标题为
<容器> · exec。能力判断走 服务契约版本(ttyTerminal.version >= 2才有mount),不是猜函数存不存在。- 就地嵌入(tty ≥ 0.15,推荐):面板底部开一个终端抽屉,由
目标(本机 / SSH)
kind |
说明 |
|---|---|
local |
宿主所在机器上的 docker CLI(spawn 直接执行,不经 shell) |
ssh |
经 ssh2 连到远程主机,在远程执行 docker CLI(argv 经单引号转义) |
kind=ssh 有两种填法:① 引用 tty 连接簿条目——book 填条目名(在「终端面板」的连接簿里维护),
主机 / 端口 / 用户名 / 认证方式随之生效;设置卡片的下拉只列出 tty 已保存的条目,tty 未安装或条目不
存在时该目标解析失败,面板与 agent 工具都会给出明确错误。② 内联字段——host + username 必填,
其余按需(port / auth / keyPath / password / passphrase / agentForward)。
目标解析是每次操作现算的:在 tty 卡片里改了连接簿条目,下一次操作立即用新值,无需重启。远端需要
装了 docker CLI,且当前账号免 sudo 可用 docker(通常在 docker 组),否则 probe 会原样透出
permission denied while trying to connect to the Docker daemon socket 之类的错误。
连接本机(一键)
不想手填目标时,UI 里有两个入口,都不做常驻:
| 何时 | 入口在哪 |
|---|---|
| 一个目标都没有 | 面板正文的空态里一个 连接本机 主按钮(那时整块是空的,一键就是最短路径) |
| 已经有别的目标 | 目标下拉的末一项 + 连接本机——就在你找机器的地方,点开才出现 |
两处互斥(不会同一个入口出现两次),已经有本机目标时两处都不出现。做成常驻的工具条按钮不划算:
已经有可用目标时它既占位又招人点,是纯噪音;而只留空态那一个入口又会漏掉「手上有几台 SSH、想再加
一台本机」这种常见情况。agent 侧同一件事是 docker_connect_local,HTTP 侧是 POST /connect-local
——所有入口共用宿主里同一份实现。
| 情况 | 行为 |
|---|---|
已有一条 kind=local 目标(无论叫什么名字) |
复用它(created:false),只切过去;不新建、不重写、不改名 |
| 没有本机目标 | 用稳定名 local 追加到列表末尾;local 已被占用时依次试 local-2、local-3… |
| 已有 SSH / 自定义目标 | 原样保留(含顺序与凭据);只在末尾追加本机那条 |
| 重复点 / 并发点 | 幂等:最终只有一条本机目标 |
| 设置存储不可用 / 保存失败 | 明确报错(503 / 500 + 原因),不改内存配置——不会出现「界面上加上了、刷新就没了」 |
写盘成功之后顺带做一次只读探测(docker version),结果放进响应的 message:daemon 可达时给出
服务端版本;不可达时说清成因——docker CLI 缺失(去装 Docker,或把「docker CLI」改成正确路径)/
探测超时(daemon 卡死,或 docker context 指向了连不上的远端)/ daemon 未启动(Linux
systemctl start docker,macOS / Windows 启动 Docker Desktop)/ socket 无权访问(账号加入 docker
组)。探测失败不算这次操作失败(目标确实加上了,reachable:false 单独表示)。
探测用的二进制就是设置卡片里的「docker CLI」(dockerBin,默认 docker)。CLI 缺失时会只读地
在 PATH 里找一下别的容器 CLI(docker → podman → nerdctl),找到就在那句话里点名——只提示,
绝不替你改配置(podman 与本插件输出格式的兼容性并未逐项验证,见「已知限制」)。比对候选时按工具
名(basename)而不是配置里的原字符串。超时是独立一档:回 timedOut:true + 一句可执行的话,并且
真的按时返回——到点先杀、再给 500ms 收敛期,仍不 close 就自行放掉管道收尾(SIGKILL 只作用于直接
子进程,dockerBin 指向包装脚本时孙进程会把手上的上限拖成分钟级)。
刻意不做的事:不自动安装 Docker、不启动 daemon、不切换 docker context、不改任何环境变量;空
targets(= 用户没配)的语义不变,不产生隐式默认目标;clearTargets 的防误清空逻辑一字未动。这条
路径也与能力开关无关——它只写插件自己的配置,不涉及任何需要授权的目标侧写操作。
聚合日志(多选 / Compose 项目)
多选容器或打开一个 Compose 项目,都能把多个容器的日志聚合成一条流:一条 /logs/stream?ids=… 装下
全部容器(帧里带 id;宿主侧仍为每个容器各跑一条 docker logs -f),客户端按到达顺序混流。工具栏提供:
| 控件 | 语义 |
|---|---|
| 实时 / 已暂停 | 真暂停(见下) |
| 时间戳 | 显示每行时间戳。时间戳始终随流接收(timestamps=1),只影响显示 |
| 按到达 / 按时间 | 按到达:零延迟跟随;按时间:用每行的容器时间戳合并成一条真时间线 |
| 级别过滤 | 全部级别 / WARN+ / ERROR+。无级别前缀的行(堆栈等续行)继承上一条日志的级别,所以 ERROR+ 会连它的堆栈一起保留;窗口开头的孤儿续行(记录头在窗口外)保留 |
| ⬇ .log / ⬇ .md | 导出当前显示内容:.log 是纯行文本([服务] ISO时间 正文),.md 带来源容器 / 行数 / 导出时间表头 |
按时间合并怎么做的:SSE 各容器建连有先后,A 的首屏历史可能整批先到、B 随后才到,只对单批排序修 不了跨批逆序。实现是回填最近 400 行——每批新行到达时把「最近 400 行 + 新行」整体按时间戳重排 (RFC3339 前缀为排序依据,解析后从正文里剥掉)。代价是「按时间」模式下已在屏上的最近若干行可能轻微 重排(正在跟随实时输出时建议用「按到达」)。
聚合日志的「暂停」(真暂停)
多选容器 / Compose 项目的聚合日志有一个 实时 / 已暂停 开关。暂停是内容冻结,不只是停止自动滚动:
暂停期间新到的日志进入客户端缓冲、DOM 不再追加(读屏不会被顶走,也不会因显示上限 2000 行裁前部
而跳屏);按钮上直接显示攒了多少行(已暂停 +348);恢复时把缓冲一次性并入(沿用 5000 行环形上限)
并回到底部。只停「自动滚动」是不够的:标签写着「已暂停」而内容还在长,用户会以为开关坏了。
总览(跨目标)与「需关注」口径
目标选择器旁的 总览 pill(配了 ≥2 个目标才出现)一屏铺开全部目标:每个目标一张计数卡(运行中 /
已停止 / 不健康 / 需关注),下方是跨目标的「需关注容器」表(容器名 / 目标 / 状态 / 原因 /
镜像,点行进详情、点卡切到该目标的列表)。三条设计约束:
- 渐进落地 + 失败隔离:每个目标独立请求、独立落格;一台 SSH 不可达不会让整页静默(刻意不用
Promise.all汇总:SSH 不可达要等 readyTimeout 20s,汇总等于让整页静默 20s)。 - 需关注口径以宿主为准:容器列表与
/attention并行请求;后者额外做一次docker inspect,因此能 识别 OOM(OOMKilled) 与真实退出码——ps 摘要里Exited (137)分不出是被 OOM 杀还是手动 kill。 拿不到/attention(老版本宿主 / 该目标失败)时退回摘要口径,并在计数上标注「需关注(粗判)」。 - 排序:OOM > 僵死 > 不健康 > 反复重启 > 非零退出;同权重按「最近一次结束时间」倒序(行 hover 显示 结束/启动时间、重启次数、退出码)。
总览的轮询复用工具条的「自动刷新」开关(默认关):这一页每轮是全 N 个目标各一次 docker ps,比任何
单目标页都贵。没有异常显示「一切正常」;还有目标没答完时显示「读取中…」,计数卡也进入读取态(不出
0/0/0——「0 个容器」会被读成「这台机器没容器」,而它只是还没答)。进总览时会清掉单目标遗留的失败横幅:
总览按目标归因,不再叠一条单目标的「操作失败」——同一个 SSH 超时被讲两遍,看着像两台机器都挂了。
容器诊断(「为什么不行」)
容器详情多一个诊断页签,agent 侧是 docker_diagnose。它一次采集四路——docker inspect(权威状态 /
退出码 / OOM / 重启次数 / 健康检查最后输出 / 资源上限)、docker logs --tail N、docker events --since
(服务端按 --filter container=<id> 过滤)、docker stats --no-stream——然后交给一组纯判据
(src/diagnose.ts,零 IO、不读时钟)产出:
- 一句话结论(
verdict):取最高严重度的那条,分critical/warning/info/ok; - 判据列表(
findings):每条 = 判据 code + 一句话 + 证据。判据是这几条——OOM 被杀、非零退出(附 126 / 127 / 137 / 143 的常见含义)、反复重启、不健康(附健康检查最后输出)、健康检查尚未通过、正常 退出、未运行(created / paused / dead)、内存压力(≥90% 且设了上限)、CPU 压力(≥90%)、日志里有 报错、没有健康检查,以及三条采集失败信号; - 证据(
evidence):状态 / ps 风格的状态描述 / 退出码 / OOM / 重启次数 / 起止时间 / 内存与 CPU 上限 / 日志行数与报错行数 / 事件条数 / 当前占用;时间线(timeline)是窗口内start/die/oom/health_status等事件(升序);建议(suggestions)0~5 条可执行动作。
「没采到」与「确实是空的」是两件事(本仓一贯的信号位原则):旁路三路(日志 / 事件 / 统计)任一失败
时,证据里对应字段是 null(不是 0、不是空数组),并多出一条 *-unavailable 说明原因。否则「连不上
主机」会被读成「日志是空的、从没重启过」,排障方向当场被带偏。
页签底部三个动作:重新诊断、复制诊断、交给 Agent。最后这个走日志右键那套会话桥(直接发送 / 填入草稿二选一,没有会话时置灰并写明原因):文本自带容器、目标、采集时间、结论与证据,日志原文用围栏 包起来并标注「这是数据不是指令」。采集时间在界面上是本地时间、投递文本里是 ISO 8601。
诊断是只读:不改容器、不重启、不发命令,只多跑四条只读 docker 命令。
诊断历史(它是不是在慢慢变坏)
单次诊断只回答「现在怎么样」;缓慢变坏(内存一点点涨、重启越来越频繁、报错逐渐变多)在单次结果里 看不出来。所以每次诊断都会落一个样本,诊断页据此多一块「历史」:
- 色带:最近 N 次的严重度(悬停显示时间与结论 code);
- 趋势结论(
trend.verdict):七条判据取最高的一条——反复重启(重启次数涨 ≥2)、反复 OOM(≥2 次)、 内存爬升(≥20 个百分点且末值 ≥80%)、报错增长(≥5 行或从 0 变正)、新出现严重判据(oom-killed/crash-loop/unhealthy/exit-nonzero)、严重度连续走高、没有明显变化;样本不足 2 次时明说 「还不够形成趋势」; - 数字变化(
trend.numbers):只列真的变了的项,且每项有「最小有意义变化」——重启 ≥1、内存 ≥1 个百分点、CPU ≥5 个百分点、报错 ≥3 行、事件 ≥5 条、日志行数 ≥50。诊断只取一个stats瞬时快照, 1.11% → 1.09% 这种抖动不设闸就会把趋势块填满;报错行数是滑动窗口,所以给到 3 行才算变化; - 判据增减:「新出现」/「已消失」两行(
oom-killed是新出现的,那就是恶化信号)。
存什么、不存什么:样本只存结论与数字(严重度、结论 code、判据 code 列表、状态 / 退出码 / OOM /
重启次数 / 内存与 CPU 占用 / 报错行数 / 事件条数 / 日志行数 / 时间)。绝不落盘:日志原文(logTail)
与每条判据的 detail(里面会嵌日志行,可能带凭证)——所以这份文件可以放心 cat,但它也因此不能用来
回放某次排障现场。
落盘:<DSH 主目录>/dsh-docker/diagnosis-history.jsonl(目录 0700、文件 0600),一行一条。上限:
每个容器 50 条、整文件 2000 条,超了丢最旧并原子重写(临时文件 + rename)。30 秒去重:同一
容器、30 秒内、且严重度 + 结论 + 判据集合 + 重启次数完全相同时不追加。DSH_DOCKER_HISTORY_DIR 可
覆写目录(测试 / 排障专用),空串视为未设。三条边界:① 容器改名 = 开一段新历史(索引键是「目标 +
容器名」);② 并发追加没加锁(可能丢一次更新);③ 写盘失败只降级不报错:诊断照常返回、
recorded = false、宿主 stdout 留一行。
磁盘与可回收(存储治理)
磁盘是工具条分段的最后一段(容器 / 镜像 / Compose / 网络 / 卷 / 磁盘),agent 侧是
docker_disk_usage。数据来自两条命令:docker system df(四类汇总)与 docker system df -v(逐项明细)。
- 四张汇总卡:镜像 / 容器 / 本地卷 / 构建缓存各自的条目数、活跃数、占用、可回收(分类名保留 docker 原文,界面负责翻译);
- 合计可回收:四类
reclaimable的字节和(任一行解析不出就给—,宁可不说也不给错数); - 镜像明细(按独占大小降序):仓库:标签 / 大小 / 独占 / 容器数——
containers === 0打「未使用」 徽标,行尾删除图标(受allowMutations门、走既有确认框); - 卷明细(按大小降序):名称 / 驱动 / 大小 / 链接数——
links === 0同样打「未使用」。
三条刻意的取舍:先排序再截断(真机实测 259 个卷里 258 个是 0B,先截断会把 100 个名额全给空卷);
明细上限 100 行并在表头留痕(工具侧另按 20 行截,两个 truncated 取或);老 docker 只降级不报错
(不支持 -v --format '{{json .}}' 或输出形状不对时 degraded: true + 人话原因,汇总卡照常显示、明细表
不渲染——渲染空表会让人以为「没有」)。
磁盘页不轮询(system df -v 比 docker images 贵),也没有 prune 按钮:逐行删除 + 确认框比
「一键清空一类」安全,而那类入口已经在镜像 / 卷页的工具条上各有一个。
等价 docker run(现场还原)
概览页底部有一块可折叠的等价 docker run,agent 侧是 docker_run_command。它从 docker inspect 的
原始行反推一条可复现命令——不扩 ContainerDetail,因为那会把 env 顺带灌进 docker_inspect 的
模型上下文。覆盖:名称、重启策略、端口映射(没绑 hostPort 的用 --expose)、bind / 命名卷 / tmpfs、
非默认网络、环境变量、用户 / 工作目录 / 主机名(等于默认值时省略)、内存与 CPU 上限、cap-add / cap-drop /
device,以及镜像与命令本身。点开才请求,一行一个参数、POSIX 单引号转义,可直接复制。
三条刻意的取舍:环境变量原值保留(改写过就不是等价命令),命中
/(PASS|PWD|SECRET|TOKEN|KEY|CRED|AUTH|PRIVATE)/i 的键名收进 secretKeys——界面在命令上方给一条
「含疑似凭证」的警告,只点键名、不显示值,命令本身不脱敏(脱敏后的命令跑不起来);故意没还原的
必须点名(omitted:--privileged、--link、非默认日志驱动、--pid/--ipc/--uts、--sysctl、
--add-host、--security-opt、特殊挂载类型、匿名卷、ENTRYPOINT 覆盖、自定义健康检查——逐条给出「为什么
没写」与「怎么补」,绝不静默丢);compose 管理的容器只是近似(网络别名 / labels / depends_on
不在命令里,界面直说)。
配置(保存即热生效)
未获宿主授权时,设置卡片里那两个开关点它会就地发起一次授权:面板给出一条「在宿主终端执行」的命令,
执行完十秒内自动解锁并替你打开开关(免重启);面板里另有「另一种方式(最强)」说明启动环境变量的做法。
已授权时它们就是普通开关,关掉永远可用(宿主侧不拦降权)。「配置开着但没授权」会显示「未生效:未获
宿主授权」徽标——这个状态最容易被当成插件坏了,所以必须有个名字。一个能力一行:开关后面跟这个能力
自己的授权状态与授权时刻(已授权 · 2026-09-26 22:31:08),撤销按钮右对齐。
两个能力的确认也能一次粘贴搞定:再点另一个开关不会顶掉前一块面板,两块面板纵向堆叠,顶部多出 一个「复制全部(N 条)」。
为什么显示时刻:带外授权是持久的——宿主重启后它直接生效、不再有任何一次确认,所以宿主启动时会逐条
打一行 [dsh-docker] elevation: load capability=… via=file grantedAt=…(不含 nonce 与路径),卡片上
也显示出来。启动环境变量授权没有时刻,那一行写的是「由启动环境变量授权;要撤销需在启动环境里去掉它
并重启宿主」。

设置面在宿主里的位置随 DSH 版本变,但始终是同一份表单(同一个 settings 命名空间、同一条写入 通道,配置数据与行为完全相同)——各代际的落点与四条注册契约的唯一归宿在 conventions.md § 客户端设置面。
配置落在 settings 命名空间 docker,即 ~/.dsh/settings.yaml 的 docker: 段
($DSH_HOME/settings.yaml)。插件行里的 composition 配置作为 schema base 打底,settings 层覆盖它;
HTTP POST /api/dsh-docker/config 是卡片的写入通道,只接受下表这些键(未知键返回 400)。
| 项 | 默认 | 说明 |
|---|---|---|
enabled |
true | 关闭整个插件(保存即热生效:工具立即注销、公告撤下、除 /config 外的数据路由 403;/config 始终可读写——设置卡片就是重新启用的入口。与 tty 不同:tty 是重启生效) |
announceToAgent |
true | 是否向 agent 注入能力公告(systemPrompt section plugin:dsh-docker) |
dockerBin |
docker |
docker CLI 可执行名或路径(podman 可填 podman);只允许字母、数字与 _ . / \ : - 及内部空格,且不能以 - 开头(Windows 盘符与 \ 必须放行**)。**「连接本机」的探测用的就是它**;填错时那句话会点名 PATH 里找到的别的容器 CLI,但不会**替你改这个值 |
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,对应工具不注册) |
| —(能力授权) | 未授权 | 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 |
allowExec |
false | 允许一次性 docker exec(容器详情抽屉「概览」页签里的一次性命令框,与 docker_exec 工具;关闭时 /exec 返回 403)。不管卡片「终端」按钮的 docker exec -it 交互式 shell——那条由 tty 承载(见「已知限制」) |
execTimeoutSec |
30 | exec 默认超时秒数(1~120) |
pollIntervalSec |
5 | 面板统计刷新间隔秒数(1~60) |
logTailDefault |
200 | 日志默认尾部行数(1~5000) |
maxOutputKb |
512 | 单次命令输出上限(KB,1~8192);超出截断并标记 truncated |
targets |
[] |
目标列表,见下 |
hostKeys |
[] |
SSH 主机指纹记录(TOFU,自动维护) |
数值越界会被夹到边界内,类型不符则回落到默认值。
targets[] 字段
| 字段 | 默认 | 说明 |
|---|---|---|
name |
—(必填) | 目标展示名,唯一;空名或重名条目在保存时被丢弃(≤64 字符) |
kind |
local |
local 本机 / ssh 远程 |
book |
'' |
kind=ssh 时引用 tty 连接簿条目名(留空则用下面的内联字段) |
host |
'' |
内联主机名或 IP(无 book 时必填) |
port |
22 | SSH 端口(1~65535,越界回落 22) |
username |
'' |
内联 SSH 用户名(无 book 时必填) |
auth |
agent |
agent(走 SSH_AUTH_SOCK)/ key(用 keyPath)/ password(用 password,同时挂 keyboard-interactive) |
keyPath |
'' |
auth=key 的私钥路径(~ 开头会展开为 home) |
password |
'' |
auth=password 的密码;建议填 env:NAME 凭据引用 |
passphrase |
'' |
私钥口令;建议填 env:NAME 凭据引用 |
agentForward |
false | 是否转发本机 ssh-agent(SSH_AUTH_SOCK 存在时生效) |
password / passphrase 里的 env:NAME 是凭据引用(配置只持引用、值归 provider),连接时才
解析:① 官方凭据层(ctx.credentials)——叠 file($DSH_HOME/.credentials.yaml)/ env /
project-env / user-env 各层,且「每次操作重新解析」,所以改完下一个操作即生效、不必重启宿主;
② 服务不可用(老宿主 / 未装该 bundle)或它没有这个引用时,退回 process.env[NAME]。两边都没有会明确
报错并点名两个来源(服务报错也一并带上——否则"凭据服务坏了"会伪装成"你没配")。这两个值永不回传
浏览器:配置快照里只给 passwordSet / passphraseSet 两个布尔位。
hostKeys[](SSH 主机指纹,TOFU)
| 字段 | 说明 |
|---|---|
host |
主机名或 IP(必填) |
port |
端口,默认 22(按 host:port 唯一) |
fingerprints |
指纹集合(必填,至少一条):hostHash: 'sha256' 回调收到的原样十六进制指纹。同一 host:port 可含多把主机密钥(rsa / ed25519 …),命中任意一条即视为匹配 |
fingerprint |
旧版单指纹字段(字符串):只作迁移输入,清洗时并入 fingerprints,新配置不要再用 |
首次连接自动记录并落盘;之后每次连接必须匹配,指纹变更直接拒绝连接,错误信息带「删除该主机记录再
重连」的指引。记录列表在设置卡片里可删除重置(删除会以显式的 hostKeysRemove: [{host, port}] 提交,
不会被并集合并悄悄撤销)。
agent 工具
| 工具 | 注册条件 | 参数 | 作用 / 典型用法 |
|---|---|---|---|
docker_targets |
恒注册 | probe?: boolean |
列出目标(name / kind / label),SSH 目标还会带出它引用的 tty 连接簿条目名 book(内联 host/username 的目标没有这个字段;用它去终端面板 / sftp_* 的 book,名字对不上时只能靠它);probe:true 逐个探测 docker 版本与 daemon 可达性(SSH 目标会建连接,较慢)。其他工具的 target 取自这里 |
docker_connect_local |
恒注册 | —(无参数) | 连接本机:确保存在一条 kind=local 目标并选中它(已存在则复用,不重复创建;SSH / 自定义目标原样保留),随后只读探测 daemon:返回 {name, kind, created, saved, reachable, serverVersion?}。没有任何目标时其余工具不注册(见下方「零目标」那条),用它一步补上。不需要任何能力授权;不会安装 Docker / 启动 daemon / 切 context / 改环境变量。daemon 不可达时说清成因(CLI 缺失 / 探测超时 / daemon 未启动 / socket 无权),不是只报空列表 |
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)。排障第一步 |
docker_attention |
有目标即注册 | target?(支持 *)、limit?: number |
需关注汇总:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 reasons、exitCode、oomKilled、restartCount。OOM 与真实退出码来自一次 docker inspect。排障入口:不确定从哪台/哪个容器看起时先调它 |
docker_diagnose |
有目标即注册 | target?、id(必填)、logTail?(1500,默认 80)、since?(docker 语法,默认 1h)、history?(050,默认 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 就是「它是不是在慢慢变坏」那一句 |
docker_inspect |
有目标即注册 | target?、id(必填) |
docker inspect 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
docker_logs |
有目标即注册 | target?、id、tail?(1~5000,默认 logTailDefault)、timestamps?、since?、until? |
docker logs --tail 尾部;since / until 用 docker 语法圈时间窗;超上限标记 truncated;截断时结果里会说明可传更小的 tail、加 since/until,或改用 docker_logs_grep |
docker_logs_grep |
有目标即注册 | target?、id、pattern(必填,≤512 字符)、regexp?、ignoreCase?、since?、until?、context?(020,默认 2)、limit?(1500,默认 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 正则 |
docker_stats |
有目标即注册 | target?、ids?(逗号分隔的容器名/ID) |
docker stats --no-stream 快照:CPU% / 内存用量与占比 / 网络 IO / 块 IO / PIDs;ids 省略 = 全部运行中容器。实时跟随是面板能力(SSE),工具保持单值快照语义 |
docker_images |
有目标即注册 | target?、limit?(1~500,默认 100)、offset?(默认 0) |
镜像列表(仓库:标签 / 大小 / 创建时间 / 短 ID) |
docker_top |
有目标即注册 | target?、id(必填) |
容器内进程快照(docker top <id> -eo pid,ppid,user,stat,etime,pcpu,pmem,cmd):PID / PPID / 用户 / STAT / 已运行 / CPU / 内存 / 命令,字段全是 docker 打印的原文。容器「还在跑」与「进程还在」是两件事 |
docker_events |
有目标即注册 | target?、since?(docker --since 语法,默认 10m) |
容器事件快照(过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,exec_* 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
docker_networks |
有目标即注册 | target?、limit?(1~500,默认 100)、offset?(默认 0) |
网络列表(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表不进列表行——列表逐行 inspect 就是 N 次 docker 调用 |
docker_volumes |
有目标即注册 | target?、limit?(1~500,默认 100)、offset?(默认 0) |
卷列表(名称 / 驱动 / 范围 / 挂载点) |
docker_disk_usage |
有目标即注册 | target? |
磁盘占用与**「谁在用」**:docker system df 四类汇总 + -v 明细(镜像的 containers、卷的 links,各截 20 行)。删之前先看 containers 是不是 0;汇总与明细是两条命令,明细失败只降级(degraded) |
docker_run_command |
有目标即注册 | target?、id(必填) |
只推导、不执行:从 docker inspect 原始数据反推等价 docker run 命令文本 + warnings / secretKeys(只给键名,值不脱敏)/ omitted(故意没还原的,逐条给原因)。compose 容器只是近似 |
docker_image_inspect |
有目标即注册 | target?、ref(必填) |
docker image inspect + docker history:大小 / 含父层大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 构建历史 |
docker_action |
allowMutations + 有目标 |
target?、action(start | stop | restart | remove)、id |
容器生命周期操作。remove 是破坏性的:删除容器配置与可写层(数据卷不在其中),执行前必须向用户确认目标容器 |
docker_image_remove |
allowMutations + 有目标 |
target?、ref(必填) |
删除镜像(docker image rm,不带 -f)。破坏性:被容器或子镜像引用时会失败;执行前须确认目标镜像并复述后果 |
docker_image_prune |
allowMutations + 有目标 |
target? |
清理 dangling(无标签)镜像(docker image prune -f)。刻意不加 --all,只删无标签镜像 |
docker_image_pull |
allowMutations + 有目标 |
target?、ref(必填)、timeoutSec?(10~1800,默认 600) |
docker pull 快照形态(可能耗时数分钟);交互式看逐层进度请让用户到面板镜像页的拉取图标看 SSE 进度流 |
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 |
target省略时回落到唯一已配置目标;配置了多个目标则必填,错误信息会列出可用目标名。- 零目标时只有
docker_targets/docker_connect_local注册:其余工具没有可寻址对象,注册了只会让 模型白试一次;配好目标后立即补注册,无需重启。 - 四条列表工具支持分页:
docker_ps/docker_images/docker_networks/docker_volumes都接受limit(1~500,默认 100)与offset(默认 0),结果带total/truncated,截断时给出下一页的offset。分页只解决「结果长到读不下去」,不改变单条 docker 命令 512KB 的上限。 - 两个开关变化会立即重注册工具:关掉
allowMutations/allowExec后,对应工具(含三个镜像变更 工具)从 agent 侧消失,无需重启。 - 已注册的调用还受会话权限档位闸(tier gate)约束:
read类(上表标「有目标即注册」的读工具)全档 放行;docker_action/docker_image_remove/docker_image_prune/docker_image_pull归write类,docker_exec归exec类,这两类在受限档(仅可查看 / 工作区内修改)下每次调用弹授权询问(人 允许这一次才执行;无人值守即审批策略never时确定性拒绝),完全权限档零询问,Auto review 档不叠加。 宿主未组合sandboxPolicy/approval服务时不闸,启动日志打一行tier-gate: services absent (…), per-call gate disabled。它与上面两个静态开关是双层:静态开关管 注册(宿主级、持久授权),档位闸管已注册调用的放行(逐调用、随档位即时变)。机制见 docs/permission-tier-plan.md。 - 推荐排障顺序:
docker_targets→docker_ps→docker_diagnose(一次拿到结论 + 判据 + 证据,多数 「它为什么挂了」到这里就有方向了)→docker_logs(尾部看不到成因就docker_logs_grep带pattern/since/until检索深处)→docker_inspect(原始字段)→docker_stats/docker_top(活着但不对劲: 先看资源,再看进程还在不在);镜像排查docker_images→docker_image_inspect;要清磁盘先docker_disk_usage(看containers/links谁在用),再决定删什么。 announceToAgent开启时,插件向 systemPrompt 注入一段精简公告:插件与面板位置、「默认只读」与 「docker socket ≈ 目标主机 root」的约束、「allowExec只管一次性 exec 通道」的边界(卡片「终端」按钮 不受它门控)、target的*适用范围,以及跨工具排障顺序;它不逐条复述工具用法。
HTTP 路由(/api/dsh-docker 前缀,全部 loopback 围栏)
围栏校验 remoteAddress(整个 127/8 + ::1 + ::ffff: 映射)、Host(字面量环回、localhost,或
解析到本机的主机名 / /etc/hosts 别名,解析带 500ms 超时与 60s 缓存)、Origin 与 sec-fetch-site;
非本机请求一律 403 forbidden: loopback-only。请求体上限 1MB,响应统一 application/json +
referrer-policy: no-referrer。
除 loopback 之外还有一道「同源证明」:四条 SSE(/logs/stream、/stats/stream、/events/stream、
/images/pull/stream)与全部写子路由(/action、/images/remove|prune、/networks/remove|prune、
/volumes/remove|prune、/exec、/elevate(含 /elevate/status、/elevate/revoke)、/connect-local)要求请求带 Origin: <同源> 或
Sec-Fetch-Site: same-origin,否则 403 缺少同源证明。这是为了挡住「恶意页面用
<img src=.../images/pull/stream> 触发一次真实拉取」这类跨站副作用;curl / 老 Safari / 部分 WebView 不带
这两个头时会撞上它(浏览器正常使用不受影响)。只读路由不要求同源证明。
例外:桌面版靠宿主 Cookie 过闸(D139)。桌面版把页面发往 dsh-app://app/api/… 的请求转给真实宿主时
会删掉 Origin 与 Sec-Fetch-Site、只重写 Cookie(forwardWebRequest),所以「两条证明都缺省」
在桌面版是常态。这类转发的判定条件是带着宿主会话 Cookie(缺它整条转发直接 503):没有
Origin、没有 Sec-Fetch-Site、但有 Cookie → 放行;两条都没有且无 Cookie(裸 curl / 老 Safari)
→ 仍 403。浏览器页面伪造不了 Cookie 头,而跨站请求一定带 sec-fetch-site: cross-site(loopback 围栏
先拒),所以拒绝分支没有被放松;症状与排查见 docs/troubleshooting.md。
| 路由 | 方法 | 请求体 | 返回 |
|---|---|---|---|
/config |
GET | — | {ok:true, config}:配置快照(targets 只给 passwordSet / passphraseSet,另附只读的 ttyBooks / ttyAvailable / toolsRegistered / streamProtocol——最后这个是 SSE 帧协议版本,客户端据此决定「怎样才算已就绪」) |
/config |
POST | 上表配置键的任意子集 | {ok:true, config};未知键 400,非法 JSON 400 |
/targets |
GET / POST | — | {ok:true, targets:[{name, kind, label?|error?}]} |
/connect-local |
POST | {}(请求体被忽略) |
一键连接本机:复用或新建 kind=local 目标并返回 {ok:true, result:{name, kind, created, saved, reachable, serverVersion?, message}, config};宿主没有 settings 服务时 503,保存失败时 500(都带原因且不改内存配置)。要求同源证明(它是写路径),但不需要 allowMutations / allowExec |
/probe |
POST | {target?} |
{ok:true, probe:{ok, bin, serverVersion, error, target}} |
/containers |
POST | {target?, all?} |
{ok:true, containers: ContainerSummary[]};target:'*' 时返回 {ok:true, groups:[{target,label,ok,error?,data?}]}(跨目标并发聚合) |
/attention |
POST | {target?, limit?} |
单目标 {ok:true, items, total, truncated, degraded}(limit 在过滤 + 严重度排序之后生效,默认 100、上限 500);target:'*' 时按目标分组 |
/inspect |
POST | {target?, id} |
{ok:true, details: ContainerDetail[]} |
/stats |
POST | {target?, ids?: string[]} |
{ok:true, stats: ContainerStats[]} |
/logs |
POST | {target?, id, tail?, timestamps?, since?} |
{ok:true, logs:{id, text, truncated}} |
/top |
POST | {target?, id}(必填) |
{ok:true, top:{id, name, rows:[{pid, ppid, user, stat, elapsed, cpu, mem, command}], truncated}}——字段全是 docker top 打印的原文;未运行 / 不存在时走既有错误通道(不是 200 空列表) |
/diagnose |
POST | {target?, id}(必填)、logTail?(1500,默认 80)、since?(默认 1h)、history?(050,默认 10) |
{ok:true, diagnosis:{verdict, findings, evidence, timeline, logTail, suggestions, history:{samples, trend, recorded}}}。history 是本次记录之后的最近 N 条样本与趋势(recorded 说明本次有没有真的追加)。四路采集,旁路失败只降级(evidence 对应字段 null + *-unavailable 判据);非法 since 在引用白名单段就 400 |
/logs/stream |
GET | query:target?、id(单容器,与 ids 二选一)或 ids(逗号分隔的多个容器,一条连接装下全部,最多 32 个)、tail?(0~5000;0 = 不补历史只跟随,客户端重连用)、timestamps?(1/true)、since? |
200 text/event-stream 长连接,事件协议见下;参数错误 / 未知目标 / 非 loopback 返回常规 JSON 错误 |
/stats/stream |
GET | query:target?、ids?(逗号分隔;省略 = 全部运行中) |
200 text/event-stream:每秒一帧 stats(形状与 /stats 快照一致);不会自然结束,靠客户端断连收尾 |
/events/stream |
GET | query:target? |
200 text/event-stream:一帧 event 一个容器事件(服务端已过白名单);不会自然结束 |
/images |
POST | {target?} |
{ok:true, images: ImageSummary[]} |
/images/inspect |
POST | {target?, ref}(必填) |
{ok:true, image:{ref, detail: ImageDetail, history: ImageHistoryEntry[], historyError}};history 优先 --format '{{json .}}',老版本退回纯文本表格 |
/images/remove |
POST | {target?, ref}(必填) |
需 allowMutations(否则 403);docker image rm(不带 -f);{ok:true, result:{ref, message}} |
/images/prune |
POST | {target?} |
需 allowMutations(否则 403);docker image prune -f(只删 dangling);{ok:true, result:{message}} |
/images/pull/stream |
GET | query:target?、ref(必填) |
需 allowMutations(否则 403 且不建流);200 text/event-stream:line 逐层进度 + end{reason:'pull-exit',code,ref} |
/networks |
POST | {target?} |
{ok:true, networks: NetworkSummary[]} |
/networks/inspect |
POST | {target?, name}(必填) |
{ok:true, network:{name, detail: NetworkDetail}}(含子网 / 网关 / 选项 / 标签 / 接入的容器) |
/networks/remove |
POST | {target?, name}(必填) |
需 allowMutations(否则 403);docker network rm;{ok:true, result:{name, message}} |
/networks/prune |
POST | {target?} |
需 allowMutations(否则 403);docker network prune -f;{ok:true, result:{message}} |
/volumes |
POST | {target?} |
{ok:true, volumes: VolumeSummary[]} |
/volumes/inspect |
POST | {target?, name}(必填) |
{ok:true, volume:{name, detail: VolumeDetail}}(挂载点 / 选项 / 标签) |
/volumes/remove |
POST | {target?, name}(必填) |
需 allowMutations(否则 403);docker volume rm(数据随卷一起没);{ok:true, result:{name, message}} |
/volumes/prune |
POST | {target?} |
需 allowMutations(否则 403);docker volume prune -f(删数据,见已知限制);{ok:true, result:{message}} |
/system-df |
POST | {target?} |
{ok:true, disk:{summary, images, volumes, buildCache, truncated, degraded, degradedReason}}:summary 四类汇总,明细各上限 100 行(先排序再截断);老 docker 只降级不报错 |
/run-command |
POST | {target?, id}(必填) |
{ok:true, recreate:{id, name, command, warnings, secretKeys, omitted}}——只推导,不执行 |
/action |
POST | {target?, action, id} |
需 allowMutations(否则 403);{ok:true, result:{id, action, message}} |
/exec |
POST | {target?, id, command, timeoutSec?} |
需 allowExec(否则 403);{ok:true, result:{id, command, code, stdout, stderr, truncated, durationMs}} |
其余子路径 404(unknown route: ...);/config、/targets、上述四条 */stream 之外的 GET 返回 405;
执行失败(docker 报错、目标解析失败等)返回 500 或 400 加 {error} 文本。
SSE 事件协议(/logs/stream、/stats/stream、/events/stream、/images/pull/stream)
四条长流共用同一份基建(openSseStream):统一写头 + flushHeaders()、15s 一帧 : ping 心跳、活跃流
登记(插件禁用 / 配置热更新 / 卸载时统一 end + abort)、客户端断开静默中止,以及管道起来后先发一帧
ready。每帧一行 event: + 一行 JSON data:(单行封装:换行 / 引号被转义,多字节字符不会被 SSE 行
边界截断),随后空行:
| 事件 | data | 说明 |
|---|---|---|
ready |
{} |
执行器真的起来了才发一帧(本机子进程 spawn / 远端通道已开 = StreamHandlers.onStart):客户端据此才认为「在收数据」。响应头到达(onopen)不算——响应头由 flushHeaders() 立刻写出去,而建连(SSH acquire / 远端开通道)可能卡十几秒。起不来就没有 ready |
line |
{"d":"..."} / {"e":"..."};多源时 {"id":"<容器 id>","d"|"e":"..."} |
stdout / stderr 分片(不保证按行切,客户端自行拼行)。日志流与拉取流用。ids= 多源时每帧带 id,客户端按它把分片派回对应容器 |
source-end |
{"id":"<容器 id>","code":N|null} |
仅多源:该容器的 docker logs -f 自然退出(容器停止)。整条连接不关——其余容器照常推;end 要等所有源都结束 |
source-error |
{"id":"<容器 id>","message":"..."} |
仅多源:该容器这一条子流失败。整条连接也不关,其余容器继续;客户端标注「部分出错」 |
stats |
ContainerStats |
统计流专用:每秒每个容器一帧,字段与 /stats 快照完全一致。服务端按扁平 {...} 抽取(docker stats 即便 stdout 是管道也走 TTY 渲染器,帧里混着 ESC[H/ESC[K/ESC[J,按行解析会整行丢掉)并折叠同一采样的重复渲染 |
event |
{action, name, image, composeProject?, time?, exitCode?} |
事件流专用:一帧一个容器事件(白名单外的事件行在服务端就丢了;值为 null 的字段省略) |
end |
{"reason":"container-exit"|"stats-exit"|"events-exit"|"pull-exit","code":N, ...} |
执行器自然退出。日志流附容器退出码(多源时取按 ids 顺序最后一个非 null 的退出码,各容器的真实退出码在各自的 source-end 里)、统计流 reason=stats-exit、事件流 reason=events-exit、拉取流附 ref |
error |
{"message":"..."} |
参数 / 执行失败,随后关闭;连接层断路不会发这个事件 |
多容器单连接(ids=,D168):聚合日志把 N 个容器塞进一条 SSE——宿主为每个容器各起一条
docker logs -f,帧里带 id 分流。这样聚合视图无论选几个容器都只占浏览器一条同源连接(旧实现是
每容器一条 EventSource,叠加活动条的事件流会吃满 D165 实测出来的 4 条常驻流)。单源 id= 的形状
逐字节未变:帧不带 id、不发 source-end/source-error。/config 的 streamProtocol 因此升到
3:客户端据此决定走单连接还是退回「每容器一条」。四条流的差异只有「执行器 + 结束原因」:
| 流 | 执行器 | 自然结束条件 | 关闭语义 |
|---|---|---|---|
/logs/stream |
docker logs --follow(ids= 时每个容器各一条) |
容器停止(container-exit;多源时全部容器的流都结束) |
前端关 FOLLOW / 切页 / 关面板 |
/stats/stream |
docker stats(无 --no-stream) |
全部被统计容器退出(stats-exit) |
前端主动断(这条流不会自己停) |
/events/stream |
docker events(--filter type=container) |
daemon 侧流结束(events-exit) |
切页 / 切目标 / 关面板 |
/images/pull/stream |
docker pull |
拉取完成 / 失败(pull-exit) |
前端离开拉取视图 |
- 响应头:
content-type: text/event-stream; charset=utf-8、cache-control: no-cache、connection: keep-alive,写头后立即flushHeaders()(宿主 gzip 对text/event-stream显式跳过)。 - 心跳:每 15s 一帧
: ping注释(SSE 规范里客户端忽略)。 - 背压与溢出(D153):客户端消费不过来时帧进内存队列(每条流 ≤ 8MB);日志 / 拉取流溢出时丢最旧的
整帧、只留最新,并推一条
skip{frames,bytes}(skip帧自身永不丢)——流不断;skip 排队期间再发生 丢弃时原地更新这条 skip 的账目(D156)。统计 / 事件流溢出时补end{reason:output-limit}收尾,由 客户端重连。另外write()返回 false 只代表「越过高水位」,帧不会被重写。 - 分片合帧(D151):日志流与拉取流把上游分片按 50ms 窗口合并后再推(同一通道严格保序、stdout 先于
stderr、攒满 256KB 立刻推),所以
line帧的d/e里可能有多行——客户端本来就按「任意分片」 切行。end帧一定排在窗口里最后一批line之后。 - 清理:客户端断开 → 立即中止执行器(本机
SIGTERM,2s 未退再SIGKILL;SSH 关闭该 exec channel、 连接池连接保留复用),不写任何帧;插件禁用 / 配置热更新 / 卸载 → 服务端主动收尾(abort +end)。 - SSH 的长流会占用连接池里的连接(busy 计数),空闲回收(120s)不会误杀;流结束后恢复回收。
- 「已就绪」的判据(客户端侧):绿点 /「实时跟随中」只认宿主那帧
ready——响应头到了不算。此外每条 流还有一条「更硬的证据」兜底:事件流的第一条真实事件、日志流的第一行、统计流的第一个采样、拉取流的 第一帧进度。开发档错位(宿主进程还跑着旧代码、浏览器却读到新的client.js)另有版本协商:/config的streamProtocol < 2说明该宿主从不发ready,客户端退回旧判据并只重开一次那条流。
安全模型
docker socket ≈ 目标主机的 root 权限。 能访问 daemon 就能挂载宿主目录、以特权模式起容器、读容器里的 密钥——因此本插件按「只读优先」设计:
- 默认只读。
allowMutations未开启时,/action、/images/remove、/images/prune、/images/pull/stream一律返回 403,面板的启停删 / 镜像删除 / 清理 / 拉取不可用,docker_action、docker_image_remove、docker_image_prune、docker_image_pull工具根本不注册;allowExec未开启 时,/exec返回 403,docker_exec工具同样不注册。两个开关互相独立,必须在设置卡片由用户显式打开。 读取类路由(/logs/stream、/stats/stream、/events/stream、/images/inspect)不受这两个开关影响。allowExec只管本插件的「一次性」exec 通道(/exec路由、docker_exec工具、容器详情抽屉 「概览」页签的一次性命令框)——卡片上的**「终端」按钮跑的是docker exec -it交互式 shell,由 tty 承载、不经过这两个开关**,只读模式下也可用(它不是漏了门,是刻意留的进入通道,见「已知限制」)。 - 破坏性操作要复述后果。
remove映射为docker rm(不带-f),agent 公告要求执行前向用户 确认目标容器;运行中容器会报错并附「容器仍在运行:先停止再删除」的提示,不会静默强删。 - 凭证不落明文(建议)。
password/passphrase支持env:NAME凭据引用,值存在官方凭据存储 ($DSH_HOME/.credentials.yaml)里,避免明文写进settings.yaml;agent认证(SSH_AUTH_SOCK) 完全不落盘。配置快照只回「是否已设置」。 - 主机指纹 TOFU 钉扎。首次连接记录 sha256 指纹,之后必须一致,变更即拒绝连接(防中间人);tty 已 确认过的主机会被当作种子直接信任并复制进本插件的记录。TOFU 的固有边界是「首次若已遭遇 MITM,记下的 就是伪指纹」,以及按 host:port 只存一条(同主机多密钥类型可能误报变更,删除记录重连即可重新校准)。
- 命令一律 argv 构造,绝不做字符串拼接。容器名 / ID 先过白名单
assertRef([A-Za-z0-9][A-Za-z0-9_.-]*,≤128 字符,拒绝空格、;、$()、反引号等),镜像与容器引用同理; 远程经shJoin逐参数单引号转义后交给远端 shell,本机spawn(bin, args)不经 shell。 - 输出有上限。
maxOutputKb限制单次命令的 stdout/stderr 字节数,超出截断并标记,避免大日志撑爆 内存或 agent 上下文。 - HTTP 只对本机开放。全部路由走 loopback 围栏,远程浏览器无法调用。
- 日志实时流是只读能力。
GET /logs/stream与/logs一致:不受allowMutations/allowExec门控 (它不改容器状态),但同样只放行 loopback、id过assertRef白名单、参数经同样的夹紧。与快照不同, 长流没有maxOutputKb上限(跟随被截断就失去意义),内存防护由客户端的 5000 行环形缓冲承担。 - 变更操作有逐条使用审计。kit elevation 的审计行回答「授权了」,这里回答「用了」:每一次过了
能力闸的变更 / exec 都落一行
[dsh-docker] capability-use: …结构化审计行(出口是宿主 stdout), 面板路由与 agent 工具两条入口都覆盖。行格式、不记什么(403 / tier-gate ask)与「exec 命令会随行 出现」这条代价,见「已知限制」的使用审计条。
已知限制
- 没有交互式 TTY:
exec是一次性命令(docker exec <id> sh -c <cmd>,不带-i/-t),不能跑 vim / top / 交互式 shell,也不能喂 stdin 做对话。交互排障请到 tty 面板执行docker exec -it <容器> sh。 allowExec不是「进不了容器」的闸:卡片「终端」按钮(docker exec -it '<容器>' sh)经 tty 的ttyTerminal承载,不检查allowExec,只读模式下 也照常可用——exec 被关时面板给的提示原文就是这么说的(「打开『允许 exec』,或 直接复制卡片上的 exec 命令到终端面板交互式进入容器」)。这是刻意的:只读巡检时 进容器看现场是最常用的一条路径,闸门管的是本插件自己那条一次性通道 (/exec与docker_exec:改本地状态、可被 agent 工具批量调用),交互式 shell 归 tty(用户主动操作、它自己才是 PTY 的所有者)。代价要说清:装上 tty 之后,allowExec对页内脚本(第三方插件的 client 半体 / XSS)本来就不是边界——它们 走ttyTerminal.open或 WSspawn帧就能跑任意命令,比本插件的一次性 exec 还强。 这一层的威胁模型边界见 architecture.md § 7。- 事件流有断线窗口:
docker events是「从现在开始」的推流,断线重连期间发生的事件服务端已经推过、不会 补发。客户端用「重连成功后先做一次全量列表刷新」来补偿(对齐状态,不是把事件补回来);要精确的完整事件 历史请用docker_events工具(带--since的快照)。 - 浏览器同源连接预算是硬约束,docker 面板最多只占 1 条常驻流(D165 实测 + D168):宿主是
HTTP/1.1(没有 h2 多路复用),而每条
EventSource独占一条连接、且不会主动释放。吃满之后,同一 个页面里所有普通请求(列表刷新、/api/*、会话加载……)一起排队——用户看到的是整个 DSH 页面 卡住,不只是 docker 面板。docker 面板的常驻流在任何视图下都只有 1 条:列表 = 活动条事件流;详情 = 日志 / 统计 FOLLOW(同一时刻只有一个页签);聚合 / Compose 日志 = 一条ids=多容器流;拉取 = 一条 进度流(进入详情 / 聚合时事件流主动断开,回列表重连并补一次全量刷新)。超了不是「慢一点」,是同页面所有 请求一起失效;完整实测数据见 DEFECTS.md 的 D165,复核方式是在 devtools 里连开new EventSource('/api/dsh-docker/events/stream?target=<目标>')到第 5 条,再打一次普通请求。 (WebSocket 不占这份预算;流式日志 / 统计在浏览器侧的保留量见「使用」。) - 多标签页与聚合上限:那些连接是整个页面共享的(官方
dsh-client-hmr、其它插件的 SSE 都算);治本要 宿主换 WebSocket / h2,或各插件共用一条多路复用连接。聚合上限 3 现在是「可读性 + SSH 8 条通道额度」的 上限,不再由浏览器预算推出(D168):一屏混三份以上日志会看不清,且每个被选容器在宿主侧仍各跑一条docker logs -f(SSH 目标上就是一条 channel)。PICK_MAX = 3即此值。 - SSH 目标上的通道额度是共享的(
MaxSessions):一个目标只维持一条 TCP 连接,长流与短命令共用 这条连接上的通道,而 OpenSSH 的MaxSessions默认只有 10。长流会一直占到用户关掉面板为止,聚合日志里 每个被选容器仍各占一条 channel——叠上刷新 / inspect 这类短命令就可能逼近额度,远端拒绝时看到的是(SSH) Channel open failure: open failed。插件对每个 SSH 目标限制同时最多 8 条长流(源码常量MAX_STREAMS_PER_TARGET,即 10 − 2,留两 条给短命令),聚合上限也定在 3;折叠右侧栏标签会主动断流、把通道还回去。(D150)两道补救:① 短命令 按目标排队(每条连接同时最多 2 条并发,DSH_DOCKER_SHORT_CHANNELS可覆盖,仅测试 / 排障);② 额度 满会自愈——远端回no more sessions这类拒绝时,插件丢掉并关闭那条连接、重建一条再重试一次,宿主 日志留一行通道额度已满:已重建连接重试(这条连接不会自己恢复)。(D164)③ 中止长流时插件自己 的账当场结清——AbortSignal触发时除了向远端KILL+ 关通道,还会直接结束这条流(原先只等远端close,远端不确认时那条流的busy计数永远不会减,累积到 8 之后该目标的每条长流都被拒绝且没有 自愈路径)。根治办法在 sshd 一侧:把那台主机的MaxSessions调大——例如/etc/ssh/sshd_config.d/10-dsh-maxsessions.conf写一行MaxSessions 50,再sshd -t && systemctl reload ssh(reload 只影响新连接)。当前上限是编译期常量,需要跟着改就提 issue。 - docker CLI 版本差异:解析走
--format '{{json .}}',字段随版本增减,解析器一律降级而不抛异常 (例如State缺失就从Status推导状态);缺字段时对应列可能为空,权威数据请用docker inspect。 rm不带-f:容器remove映射docker rm、镜像remove映射docker image rm,都不加-f——运行中的容器、被引用的镜像会失败并给出提示;要强制删除得去 tty 面板手动执行。- SSH 目标需要免 sudo 的 docker:账号不在 docker 组时权限错误原样透出,不做自动 sudo 提权。
远端未安装 docker 时
probe失败(退出码 127),PATH 不一致时可把dockerBin填成绝对路径。 dockerBin的校验发生在落盘之前:非法值直接 400 并带上原因,不会被写进settings.yaml。 指向.cmd/.bat时本机通道起不来:本机执行走裸spawn,Windows 上 Node 会拒绝直接执行.cmd(EINVAL)——手写包装脚本请改用.exe,或等待本机通道也切到 kit 的spawnPortable。- Compose 项目视图只认
com.docker.compose.project:docker stack deploy起的 swarm 服务带的是com.docker.stack.namespace/com.docker.swarm.service.name,于是这些容器显示为「未分组」。要支持得 另立一套 stack 分组语义。 volume prune的回收范围随版本变,且可能删数据:Docker 29 上docker volume prune -f只回收匿名 未用卷(命名卷留着);而 docker ≥ 23 有-a/--all,docker < 23 没有这个开关、plain prune 会把未被 使用的命名卷一起删掉。本插件就不带--all,确认文案写明了版本差异;要删命名卷请用/volumes/remove。- podman 兼容靠
dockerBin:填podman即可跑,但stats与--format '{{json .}}'的字段和输出 格式与 docker 有差异,只能依赖解析器的降级路径,未逐项验证。 - 没有镜像构建 / Compose 编排变更:镜像支持拉取 / 删除 / 清理 dangling,但没有
docker build、docker save/load、docker push;Compose 是只读项目视图。网络 / 卷的变更只有面板按钮, 没有 agent 工具——刻意不扩大 agent 侧的变更面。 - 诊断是判据,不是专家:这些判据覆盖的是「容器自报的客观证据」(退出码 / OOM / 重启次数 / 健康检查 / 资源压力 / 日志里的报错行),不理解业务。「这个报错是否可忽略」那一步交给「交给 Agent」之后的人或 模型。代价:诊断一次跑四条只读命令,在 SSH 目标上就是最多四条 channel(短命令闸把并发压到 2,所以是 两批),慢机器上会感觉到等。
- 事件采集给的是独立预算(8MB + 留尾):单容器一小时的事件里
exec_create/exec_start/exec_die这类三连占绝大多数,按通用的 512KB 档会让时间线永远为空。所以诊断的事件采集单独放宽到 8MB 并保留尾部——窗口内事件特别多的容器,时间线只覆盖最近的一段。 - 等价
docker run是近似,不是备份:compose 管理的容器不带网络别名 / labels / depends_on;--privileged/--link/ ENTRYPOINT 覆盖 / 自定义健康检查 / 匿名卷等故意不还原,逐条列在omitted里。它不能替代docker commit/ 卷备份。 - 磁盘明细的两处截断各自独立:宿主侧每类 100 行(
truncated)、工具侧 20 行;真机上卷多到几千个时,system df -v的输出本身也可能撞 8MB 预算(那时degraded: true,汇总仍然可用)。「合计可回收」由 字符串解析成字节再相加——解析不出就整条显示—。 - 总览只汇总「容器概况」,且完全只读:只做计数卡与异常置顶表,没有任何跨目标操作,也不聚合日志 /
统计 / 事件流。另外「异常」只含
unhealthy与restarting:ContainerSummary里没有exitCode, 无法区分「崩溃退出」与「人工停掉」——死掉的容器请在计数卡的「已停止」里看。 - 连接栏按钮需要一条匹配的目标才有数据:按钮在 SSH 标签上一律显示,但若会话主机没有对应的
kind=ssh目标(连接簿名或host:port都匹配不上),点开只会看到「尚未配置为 Docker 目标」的提示;本地标签的 连接栏本身隐藏。目标增删后最多 30 秒内刷新(设置卡片保存会立即刷新)。 enabled: false是热生效:保存后工具立即注销、公告撤下、除/config外的数据路由 403(进行中的长流 也会被立即收尾)。路由对象本身不卸载、靠 403 拦截;/config始终可读写,设置卡片就是重新启用的入口, 无需重启dsh web。- 使用审计走宿主 stdout,exec 命令会随行出现:每一次过了能力闸的变更 / exec(面板路由与 agent 的
docker_action/docker_image_remove/docker_image_prune/docker_image_pull/docker_exec; 403 与参数校验 400 不记,「使用 = 过了闸」)落一行[dsh-docker] capability-use: capability=allowMutations|allowExec source=http|tool action=… target=… ref=… ok=true|false durationMs=…,key=value 风格与 kit elevation 的授权行一致;pull 进度流拆 start / end 两行。出口是console.log(D162:真机实测ctx.logger的行在 0.2.x 宿主上既不进 stdout 也不落盘)——跑dsh web的那个终端里就能看到、可 grep。代价:exec 的命令会记进行尾detail(连同退出码),整段 截断 200 字符、控制字符转义成字面量——行是给人看的,就得接受它出现在终端可见的日志流里。docker 报错也 算一次使用(ok=false);tier-gate 的 ask / deny 不记。没有查询界面、没有独立审计文件。 - 跨目标聚合的边界:并发上限 4;单目标预算 90s(兜底),而每条命令自己的超时(30s)才是被遵守的 那个——到点时底层的子进程 / SSH 通道会被真的取消,否则超时那条命令会继续占着远端会话槽(D161)。 单个目标失败/超时只影响它自己那一格。目标很多时总览的请求量线性增长(每目标 2 个请求),自动刷新会放大 这个量。
工作原理
浏览器半体 (client.js)
├─ 侧边栏「容器」入口 → 面板(目标选择 / 容器列表 / 卡片动作 / 五页签详情 / 多目标总览 /
│ Compose · 镜像 · 网络 · 卷 · 磁盘各页)与终端抽屉(ttyTerminal.mount)
├─ 四条 SSE(/logs/stream 含 ids=、/stats/stream、/events/stream、/images/pull/stream)
│ + 普通 fetch → /api/dsh-docker/*(loopback 围栏;写路由另需同源证明)
└─ 可选消费 tty 的 ttyConnbar / ttyPanel / ttyTerminal(未装或版本不足逐项降级)
宿主半体 (src/index.ts)
├─ settings 命名空间 docker → normalizeConfig(夹紧 / 白名单键)
├─ 只读复用 tty settings 的 sshHosts(连接簿)与 hostKeys(指纹种子)
├─ resolveTarget → 每目标一个 DockerApi(src/docker.ts)+ RemoteExec(src/ssh-exec.ts):
│ argv 构造 + assertRef + 输出上限;懒连接池 / 非 PTY exec channel / shJoin / TOFU
├─ openSseStream:四条流共用一份基建(ready / 心跳 / 活跃流登记 / 背压 / 统一收尾)
└─ 路由 + agent 工具(读类恒注册,写 / exec 类按开关注册)——清单见上面两张表
开发与验收
pnpm --filter @hyzyn/dsh-docker build # tsc → lib/(宿主半体)+ esbuild → client.js(浏览器半体)
pnpm --filter @hyzyn/dsh-docker typecheck
pnpm --filter @hyzyn/dsh-docker smoke # 三套离线回归,都不需要 docker daemon(项数以脚本尾部自报为准)
pnpm test # 仓库级 vitest(本包 test/ 下的用例都在里面;项数以运行输出为准)
改了哪一半、怎么才生效:
client-src/*(浏览器半体)→ esbuild 出client.js,宿主有 client HMR 轮询,热更、刷新页面即见;src/*.ts(宿主半体)→ tsc 出lib/,必须是新进程才生效(运行中的dsh web在启动时就把lib/载进内存),改完pnpm build记得重启。忘了重启的症状是客户端是新的、 宿主是旧的:宿主侧新增的提示语没出现,那就是旧进程。
验证面分三层:三套离线 smoke(不需要 docker daemon)、本包的 vitest 套件、以及真 daemon 的 手工验收清单。逐项的覆盖范围、浏览器侧性能门槛与手工步骤见 docs/verification.md——README 只留「怎么跑」,清单不在这里重复。
版本 / 许可证
@hyzyn/dsh-docker(版本以 package.json 为准) · Apache License 2.0
No comments yet. Be the first to write one.