DSH Web GUI 的 Docker 容器面板:本机或 SSH 主机上的容器列表(可选含已停止)、docker inspect 详情、日志、stats 快照、镜像列表与一次性 exec——默认只读(启停删与 exec 均需显式开启权限),可经 tty 插件在面板底部就地打开容器终端。
安装
# npm 包(预构建)
dsh plugin --profile web add @hyzyn/dsh-docker
# GitHub 源码(首次需按提示配置 allowBuilds 构建授权后重试)
dsh plugin --profile web add github:hyzyn/dsh-plugin-kit#path:/packages/docker
装任何插件都等于在你的机器上跑第三方代码,权限和你本人一样大——能读你的文件、用你的凭据、访问网络,工具审批管不到它。GitHub 来源的插件还会在安装时执行构建脚本——pnpm 默认拦截,所以安装可能停在 ERR_PNPM_GIT_DEP_PREPARE_NOT_ALLOWED 或 ERR_PNPM_IGNORED_BUILDS;dsh 会打印出需要添加的确切键名,把它加进该 profile 的 pnpm-workspace.yaml 的 allowBuilds 下,重跑一次即可装上。放行构建本身就是一次信任判断:请只安装可信来源,并尽量锁定 commit(github:owner/repo#sha)。
README
中文 | English
DSH 侧边栏「容器」面板:本机与 SSH 主机的容器一屏巡检——读日志、看资源、进容器、启停删,默认只读。
特性
- 常驻会话右侧栏:面板作为会话右侧栏标签(
sidebar.right.pane.tab)与对话同屏——发完错误交给 Agent 后日志留在右边,不影响看它干活;折叠即退出视野、不占屏。面板级状态(目标 / 视图 / 过滤词 / 选中容器及它的页签)跨会话保留,而折叠会主动断流、把 SSH 通道还回去。拿不到右侧栏服务的老宿主自动退回原有的 dock / 模态,行为与旧版一致。 - 日志右键交给 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 的同一判据)——只认老字段会让菜单判成「当前没有打开的会话」而整组置灰;同一份取值也驱动「切会话把容器标签带过去」。 - 显式一键连接本机:两个非常驻入口——一个目标都没有时是「还没有配置 Docker 目标」空态里的主按钮,已有别的目标时是目标下拉的末一项
+ 连接本机(工具条上不挂:常驻时它既占位又招人点,纯噪音)——一次点击(或选中该项)就把宿主所在机器加成本机目标并选中(已有本机目标则直接复用,不重复创建;SSH / 自定义目标原样保留),agent 侧同一条操作是docker_connect_local。它不改空targets的语义、不凭空造隐式默认目标、不自动装 Docker / 起 daemon / 切 docker context,也不需要任何能力授权(只加一条指向插件自身的本机目标)。 - 多目标聚合取数:总览页对全部
targets[]并行请求,单个目标不可达只污染自己那一格;agent 侧同一口径由docker_ps target:"*"/docker_attention target:"*"暴露,跨目标不互相阻塞。 - 「需关注」读权威字段:不健康 / 反复重启 / OOM 被杀 / 非零退出 / 僵死;OOM 与真实退出码由一次
docker inspect补齐——docker ps摘要里的 137 分不出 OOM 与手动 kill,只按摘要筛必然误报。 - 四条 SSE 长流共用一套基建:日志 FOLLOW、
docker stats、docker events、docker pull走同一个openSseStream(心跳 / 活跃流登记 / 断开清理 / 管道起来后先发一帧ready),差异只在收尾语义——日志与拉取自然结束,统计与事件由前端主动断。「连上了」与「在收数据」是两件事:客户端只认ready,响应头到达不算(见「SSE 事件协议」)。多选 / Compose 项目的聚合日志把多个容器装进一条/logs/stream?ids=…(D168),按--timestamps前缀还原跨容器真实时序,「暂停」只冻结渲染(流继续接收,恢复时一次性补齐)。 - 默认只读,能力开关分三级:启停删 / exec / 镜像变更各自独立开关,未开启时 agent 工具不注册、HTTP 路由 403(能力不存在,而非调用后报错);开关的提权走两条带外通道(启动环境变量 / 卡片里就地确认,见下条);容器名与 ID 过白名单,命令一律 argv 构造 + 单引号转义,密码 / 口令以
env:NAME凭据引用(官方凭据层解析,缺失时退回环境变量)且永不回传浏览器。 - 与 dsh-tty 数据级复用、代码级不耦合:不 import 任何 tty 代码,tty 也无需改一行源码,两者可各自安装与升级;装了 tty 则消费三个可选扩展点——连接栏动作(
ttyConnbar)、终端承载(ttyTerminal:标签 / dock 承载下经open新开标签,模态下经mount就地嵌入抽屉)、以及只在兜底路径用到的终端右侧 dock(ttyPanel.mountPane);未装或版本不足逐项静默降级。
与 dsh-tty 的关系
本插件自成一体:不 import 任何 tty 代码,tty 也无需改一行源码,两者可各自单独安装、 各自升级;装了 tty 就在下面这些可选扩展点上协作,没装则安静降级。
| 维度 | 说明 |
|---|---|
| 插件形态 | 独立包 @hyzyn/dsh-docker,不 import 任何 tty 代码,tty 也无需改一行源码;两者可各自单独安装、各自升级 |
| 连接簿 | 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,用户手敲的 docker exec -it 不算);tty 未装 / 版本过旧则静默跳过 |
| 面板承载 | 默认走会话右侧栏标签(sidebar.right.pane.tab):面板与对话同屏,折叠即退出视野、不占屏;框架侧边栏的入口只负责打开或聚焦它,页类型在同一栏内去重,反复点不会开出第二个。终端连接栏的入口相反——它长在盖满视口的 tty 弹窗上,开标签会被挡住,所以那条走 ttyPanel.mountPane 停靠到终端右侧(入口决定承载);投递日志给会话后经 ttyPanel.minimize()(契约 v2)折起终端,把舞台让给会话。拿不到右侧栏服务(老版本 DSH)、或把 localStorage['dsh-docker:carrier'] 置成 modal 时,侧边栏入口也回退到 dock(tty 开着时)或自带 backdrop 的全屏模态。三种承载是同一个组件,只是外壳与几何不同 |
| 终端承载 | 交互式终端由 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 / 重连栈 |
| 分工 | 交互式排障(docker exec -it、容器内 shell、TUI)由 tty 承载(抽屉内嵌或标签);只读巡检与 agent 自动化用本插件自己的 exec 通道 |
数据级复用、代码级不耦合:连接簿与指纹种子是「读同一份 settings」,连接栏按钮是 「消费一个通用扩展点」,都不是「依赖 tty 的模块」,因此 tty 升级或卸载都不会连带 弄坏本插件。
安装
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。
使用
两个入口,同一个面板。默认承载是会话右侧栏标签——入口只负责打开或聚焦它, 面板与对话同屏:发完错误交给 Agent 后,日志留在右边、不影响看它干活。
- 侧边栏「容器」(总入口):任何目标都能用,包括本机 docker 与多目标切换。 已打开则聚焦(页类型在同一栏内去重),不会开出第二个「Docker 容器」标签。
- 投递后去哪儿看:右侧栏标签承载下会话就在旁边;docked / 模态下发送成功后终端会自动 折起(会话照旧跑着,点侧边栏「终端」入口的徽标恢复),于是你直接落在会话里——不必对着 一个盖住会话的弹窗猜「点了没有反应」。
- SSH 连接栏「容器」按钮(上下文快捷方式,tty ≥ 0.13.0):在 tty 终端面板的
SSH 标签里,连接栏 SFTP 按钮旁会出现「容器」——注册即显示,点击直接用
当前会话那台主机打开面板,不用再选目标。目标解析发生在点击时:会话来自
连接簿时按条目名匹配,否则按
host:port匹配已解析的目标;没配到目标也不会 藏按钮——面板会带一条提示告诉你会话主机(含连接簿名)该去设置卡片怎么配。 这条路径走终端右侧的 dock,不开右侧栏标签——按钮长在 tty 弹窗上,而弹窗盖满 视口,开标签会被整个挡住、看着像「点了没反应」。目标照旧带进面板并重挂到那台主机。 于是规则是「入口决定承载」:框架侧边栏点 → 右侧栏标签(与对话同屏); 终端弹窗里点 → 终端右侧 dock(与终端同屏)。 dock 面板归属点它的那个标签(tty ≥ 0.18.4):切到别的 SSH 标签时整块收起 (React 树与轮询都保活,切回来恢复原样),关掉该标签则一并收掉;别的标签上再点 「容器」会用那台主机的目标重开一块。收起 ≠ 关掉:tty 不重挂,面板状态不丢。
承载形态
| 承载 | 何时用 | 行为 |
|---|---|---|
| 会话右侧栏标签(默认) | 宿主提供 sidebarRight / sidebarRightTabs |
与会话同屏;折叠=退出视野但不丢状态(面板级状态按模块保留,见下);支持右侧栏的 fullscreen 铺满 |
| 终端右侧 dock | 从终端连接栏的「容器」按钮进来(按钮就在 tty 弹窗上,标签会被它挡住),或拿不到右侧栏服务 / 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 右侧栏的标签记录本身是会话作用域的(sidebar.right.pane.tab 与
rightbar.session 都声明 scope: 'session'),A 会话开的标签在 B 会话里并不存在。可
容器面板看的是主机,「切个会话它就没了」是纯损失,所以插件额外记了一个「用户希望它开着」
的意图:切会话时自动在新会话里把标签重开,只有你点了标签的 ✕ 才停止——也就是
「面板跟人走,而不是跟会话走」。那个 ✕ 是宿主执行的(sidebarRight 自己摘标签),
所以「用户关掉了」这件事必须经 registerCloseHandler 登记才收得到:只靠面板自己的
onClose 会漏掉它,于是刚关掉的标签会在每次切会话时自己回来。
dock 兜底形态(终端右侧栏):
这条路径打开的面板不会盖住终端:tty ≥ 0.16 时它挂在终端面板右侧的 dock 里
(可拖宽、可折叠成窄条、✕ 收起),终端照常敲命令。dock 模式下卡片「终端」按钮改为
在同一终端面板新开标签执行 docker exec -it(面板已经在一个终端里了,再嵌一层
没有意义);同时不再渲染面板自己的头部——标题与 ✕ 由侧栏标题栏承担,刷新与只读
徽标并到工具条末尾并靠右(刷新中图标自己转,不再另挂 spinner):左端留给目标 /
视图 / 搜索 / 筛选这些「过滤类」控件,刷新不会被当成第一个筛选项,位置也与非 dock
模式头部里一致;520px 窄栏里不会白留一条空行。
面板内:
切目标有过渡与保护:选择器一变,下方仍是上一个目标的卡片(远端一次往返最长 20s)。 过渡层的三条原则——不推版、有方向感、少灰:
- 面板正文顶部一条 2px 不定长流光进度条(绝对定位)给「正在取数」的全局信号;
- 正文顶部居中浮出一枚蓝色胶囊(与 dsh-rss 的加载胶囊同一套位置与配色语言):
底色
color-mix(accent 12%, surface)、描边accent 42%、文字与旋转图标走--dk-accent; 可见文案只两段⟳ 正在切换到 目标2 · 当前显示:目标1(不写成一句话、不用引号裹目标名), 完整的「为什么点不动」放title,需要时 hover 就有; - 旧数据保持 82% + 轻度去色(不是压暗到看不清)并锁住指针:读起来是「另一批 内容」而不是「坏了」,同时避免照着旧列表操作——那会拿新目标当目标、用旧列表的容器 ID 发命令,真能停掉对端同名容器;
- 新数据 8px 上滑 + 淡入 200ms 落地,让「换了一批内容」看得见;动效尊重
prefers-reduced-motion。
以上全部绝对定位:切换过程中首卡位置与滚动高度实测完全不变(横幅方案会把内容整块 推下去)。切换失败则清空旧列表并归到新目标的错误态(不继续展示别的目标的数据), 空态文案也会区分「读取失败」与「筛选过窄」。
目标选择:面板先选目标(来自配置
targets,本机 / SSH);只配置了一个 目标时默认选中它,agent 工具也可以省略target参数。 会记住上次选的目标(存浏览器localStorage的dsh-docker:last-target,不落 配置、不进 settings):下次打开面板自动选中它。优先级是连接栏指定 > 上次记住的 (且仍存在)> 列表第一个——记住的目标被删掉/改名后会自动退回第一个,不会停在 「未知目标」上;从终端连接栏的「容器」按钮进来时,当前会话主机优先于记忆值。换目标是整段上下文切换: 上一个目标还在飞的请求一律作废(列表写入闸),不会出现「选择器已经是目标2、卡片 还是目标1 的容器」这种串台,也不会让旧目标的超时横幅停在新目标的页面上。多目标总览(只读):目标选择器旁的
总览pill(配了 ≥2 个目标才出现)——不选 目标,一屏看全部主机:上面一行计数卡(目标名 + 本机 / SSH 标识 + 运行中 / 已停止 / 不健康),下面异常容器置顶表(容器名 / 目标 / 状态 / 镜像;不健康排在 重启中之前,同级按目标在配置里的顺序,跨轮询不跳行)。取数是并行 + 渐进落地: 每个目标独立发一次POST /containers(all:true,裸docker ps不返回 exited, 「已停止」会永远是 0),一个目标不可达只影响它自己那一格——卡片描红 + 顶部一条 「N 个目标不可达」横幅,其余目标的结果照常显示(刻意不用Promise.all汇总: SSH 不可达要等 readyTimeout 20s,汇总等于让整页静默 20s)。点计数卡回到该目标的 常规容器列表,点异常行进该容器详情(返回落到该目标的容器列表);总览不做任何 跨目标操作,异常表的行上没有动作按钮。进总览时会清掉「当前这个目标」遗留的 失败横幅:总览按目标归因(红卡 + 不可达横幅),不再叠一条单目标的「操作失败」—— 同一个 SSH 超时被讲两遍,看着像两台机器都挂了。轮询复用工具条的「自动刷新」开关(默认关): 这一页每轮是全 N 个目标各一次docker ps,比任何单目标页都贵。没有异常显示 「一切正常」;还有目标没答完时,异常区显示「读取中…」、那张计数卡也进入读取态 (转圈 + 压暗,不出 0/0/0——「0 个容器」会被读成「这台机器没容器」,而它只是还没答) (「还不知道」不等于「没问题」)。容器列表:名称 / 状态 / 健康态 / 镜像 / 端口映射 / compose 项目与服务 / 短 ID;支持按名称或镜像搜索、按状态筛选(运行中 / 已停止 / 全部), 以及自动刷新(按
pollIntervalSec轮询,切到镜像页会暂停且不显示该开关—— 镜像变化慢,没必要每 5s 跑一次docker images;切回容器页原设置照旧生效)。 工具条里「含已停止」与「自动刷新」是成组的两个开关,搜索框是其中唯一可伸缩的项: 宽面板下整条工具条收成一行,窄栏(dock 520px)里才按组换行——不会出现一行只有 一个复选框的孤行。「活动」条(docker events 事件流):容器列表头部的可折叠窄栏(默认展开), 跟着一条 SSE(
GET /api/dsh-docker/events/stream,服务端跑docker events --filter type=container)显示最近 8 条事件(时间 + 容器名 + 动作,die带退出码如die(137))。它是事件驱动刷新的入口:收到事件后 500ms 防抖触发一次列表重取(不是每帧一次请求),与原有AUTO REFRESH叠加而不互斥—— 轮询负责兜底,事件负责「刚发生」。事件只留在内存(环形缓冲 50 条),切页即关流, 换目标清空缓冲。白名单只留九类生命周期动作(start / die / stop / kill / oom / health_status / destroy / rename / update):exec_*、archive-path(docker cp) 这类噪音在服务端就丢掉了——实测一台跑批机器 24 小时 47 条事件全是 exec,白名单 命中 0,所以只被 exec 的机器上活动条是空的,这是刻意的。 断开后要手动重连:这条流进了closed就是终止态(EventSource 只在连接层抖动 时自愈;docker events进程退出、服务端发end帧都会走 close),活动条上会出现 一个 重连 按钮(只在closed时出现;unsupported环境不给——重连必然再失败), 工具条的 ⟳ 也一并重连。AUTO REFRESH 轮询刻意不重连:塞进轮询会让一条已终止的流 被不断重开,既掩盖「它断了」,又白建 SSE。重连成功后补一次全量列表刷新(断线期间 列表可能已经落后),daemon 全程没起、流从未连上过的情形同样补——否则活动条绿了而 列表还停在错误态,看着像好了其实没好。 重连被拒时要说原因(D164):服务端主动拒绝这条流时会发一帧event: error带message(最典型的是长流配额满,见「已知限制」——那句话里写着该怎么办); 客户端现在把它显示在活动条上(「重连被拒:…」),而不是丢掉。原先只置closed,于是「拒绝 → 回到原位」与「点了没反应」在界面上完全不可区分,用户只能反复点。 重连成功(或重新建流)时这条原因清除。 断开时右侧那句提示是折叠头里的单行小字,所以刻意写短(2026-10-08 用户反馈 「啰嗦又生硬」):状态行左边已经在说「事件流已断开」,这句只说「不会自动恢复,点 「重连」」——成因、上限与MaxSessions那一串写在本页,不塞进界面。 「在收事件」只认宿主那帧ready(2026-10-11 用户现场):SSE 的onopen只等于 「响应头到了」——宿主是先把响应头 flush 出去、再去起docker events,而目标不可达 / SSH 正在重连时建连可以卡十几秒(D150 的「槽位满 → 重建连接」还会再来一轮)。那段时间 活动条原本已经亮成绿色的「实时接收中(docker events)」,一个事件都还没收到。现在绿点 只认ready(= 管道真的起来了);此前停在「正在连接事件流…」/「连接中断,正在自动 重连…」——两种都是实话。日志 / 统计 / 拉取三条流同一口径(见「SSE 事件协议」)。聚合选择(临时多选聚合日志):工具条的
聚合选择进入选择态——每张卡片 左侧出现勾选框,点卡片本体变成勾选 / 取消(不再进详情;动作条暂时收起, 免得一边多选一边误点启停删),工具条与列表之间出现操作条:「已选 N 个容器」+聚合日志+取消。聚合日志至少需要 2 个容器;选到第 3 个时给一条软提示, 超过 3 个按钮置灰并提示上限(上限怎么来的见「已知限制」的聚合上限, 操作条上也写着那句说明)。点聚合日志进入聚合视图:直接复用 Compose 项目视图那套聚合日志(一条/logs/stream?ids=…装下所有选中容器,帧里带id,按[service]/ 容器名前缀混流,带过滤与自动滚动), 返回即退出选择态并清空。 聚合视图的内容控制与单容器日志完全对齐(文本过滤 + 级别门槛 +⬇ .log/⬇ .md导出当前显示内容 + 行数统计),另有聚合独有的两项:按时间 / 按到达排序、暂停冻结 (暂停时流继续接收、恢复一次性补齐)。 再点聚合选择或按 Esc 同样退出并清空。勾选是临时的:不持久化、不命名 组合、不进 settings;切目标 / 切「容器 · 镜像 · Compose」分段 / 关面板即失效, 列表刷新后已消失的容器按 id 自动剔除。按条件一键选中(多选态):操作条第二行给出一排条件 chip(
全部可见 / 不健康 / 需关注 / 已停止,以及有勾选后的同镜像 / 同项目),计数按剩余名额截断—— chip 上写 3 就真的会选中 3 个;超出上限的数量在 title 与结果提示里如实说明。 条件只在当前筛选结果里生效(先搜索/筛状态再一键选中)。容器卡片:与参考布局一致的「标签 + 值」行(镜像 / ID / 端口 / 创建 / compose,值等宽、可省略)+ 一排图标操作按钮,按「查看 / 变更」两组用竖线分隔:
组 按钮 说明 查看 / 进入 终端、日志、资源占用 不改容器状态,只读模式下也永远可用。终端按钮跑 docker exec -it交互式 shell,不受allowExec门控(那条开关只管本插件的一次性 exec 通道,见「已知限制」)变更 启停、重启、删除 按破坏性递增排列;只在 allowMutations开启后可用,未开启时整组置灰删除额外做了两点防护:破坏性配色 + 与「重启」之间留出间距,点击后仍需二次确认。 命令飞行期间整组锁住:
stop/rm要等容器真的退出(最长十几秒),这段时间 确认框保持打开并显示「执行中…」(两个按钮都禁用),卡片的其它变更按钮压暗置灰、 正在跑的那个图标换成转圈,等列表刷新落地后才恢复——避免中途连点叠出 stop + restart + remove 这类互相打断的命令。镜像的删除 / 清理走同一套确认框, 同样有执行中态。点卡片本体进入概览。容器详情(整栏视图):顶部为「返回 + 容器名 + 状态徽标 + 目标主机」, 下方三个标签页——概览(
docker inspect权威数据 + 一次性 exec)、日志、 统计。从卡片的日志 / 统计图标可直接落到对应标签页。日志视图(紧凑两行):第一行 = 返回 + 容器名 + 状态 + 目标主机 +
LINES(尾部行数)/TIMESTAMPS/FOLLOW(实时跟随,见下) /AUTO REFRESH(开关 + 2/3/5/10s 间隔,仅日志页轮询)+ 刷新 / 关闭; 第二行 = 标签页 + 常驻的「过滤日志」 输入框(有内容时框内浮出 ✕ 清空,Esc 也能清空)+ 级别门槛 (全部级别 / INFO+ / WARN+ / ERROR+(INFO+就是「清静但别丢关键信息」那档:噪音几乎都在 DEBUG 及以下,而只有WARN+会把 INFO 一起滤掉))+ 导出(⬇ .log/⬇ .md,导出的是当前显示内容)- 右侧固定槽的行数统计。两行的分工是刻意的:第一行管传输与显示(快照 / 流 / 轮询),
第二行管内容——而第二行与聚合日志完全同一套:同一个级别内核、同一个导出构建器、
同一份计数文案(
N 行,有过滤时N / M 行)。级别门槛的语义是「无级别前缀的行是上一条的 续行,跟随其级别」,否则ERROR+会把堆栈拦腰截断。输入框宽度与位置恒定,输入 / 清空都不会挤动这一行。日志正文按级别着色([INFO]与|INFO两种常见前缀 都能识别),时间戳压暗,过滤命中高亮;显示与导出取同一集合(LINES选多少就多少),不再有「只显示最近 N 行」的截断。 详情视图下不再叠加列表工具条与面板头,每屏只有一个刷新入口。
- 右侧固定槽的行数统计。两行的分工是刻意的:第一行管传输与显示(快照 / 流 / 轮询),
第二行管内容——而第二行与聚合日志完全同一套:同一个级别内核、同一个导出构建器、
同一份计数文案(
FOLLOW 实时日志流:日志页
FOLLOW开关打开后,界面从「定时拉快照」切换 为 SSE 推送(GET /api/dsh-docker/logs/stream,服务端跑docker logs --follow)——新日志到达即追加,不再轮询;FOLLOW与AUTO REFRESH互斥(开流自动停轮询、开关置灰),关闭即回到快照并立即刷新。 流式日志保留最近 5000 行 / 4MB(环形缓冲,行数或字节超限都丢最旧并提示 一次;没有换行符的超长输出会被强制分片,内存恒有界);分片到达按 150ms 合帧 渲染(话痨容器也不再逐条重绘),行 key 用单调 id。 正文按窗口化挂载(D152):DOM 里只留「可见 + 上下各 24 行 overscan」的几十行 (实测 5000 行时约 230 个节点,此前是全量10000),其余用上下垫高占位——持续洪泛 20k 行/秒时事件循环最大延迟从 7097ms 降到 6ms、>50ms 长帧从 25~26 个降到 0。 行高是实测的(不是估算),贴底时窗口锚在列表尾部,所以「自动贴底」「上滚暂停」 「回到底部」的几何判定依旧精确;往上滚历史时按可见区顶行做跨帧记锚的修正—— 洪泛里缓冲持续淘汰旧行、或聚合视图按时间插行,都不会跳。换流 / 换容器 / 快照刷新 会整代作废这批实测高度与锚点(D155):快照行 id 是位置寻址('s'+index),刷新后 同一个 id 就是另一行内容——不换代就会拿另一代的高度算垫高(实测滚动高虚高 35644px), 滚动条、跳转、「滚到顶看到第几行」全部按错的一代算。 显示层不截断:LINES选多少就是多少——全部行都能滚到、都能导出(真正的闸是 缓冲与宿主的「输出上限(KB)」),只是不再同时挂在 DOM 里。自动滚动到底部,用户向上滚动时暂停并浮出 「回到底部」按钮;右上状态行显示连接状态,容器退出导致流自然结束时自动切回快照刷新。断线重连由插件自己管(不依赖 EventSource 的自动重连):首连带 tail 补历史,重连一律 tail=0——只补新行、不 重放历史(自动重连会复用带 tail 的 URL,服务端就会把最后 tail 行当新行重推一遍, 日志里凭空多出一段重复)。宿主侧背压(D153):单条流的暂存队列上限 8MB;日志与拉取 这类文本尾部流溢出时丢最旧的帧、留最新,并推一条skip{frames,bytes}让界面说明 「中间断了一截,跟随继续」——不再掐流(老策略是补一条end{reason:output-limit}收尾, 客户端只能重连,积压期间面板就是「连接中断」在打转)。skip 的账目是精确的:skip 帧还在 队列里没落地时又发生丢弃,服务端原地更新它的计数(D156,一段缺口都不吞);客户端把 多批 skip 累加显示成「本次连接共跳过 N 批」,重连成功即清零(D157)。统计 / 事件这类结构化流仍走收尾重连 (少一个采样/事件在语义上是错的,宁可让客户端补一次)。 连接 / 切页 / 关面板都会关闭EventSource。概览:
docker inspect的权威数据——状态与健康、退出码、重启次数与策略、 端口映射、挂载(含只读标记)、网络与 IP、entrypoint 与命令、最近一次健康 检查输出;下方可执行一次性docker exec(需allowExec)。日志:
docker logs --tail的尾部快照(LINES默认取设置卡片的「日志 默认行数」),可切时间戳与--since。两个闸要分清:LINES是行数上限, 设置卡片的「输出上限(KB)」是字节上限——两者取小,字节先超就截断并给出横幅 (所以LINES选了 5000 也可能只回来一部分)。要持续观察新日志就打开上面的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 / 内存迷你趋势图(sparkline)。与日志流不同,这条流不会 自然结束,关闭语义是前端主动断EventSource;docker stats自己退出时 服务端发end(reason=stats-exit),界面提示并自动切回快照轮询。 - Compose 项目视图:工具条第三段切换。把
composeProject/composeService标签聚合成「项目 → 服务 → 容器」,每个项目一行看全运行数 / 不健康数 / 服务数与 各容器状态;点进项目可看服务表,或打开项目级聚合日志——一条/logs/stream?ids=…装下项目内所有容器(帧里带id),客户端按[service]前缀 混流,带自动滚动开关与过滤框。混流是到达序,不保证跨容器严格时序。某个容器的子流 失败只标注「部分出错」,其余容器的日志照常收(连接不关)。 - 镜像:
docker images列表(reference / 大小 / 创建时间 / 短 ID);<none>:<none>的 dangling 镜像带dangling标记。搜索框与「N / M 个镜像」 计数固定在工具条里(不随列表滚走),表头列名在表体内吸顶。 每行有「详情 / 删除」两个动作:详情打开整栏视图(概览:大小 / 含父层 / 创建 / 平台 / 层数 / 入口与命令 / 暴露端口 / digest / 标签;构建历史:docker history的逐层命令与大小)。删除(需allowMutations)二次确认 后执行docker image rm(不带-f,镜像被引用时会失败并给出「先删相关 容器」的提示)。 - 网络 / 卷(第五、六段):工具条分段扩到「容器 / 镜像 / Compose / 网络 / 卷」
(窄栏里放不下时分段容器自己横向滚动,不做二级菜单)。两页都是「表格 + 整栏详情」
的同构布局:
- 网络:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览(ID / 驱动 /
范围 / 创建 / 子网 / 网关 / internal·attachable·ingress·ipv6 / 选项 / 标签)与
「接入的容器」页签(容器 / IPv4 / IPv6 / MAC)。容器数刻意不进列表行:
docker network inspect才能拿到接入列表,列表逐行 inspect 就是 N 次 docker 调用, 改成点进详情取一次。 - 卷:名称 / 驱动 / 范围 / 挂载点(超长路径限宽省略,title 给全量),详情为 名称 / 驱动 / 范围 / 挂载点 / 创建 / 选项 / 标签(卷没有反向索引,只有概览一页)。
- 详情页头部有删除按钮,两页工具条各有 prune 图标,都受
allowMutations门控(未开启时置灰 + title 说明),点击后二次确认;删除失败(网络还有容器接着 / 卷还被占用 / 403)在详情页就地弹横幅,不会静默。 - 自动刷新与镜像页一致:这两页不轮询(清单变化慢,5s 一次 docker CLI 是白烧), 切页 / 切目标时才刷。
- 网络:名称 / 驱动 / 范围 / internal 徽标 / ID,行点击进详情——概览(ID / 驱动 /
范围 / 创建 / 子网 / 网关 / internal·attachable·ingress·ipv6 / 选项 / 标签)与
「接入的容器」页签(容器 / IPv4 / IPv6 / MAC)。容器数刻意不进列表行:
- 拉取镜像(SSE 进度流):镜像页工具条的拉取图标(需
allowMutations) 打开拉取视图,输入引用后走GET /api/dsh-docker/images/pull/stream(服务端跑docker pull)——逐层进度(Pulling fs layer / Downloading / Extracting / Pull complete)实时出现;进度行按「层键」原地更新,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,推荐):在面板底部开一个终端抽屉,由 tty 的
ttyTerminal.mount把终端挂进来。面板不收起,看着容器日志直接进容器敲命令, 上下文不断。抽屉会挤占正文高度,所以它可以让位而不结束会话: 折叠(标题栏右侧的箭头,或双击顶边)只把抽屉压成一条标题栏,会话照旧跑着; 拖拽顶边可调高度(上限为面板高度的 75%)。真正结束会话的动作只有两个—— 抽屉上的 ✕,以及关闭面板;有活动会话时关闭面板会先弹确认,避免点 backdrop 空白处时误杀一个正在排障的容器 shell(全局终端面板也是「点空白 = 最小化」,同一取向)。 - 借 tty 弹窗开标签(tty ≥ 0.14):tty 新开一个标签执行同一命令,随后收起本 面板(本面板 z-index 更高,不收起用户只会觉得「点了没反应」)。
- 复制命令:未装 tty / 版本过旧 / 内联目标用了 key·password 认证(浏览器端 没有凭证)时,退化为复制该命令并提示到终端面板粘贴。
本地目标开本地会话,SSH 目标按连接簿条目名(或 agent 认证的内联字段)走 SSH, 标签/抽屉标题为
<容器> · exec。能力判断走服务契约版本 (ttyTerminal.version >= 2才有mount),不是猜函数存不存在。- 就地嵌入(tty ≥ 0.15,推荐):在面板底部开一个终端抽屉,由 tty 的
目标(本机 / 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 里有两个入口,都不做常驻:
| 何时 | 入口在哪 |
|---|---|
| 一个目标都没有 | 面板正文的空态里一个 连接本机 主按钮(那时整块是空的,一键就是最短路径) |
| 已经有别的目标 | 目标下拉的末一项 + 连接本机——就在你找机器的地方,点开才出现 |
两处互斥(不会同一个入口出现两次):没有目标时走空态那个大按钮;有目标时下拉里才有那一项。 已经有本机目标时两处都不出现(选择器里已经有那台本机了,再列一项等于重复)。
agent 侧同一件事是 docker_connect_local 工具,HTTP 侧是 POST /connect-local——
所有入口共用宿主里同一份实现。
为什么不做成常驻的工具条按钮:常驻一个「连接本机」既占掉目标选择器右边的位置,又要靠 强调色招人点,而已经有可用目标时它是纯噪音(第一版就是常驻的,用户当场指出多余)。 但只留空态那一个入口又会漏掉「手上有几台 SSH、想再加一台本机」这种常见情况——所以 第二个入口放进下拉,只有在真的去找机器时才出现。
| 情况 | 行为 |
|---|---|
已有一条 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 指向了
连不上的远端;用 docker context ls 看当前指向)/ daemon 未启动(Linux
systemctl start docker,macOS / Windows 启动 Docker Desktop)/ socket 无权访问
(账号加入 docker 组),而不是只回一句「没有容器」。探测失败不算这次操作失败
(目标确实加上了,reachable:false 单独表示),下次 daemon 起来就能直接用。
探测用的二进制就是设置卡片里的「docker CLI」(dockerBin,默认 docker)。
CLI 缺失时会只读地在 PATH 里找一下别的容器 CLI(docker → podman → nerdctl),
找到就在那句话里点名(「这台机器上装了 podman:把『docker CLI』填成 podman 就能用」)——
只提示,绝不替你改配置:静默改写 dockerBin 会违背本插件「不替用户决定」的取向,
而且 podman 与本插件输出格式的兼容性并未逐项验证(见「已知限制」)。比对候选时按
工具名(basename)而不是配置里的原字符串,所以 dockerBin 写成
/usr/local/bin/docker 时不会出现「把 docker 改成 docker」这种废话。
超时是独立一档:probe() 不只报退出码(code 为 null 时那句「退出码 null」对用户
零信息量),而是回 timedOut:true + 一句可执行的话。超时还会真的按时返回——
SIGKILL 只作用于直接子进程,dockerBin 指向包装脚本时孙进程仍握着管道,会把手上的
15 秒上限拖成分钟级(真机实测 3 秒上限拖到 60 秒),所以到点先杀、再给 500ms 收敛期,
仍不 close 就自行放掉管道收尾。
刻意不做的事(保守边界):不自动安装 Docker、不启动 daemon、不切换 docker
context、不改任何环境变量;空 targets(= 用户没配)的语义不变,不产生隐式默认目标;
clearTargets 的防误清空逻辑一字未动。这条路径也与能力开关无关——它只写插件
自己的配置,不涉及任何需要授权的目标侧写操作。
聚合日志(多选 / Compose 项目)
多选容器或打开一个 Compose 项目,都能把多个容器的日志聚合成一条流:一条
/logs/stream?ids=… 装下全部容器(帧里带 id;宿主侧仍为每个容器各跑一条
docker logs -f),客户端按到达顺序混流。工具栏提供:
| 控件 | 语义 |
|---|---|
| 实时 / 已暂停 | 真暂停(见下) |
| 时间戳 | 显示每行时间戳。时间戳始终随流接收(timestamps=1),只影响显示 |
| 按到达 / 按时间 | 按到达:零延迟跟随;按时间:用每行的容器时间戳合并成一条真时间线 |
| 级别过滤 | 全部级别 / WARN+ / ERROR+。无级别前缀的行(堆栈等续行)继承上一条日志的级别,所以 ERROR+ 会连它的堆栈一起保留、INFO 的续行一起滤掉;窗口开头的孤儿续行(记录头在窗口外)无从判断,保留 |
| ⬇ .log / ⬇ .md | 导出当前显示内容:.log 是纯行文本([服务] ISO时间 正文),.md 带来源容器 / 行数 / 导出时间表头,可直接当工单附件 |
按时间合并怎么做的:SSE 各容器建连有先后,A 的首屏历史可能整批先到、B 随后才到,
只对单批排序修不了跨批逆序。实现是回填最近 400 行——每批新行到达时把「最近 400 行 +
新行」整体按时间戳重排(docker logs --timestamps 的 RFC3339 前缀为排序依据,解析后从
正文里剥掉)。这样既不为了让首屏正确而先憋一段时间(不会开页白屏 1 秒),又能事后纠正
历史错序;代价是「按时间」模式下已在屏上的最近若干行可能轻微重排(正在跟随实时输出时
建议用「按到达」)。
聚合日志的「暂停」(真暂停)
多选容器 / Compose 项目的聚合日志有一个 实时 / 已暂停 开关。暂停是内容冻结,不只是停止自动滚动:
- 暂停期间新到的日志进入客户端缓冲,DOM 不再追加——读屏不会被顶走;也不会因为显示上限 (2000 行)裁掉前部而跳屏;
- 按钮上直接显示攒了多少行(
已暂停 +348); - 恢复时把缓冲一次性并入(沿用 5000 行环形上限)并回到底部。
只停「自动滚动」是不够的:标签写着「已暂停」而内容还在长,用户会以为开关坏了;日志量大时 画面还会因裁前部而自己跳。
总览(跨目标)与「需关注」口径
「总览」页一屏铺开全部目标:每个目标一张计数卡(运行中 / 已停止 / 不健康 / 需关注), 下方是跨目标的「需关注容器」表(容器名 / 目标 / 状态 / 原因 / 镜像,点行进详情、点卡切到 该目标的列表)。三条设计约束:
- 渐进落地 + 失败隔离:每个目标独立请求、独立落格;一台 SSH 不可达不会让整页静默, 不可达的目标单独出横幅,其余目标结果照常可用。
- 需关注口径以宿主为准:容器列表与
/attention并行请求;后者额外做一次docker inspect, 因此能识别 OOM(OOMKilled) 与真实退出码——ps 摘要里Exited (137)分不出是被 OOM 杀还是手动 kill。拿不到/attention(老版本宿主 / 该目标失败)时退回摘要口径,并在 计数上标注「需关注(粗判)」。 - 排序:OOM > 僵死 > 不健康 > 反复重启 > 非零退出;同权重按「最近一次结束时间」倒序, 刚崩的排在最上面(行 hover 显示结束/启动时间、重启次数、退出码)。
配置(保存即热生效)
未获宿主授权时,设置卡片里那两个开关点它会就地发起一次授权:面板给出一条「在宿主终端执行」
的命令,执行完十秒内自动解锁并替你打开开关(免重启);面板里另有「另一种方式(最强)」说明
启动环境变量的做法。已授权时它们就是普通开关,关掉永远可用(宿主侧不拦降权)。
「配置开着但没授权」会显示「未生效:未获宿主授权」徽标——这个状态最容易被当成插件坏了,
所以必须有个名字。一个能力一行:开关后面跟这个能力自己的授权状态与授权时刻(已授权 · 2026-09-26 22:31:08),
撤销按钮右对齐——两个能力的授权是分开的,并排放会看不出哪个撤销管哪个能力。
两个能力的确认也能一次粘贴搞定:面板开着时再点另一个开关不会顶掉前一块面板(两个能力的
授权本来就互不影响),两块面板纵向堆叠,顶部多出一个「复制全部(N 条)」——它把仍 pending 的
命令按发起顺序用换行符连接后一次复制,用户去宿主终端粘贴一次即可:bash / zsh /
PowerShell / cmd 粘贴多行都逐行执行,所以刻意不用 &&(PowerShell 5.1 不认)或 ;(cmd 不认)
连接。某条过期后,合并按钮只复制仍 pending 的那些;全部解锁(或全部收起)后它自己消失。两个
challenge 的 TTL 与探测周期各自独立,所以文案只说「执行后全部开关自动解锁」、不承诺同一时刻
——倒计时仍是逐面板显示,每块面板的复制 / 重新生成 / 收起也照旧只管自己那一个能力。
为什么显示时刻:带外授权是持久的——宿主重启后它直接生效、不再有任何一次确认。也就是说
「上个月授权的能力今天一开机就开着」这件事不会有任何提示,除非界面与日志说出来。所以宿主启动时会
逐条打一行 [dsh-docker] elevation: load capability=… via=file grantedAt=…(不含 nonce 与路径),
卡片上也把它显示出来。启动环境变量授权没有时刻(它就是启动环境的一部分,编一个假时间更糟),
那一行显示的是「由启动环境变量授权;要撤销需在启动环境里去掉它并重启宿主」。
设置面在宿主里的位置随 DSH 版本变,但始终是同一份表单(同一个 settings 命名空间、 同一个写入通道),配置数据与行为逐字节相同:
| 宿主 | 设置面在哪 |
|---|---|
0.2.0-rc.1 起 |
插件详情页「说明」正下方(plugins.bundle.config,key = 本包自己的 bundle 包名)——设置就在详情页里,不必再点一次「>」进二级页 |
0.1.6 线 |
侧边栏「插件」→ 该 bundle 的行 → 「>」行详情(plugins.row.config) |
| 以上各版 | 设置 → 插件配置 里的同一条目(settings.kit.item,由 @hyzyn/dsh-kit-settings 提供) |
新宿主上 bundle 槽可用时不再注册本包那份 row 槽——同一份表单两个入口只会让人以为有两套设置;
旧宿主(bundle 槽不存在)自动回退到行详情,功能一点不减。聚合包 @hyzyn/dsh-all 例外:它的
plugins.bundle.config key 是所有插件共享的(重复注册会直接抛错),所以挂不了内联位,它在聚合
安装下继续走 row 入口。
配置落在 settings 命名空间 docker,即 ~/.dsh/settings.yaml 的 docker:
段($DSH_HOME/settings.yaml;DSH 的 settings 文件由宿主 dsh-settings-file
提供)。插件行里的 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 盘符与 \ 必须放行**,否则任何绝对路径都填不进来)。**「连接本机」的探测用的就是它**;填错(CLI 不存在)时那句话会点名 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 不算授权;② 就地提权(免重启):在设置卡片点开关 → 面板给出一条「在宿主终端执行」的命令 → 执行后十秒内生效;两个能力各点一次后,面板组顶部会给一个「复制全部(N 条)」(仍 pending 的命令按发起顺序换行连接),终端粘贴一次即可全部解锁(各自探测周期内先后生效,不承诺同一时刻)。HTTP 侧永远可以关掉它们(紧急刹车不能依赖重启),但给 true 而无授权会被 400 拒绝并说清两条路。配置里的 true 不算授权(它与 HTTP 写进去的值存在同一个存储里,分不出来源)。升级影响:升级前靠界面打开的开关会变成关——设环境变量重启,或在卡片里就地确认。为什么:回环围栏与同源证明都拦不住跨站页面与页内脚本(它们能自己填 Sec-Fetch-Site: same-origin),而 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,由@deepseek-ai/dsh-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);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 无权;CLI 缺失时还会点名 PATH 里找到的别的容器 CLI,但不会替你改 dockerBin),不是只报空列表 |
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)。排障第一步 |
docker_attention |
有目标即注册 | target?(支持 *)、limit?: number |
需关注汇总:不健康 / 反复重启 / 被 OOM 杀 / 非零退出 / 僵死;每条带 reasons、exitCode、oomKilled、restartCount。OOM 与真实退出码来自一次 docker inspect(ps 摘要里 137 无法区分手动 kill)。排障入口:不确定从哪台/哪个容器看起时先调它 |
docker_inspect |
有目标即注册 | target?、id(必填) |
docker inspect 的权威详情:状态 / 健康检查 / 退出码 / 重启次数 / 端口 / 挂载 / 网络 / 启动命令 |
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 |
docker_logs_grep |
有目标即注册 | target?、id、pattern(必填,≤512 字符)、regexp?、ignoreCase?、since?、until?、context?(0limit?(1tail?(缺省 = 扫全部历史)、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 正则 |
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_events |
有目标即注册 | target?、since?(docker --since 语法,默认 10m) |
容器事件快照(docker events --since <d> --until <now>,同样过服务端白名单):start / die / stop / kill / oom / health_status / destroy / rename / update 九类,exec_* 等噪音已在服务端丢掉。要持续观察请让用户看面板容器列表的「活动」条 |
docker_networks |
有目标即注册 | target?、limit?(1~500,默认 100)、offset?(默认 0) |
网络列表(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表不进列表行——详情页会连坐 inspect,列表逐行 inspect 就是 N 次 docker 调用 |
docker_volumes |
有目标即注册 | target?、limit?(1~500,默认 100)、offset?(默认 0) |
卷列表(名称 / 驱动 / 范围 / 挂载点) |
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(必填,经容器内 sh -c 执行)、timeoutSec?(1~120,默认 execTimeoutSec) |
一次性 docker exec,返回退出码 / stdout / stderr;无 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_logs(尾部看不到成因就docker_logs_grep带pattern/since/until检索深处)→docker_inspect→docker_stats;镜像排查docker_images→docker_image_inspect。 announceToAgent开启时,插件向 systemPrompt 注入一段精简公告:插件与面板位置、「默认 只读」与「docker socket ≈ 目标主机 root」的约束、「allowExec只管一次性 exec 通道」的 边界(卡片「终端」按钮不受它门控)、target的*适用范围,以及跨工具排障顺序;它不 逐条复述工具用法——那是各工具 description 的职责。
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、/connect-local)要求请求带 Origin: <同源> 或 Sec-Fetch-Site: same-origin,
否则 403 缺少同源证明。这是为了挡住「恶意页面用 <img src=.../images/pull/stream> 触发一次真实拉取」
这类跨站副作用;curl / 老 Safari / 部分 WebView 不带这两个头时会撞上它(浏览器正常使用不受影响)。
只读路由不要求同源证明。
例外:桌面版靠宿主 Cookie 过闸(D139)。DeepSeek Harness 桌面版把页面发往
dsh-app://app/api/… 的请求转给真实宿主时会删掉 Origin 与 Sec-Fetch-Site、只重写
Cookie(app.asar/lib/main.js 的 forwardWebRequest),所以「两条证明都缺省」在桌面版是常态。
这类转发的判定条件是带着宿主会话 Cookie(桌面壳用启动 URL 换来的 set-cookie,缺它整条转发
直接 503):没有 Origin、没有 Sec-Fetch-Site、但有 Cookie → 放行;两条都没有且无 Cookie
(裸 curl / 老 Safari)→ 仍 403。浏览器页面伪造不了 Cookie 头,而跨站请求一定带
sec-fetch-site: cross-site(loopback 围栏先拒),所以拒绝分支没有被放松。修复前桌面版的这十二条
路由全部 403——症状是日志 / 统计 / 活动 / 拉取四条流无限「连接中断,正在自动重连…」,而只读
路由照常可用。
| 路由 | 方法 | 请求体 | 返回 |
|---|---|---|---|
/config |
GET | — | {ok:true, config}:配置快照(targets 只给 passwordSet / passphraseSet,另附只读的 ttyBooks / ttyAvailable / toolsRegistered / streamProtocol——最后这个是 SSE 帧协议版本,客户端据此决定「怎样才算已就绪」,见「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: AttentionItem[], total, truncated, degraded}(limit 在过滤 + 严重度排序之后生效,默认 100、上限 500);target:'*' 时 {ok:true, groups:[{target,label,ok,error?,data:{items,total,truncated,degraded}}]} |
/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}} |
/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(ContainerStats,形状与 /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}} |
/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(下表 + 客户端的
「绿点只认 ready」口径)。各自只差执行器与结束原因:
每帧一行 event: + 一行 JSON data:(JSON 单行封装:换行 / 引号被转义,
多字节字符不会被 SSE 行边界截断),随后空行:
| 事件 | data | 说明 |
|---|---|---|
ready |
{} |
执行器真的起来了才发一帧(本机子进程 spawn / 远端通道已开 = StreamHandlers.onStart):客户端据此才认为「在收数据」。响应头到达(onopen)不算——响应头由 flushHeaders() 立刻写出去,而建连(SSH acquire / 远端开通道)可能卡十几秒,目标不可达时干脆只有 error。起不来就没有 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":"..."} |
仅多源:该容器这一条子流失败(spawn 错 / 远端通道被拒)。整条连接也不关,其余容器继续;客户端标注「部分出错」 |
stats |
ContainerStats |
统计流专用:每秒每个容器一帧,字段与 /stats 快照完全一致。服务端按扁平 {...} 抽取(docker stats 即便 stdout 是管道也走 TTY 渲染器,帧里混着 ESC[H/ESC[K/ESC[J,按行解析会整行丢掉)并折叠同一采样的重复渲染,客户端不必再解析 docker 的 PascalCase 字符串 |
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):聚合日志(多选 / Compose 项目日志)把 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里可能有多行—— 客户端本来就是按「任意分片」切行的,语义不变,只是「一个 stdout chunk 一帧」没了(话痨 容器每秒几千帧时,每帧的固定开销全砸在浏览器主线程上)。end帧一定排在窗口里最后一批line之后。 - 清理:客户端断开 → 立即中止执行器(本机
SIGTERM,2s 未退再SIGKILL; SSH 关闭该 exec channel、连接池连接保留复用),不写任何帧;插件禁用 / 配置 热更新 / 卸载 → 服务端主动收尾(abort +end)。 - SSH 的长流会占用连接池里的连接(busy 计数),空闲回收(120s)不会误杀; 流结束后恢复回收。
- 「已就绪」的判据(客户端侧):绿点 /「实时跟随中」只认宿主那帧
ready——响应头到了 不算。此外每条流还有一条「更硬的证据」兜底(收到它就等于管道在产数据):事件流的第一条 真实事件、日志流的第一行、统计流的第一个采样、拉取流的第一帧进度。 开发档错位(宿主进程还跑着旧代码、浏览器却从磁盘读到新的client.js)另有版本协商:/config的streamProtocol < 2(或老到没有这个字段)说明该宿主从不发ready,客户端 退回旧判据(响应头到了就算连上),并只重开一次那条流让回退判据生效——2026-10-11 实测: 旧宿主 + 新客户端原本会被钉死在「正在连接事件流…」(卡片能读到容器,活动条却在转)。
安全模型
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,由凭据层的 provider 托管)里, 避免明文写进settings.yaml;不必依赖 env 插件当中间人。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 行 环形缓冲与 2000 行着色上限承担。 - 变更操作有逐条使用审计。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(本机与 SSH 目标都可以)。 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的快照)。 - 流式能力的四条边界:日志页
FOLLOW(SSE +docker logs -f)、统计页FOLLOW(SSE +docker stats+ 60 点 sparkline)、容器列表的事件流 (SSE +docker events,驱动活动条与列表防抖刷新)、镜像页拉取进度流 (SSE +docker pull);但 agent 工具docker_logs/docker_stats/docker_image_pull一律保持快照语义(单值返回模型不适合无界流)。流式 日志在浏览器侧只保留最近 5000 行(丢最旧并提示),统计只保留 60 个采样点。 SSH 长流会占住连接池中的该连接(busy),同一主机上的其它命令复用同一条连接—— 但不是互不影响:通道额度是共享的,见下一条。统计流不会自然结束,关闭必须 由前端主动断EventSource。 - 浏览器同源连接预算:docker 面板现在最多只占 1 条常驻流(D165 实测 + D168 收口):
宿主是 HTTP/1.1(没有 h2 多路复用),浏览器对每个源只放这么几条 TCP,而
每条
EventSource独占一条连接、且不会主动释放。吃满之后,同一个页面里 所有普通请求(列表刷新、/api/*、会话加载……)一起排队——用户看到的是 整个 DSH 页面卡住,不只是 docker 面板(实测:常驻流 0/4 条时页面请求正常, 5 条时 10 次全部超时;完整数据与现场见 DEFECTS.md 的 D165)。 docker 面板的常驻流在任何视图下都只有 1 条:容器列表 = 活动条事件流;详情 = 日志 FOLLOW 或统计 FOLLOW(同一时刻只有一个页签);聚合 / Compose 项目日志 = 一条ids=多容器流;拉取 = 一条进度流。进入详情 / 聚合视图时事件流会主动断开 (列表被整段替掉、活动条不可见),回列表时重连并补一次全量刷新(D168)。 这不是「性能建议」而是硬约束:超了不是「慢一点」,是同页面所有请求一起失效。 想自己复核:在 devtools 里连开new EventSource('/api/dsh-docker/events/stream?target=<目标>')到第 5 条,再打一次 普通请求即可看到它挂住;关掉就恢复。(WebSocket 不占这份预算——tty 终端那条 WS 与 4 条 SSE 共存时页面仍正常,实测过。) - 多标签页要按「全部插件加起来」算,不是 docker 一家:那 6 条连接是整个页面共享的,
官方
dsh-client-hmr、其它插件的 SSE 都算在内。如果每个标签页里宿主自身就挂着 2 条 常驻 SSE,3 个标签页 = 6 条,已经到悬崖——这一层不是本插件能改的:真要治本得让 宿主换 WebSocket / h2,或让各插件共用一条多路复用连接。docker 这边已经把自家的账压到 1 条(见上一条),剩下的要按现场实际装了哪些插件去数。 - 聚合上限是「可读性 + 宿主通道」的上限,不再是浏览器连接数(D168):聚合日志是一条
连接装下所有选中容器,所以
PICK_MAX = 3不再由「4 条预算 − 1 条事件流」推出来; 保留 3 是因为一屏混三份以上日志会看不清,且每个被选容器在宿主侧仍各跑一条docker logs -f(SSH 目标上就是一条 channel,见下一条的 8 条额度)。操作条上写着 同一句说明。 - SSH 目标上的通道额度是共享的(
MaxSessions):一个目标只维持一条 TCP 连接, 所有长流与短命令共用这条连接上的通道,而 OpenSSH 的MaxSessions默认只有 10。 长流(日志 / 统计 / 事件)会一直占到用户关掉面板为止,聚合日志里每个被选容器仍各占 一条 channel(选中上限 3,所以最坏 3 条)——叠上刷新 / inspect 这类短命令就可能 逼近额度,于是远端拒绝时看到的是(SSH) Channel open failure: open failed。所以插件 对每个 SSH 目标限制同时最多 8 条长流(= 10 − 2,留两条给刷新 / inspect 这类短命令), 聚合选择的上限也定在 3。超限与远端拒通道时给出的都是带指向性的提示,而不是 ssh2 的 原始文案。若你的 sshd 调过MaxSessions(sshd -T | grep maxsessions),当前上限是 编译期常量,需要跟着改就提 issue。折叠右侧栏标签会主动断流、把通道还回去。 (D150)这条约束另有两道补救:① 短命令按目标排队 —— 每条连接同时最多 2 条 并发(DSH_DOCKER_SHORT_CHANNELS可覆盖,仅测试 / 排障),多出来的等前一条收尾, 不再出现「一次点击(概览 inspect + 日志快照 + 统计快照)就去撞 10 的硬上限」; ② 额度满会自愈 —— 远端回Channel open failure: open failed(sshd 侧原文error: no more sessions)时,插件丢掉并关闭那条连接、重建一条再重试一次, 宿主日志留一行通道额度已满:已重建连接重试。之所以必须重建:这条连接不会自己 恢复(中止长流时发的signal('KILL')在部分 sshd 上被拒绝,而 sshd 在子进程仍活着 时延迟释放 session 槽)。代价是该连接上正在跟随的流会断开、由面板 SSE 自动重连 (日志流按D133用tail=0续尾);③ 中止长流时插件自己的账当场结清(D164) ——AbortSignal触发时除了向远端KILL+ 关通道,还会**
…
链接
同类插件
Tencent/WeKnora#dsh-weknora★ 35688
把 WeKnora 知识库接入 dsh 的四个只读工具:列出知识库、混合检索原文片段、按顺序还原单篇文档,以及直接取用 WeKnora 自己带引用的 RAG 或 ReAct agent 回答(含可续聊的 session id)。
superdesigndev/treg★ 6403
给 Agent 的工具目录:按「要做的事」检索约 2,600 个外部接口(SEO 与 SERP、外链、社交、人物与公司信息补全、广告库、抓取),查看参数与单次调用价格后直接调用,凭据由服务端注入。附带技能,MCP 行在未设置 TREG_TOKEN 前保持禁用。
T-Auto/dsh-ops★ 4593
为 Windows 的 DSH 提供 bash、PowerShell 7 及 Rust 高性能工具,减少 token 消耗。
sixtysevenlf/dsh-blender-plugin★ 1388
让 DSH agent 通过直连 TCP 通道驱动本地 Blender,提供 15 个工具:视口帧、自定义视角渲染、Python 执行、无头与作业卸载、事务回滚,以及 34 个 family / 228 个 op 的规划层。
TencentCloudBase/CloudBase-AI-Toolkit#dsh-plugin★ 1152
把腾讯云 CloudBase 后端接入 DeepSeek Harness——在对话里搭好并部署全栈应用,查询结果渲染为表格卡片(分页、排序、导出 CSV),部署后可预览真实域名,并提供 CloudBase MCP 工具集(`mcp__cloudbase__*`),登录走 device-code 流程。
anysearch-team/anysearch-dsh★ 665
基于 AnySearch 的实时网页与垂直搜索插件,为 DeepSeek Harness 提供搜索工具。
社区评论
评论公开保存在 GitHub Discussions。加载评论会连接 GitHub 和 Giscus;发表内容需要 GitHub 账号。