@hyzyn/dsh-docker 0.3.3 → 0.4.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/lib/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import z from '@deepseek-ai/schemastery';
2
2
  import { definePlugin } from '@hyzyn/dsh-kit';
3
3
  import { defineTool } from '@deepseek-ai/dsh-tools';
4
- import { DockerApi, assertBin, createRunner, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
4
+ import { DockerApi, assertBin, assertImageRef, assertRef, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
5
5
  import { RemoteExec, sshTarget } from './ssh-exec.js';
6
6
  const TARGET_SCHEMA = z.object({
7
7
  name: z.string().required(),
@@ -57,7 +57,16 @@ const KNOWN_CONFIG_KEYS = new Set([
57
57
  * ------------------------------------------------------------------ */
58
58
  const ROUTE_PREFIX = '/api/dsh-docker';
59
59
  const BODY_LIMIT = 1024 * 1024;
60
- const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表、状态、端口、日志与资源占用,以及镜像列表;目标在 设置 → 插件 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除与 docker exec 需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器)、docker_inspect(容器详情)、docker_logs(日志)、docker_stats(CPU/内存/IO)、docker_images(镜像);排障推荐顺序 docker_ps → docker_logs → docker_inspect → docker_stats。docker_action / docker_exec 仅在用户打开对应开关后可用,执行前须确认目标容器,破坏性操作(remove)要向用户复述后果。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
60
+ const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 设置 → 插件 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器,含 compose 项目与服务)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序 docker_ps → docker_logs → docker_inspect → docker_stats → docker_events,镜像排查用 docker_images → docker_image_inspect。docker_action(容器生命周期)、docker_image_remove(删镜像)、docker_image_prune(清理 dangling)、docker_image_pull(拉取镜像)、docker_exec 仅在用户打开对应开关后可用,执行前须确认目标,破坏性操作(容器 remove / 镜像删除与清理)要向用户复述后果。网络 / 卷的删除与 prune 目前只提供面板按钮(HTTP 端点),没有对应的 agent 工具——不要在 agent 侧绕过面板做这些变更。docker socket 等价于目标主机的 root 权限,不要在用户未明确要求时执行变更操作。';
61
+ /**
62
+ * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
63
+ * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
64
+ */
65
+ export function sseFrame(event, data) {
66
+ return `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
67
+ }
68
+ /** SSE 心跳间隔(毫秒):注释帧只保活,客户端 EventSource 会忽略。 */
69
+ const SSE_HEARTBEAT_MS = 15_000;
61
70
  /** HTTP 路由的 loopback 信任围栏(与 tty / dsh-mcp 同思路)。 */
62
71
  function isLoopbackHttp(req) {
63
72
  const address = req.socket.remoteAddress;
@@ -211,6 +220,32 @@ function clampInt(value, min, max, fallback) {
211
220
  return fallback;
212
221
  return Math.min(Math.max(value, min), max);
213
222
  }
223
+ /**
224
+ * 事件时间(Unix 秒)→ 本机时区的 HH:MM:SS(agent 文本输出用)。
225
+ * 只回时间不回日期:事件快照窗口最多几小时,日期对排障没有信息量;
226
+ * 浏览器侧不用这个——那里用 Date 按用户本地时区现算。
227
+ */
228
+ export function formatEventTime(seconds) {
229
+ const date = new Date(seconds * 1000);
230
+ if (Number.isNaN(date.getTime()))
231
+ return '--:--:--';
232
+ const pad = (value) => String(value).padStart(2, '0');
233
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
234
+ }
235
+ /** 字节 → docker 风格的人类可读大小(十进制单位,与 `docker images` 的 SIZE 一致)。 */
236
+ export function formatBytes(value) {
237
+ if (!Number.isFinite(value) || value < 0)
238
+ return '—';
239
+ const units = ['B', 'kB', 'MB', 'GB', 'TB'];
240
+ let size = value;
241
+ let unit = 0;
242
+ while (size >= 1000 && unit < units.length - 1) {
243
+ size /= 1000;
244
+ unit += 1;
245
+ }
246
+ const text = unit === 0 ? String(Math.round(size)) : size.toFixed(size >= 100 ? 0 : 1);
247
+ return `${text} ${units[unit] ?? 'B'}`;
248
+ }
214
249
  /** 从 tty 的 settings 命名空间读取连接簿(只读;tty 未安装时为空表)。 */
215
250
  function readTtyBooks(settings) {
216
251
  const out = new Map();
@@ -444,8 +479,126 @@ const plugin = definePlugin({
444
479
  toolsRegistered: registeredNames,
445
480
  };
446
481
  };
482
+ /* ---------- 活跃 SSE 长流清点 ---------- */
483
+ /**
484
+ * 活跃的 SSE 长流句柄(日志 / 统计 / 拉取三条流共用)。流本身由 HTTP handler
485
+ * 持有并自清理;这里登记一份,供插件禁用 / 配置热更新 / 卸载时统一收尾
486
+ * (end + abort 执行器)。句柄是每个插件实例一份(不共享模块级集合),
487
+ * 多实例挂载时不会互相误杀。
488
+ */
489
+ const activeStreams = new Set();
490
+ const closeAllStreams = () => {
491
+ for (const stream of [...activeStreams]) {
492
+ try {
493
+ stream.end();
494
+ }
495
+ catch {
496
+ /* 连接已断开 */
497
+ }
498
+ }
499
+ activeStreams.clear();
500
+ };
501
+ /* ---------- 通用 SSE 长连接(三条流共用一份基建) ---------- */
502
+ /**
503
+ * 通用 SSE 长连接。日志流(`docker logs -f`)、统计流(`docker stats`)、
504
+ * 拉取流(`docker pull`)三者只差「执行器 + 结束原因 + end 附加字段」,
505
+ * 其余全部共用这一份实现:
506
+ *
507
+ * - 响应头(text/event-stream / no-cache / keep-alive / x-accel-buffering)
508
+ * 统一在这里写,写完立即 flushHeaders(宿主 gzip 对 SSE 显式跳过);
509
+ * - 每 15s 一帧 `: ping` 注释心跳(SSE 规范里客户端忽略);
510
+ * - 活跃流登记:插件禁用 / 配置热更新 / 卸载时由 closeAllStreams 统一收尾;
511
+ * - 客户端断开 → 静默清理(不写任何帧)+ abort 执行器;
512
+ * - 执行器正常退出 → `end` 帧(带 reason / code / 附加字段)后关闭响应;
513
+ * - 执行器抛错 → `error` 帧后关闭响应。
514
+ *
515
+ * 调用方负责「参数校验」——校验失败时写常规 JSON(4xx)并直接返回,不建流。
516
+ */
517
+ const openSseStream = async (res, options) => {
518
+ const write = res.write?.bind(res);
519
+ const flushHeaders = res.flushHeaders?.bind(res);
520
+ if (write === undefined || flushHeaders === undefined) {
521
+ writeJson(res, 500, { error: '宿主响应不支持 SSE 长连接' });
522
+ return;
523
+ }
524
+ res.writeHead(200, {
525
+ 'content-type': 'text/event-stream; charset=utf-8',
526
+ 'cache-control': 'no-cache',
527
+ connection: 'keep-alive',
528
+ 'referrer-policy': 'no-referrer',
529
+ // 反代(nginx 等)默认缓冲响应体:不关掉的话流要等缓冲区满才到浏览器
530
+ 'x-accel-buffering': 'no',
531
+ });
532
+ flushHeaders();
533
+ const controller = new AbortController();
534
+ let done = false;
535
+ let heartbeat = null;
536
+ const stopHeartbeat = () => {
537
+ if (heartbeat === null)
538
+ return;
539
+ clearInterval(heartbeat);
540
+ heartbeat = null;
541
+ };
542
+ /** 客户端断开 / 写失败:静默清理(不写帧),中止执行器。 */
543
+ const clientGone = () => {
544
+ if (done)
545
+ return;
546
+ done = true;
547
+ stopHeartbeat();
548
+ activeStreams.delete(handle);
549
+ controller.abort();
550
+ };
551
+ /** 服务端主动收尾(正常结束 / 出错 / 插件禁用 / 卸载):关响应。 */
552
+ const finish = () => {
553
+ if (done)
554
+ return;
555
+ done = true;
556
+ stopHeartbeat();
557
+ activeStreams.delete(handle);
558
+ controller.abort();
559
+ try {
560
+ res.end();
561
+ }
562
+ catch {
563
+ /* 连接已断开 */
564
+ }
565
+ };
566
+ const send = (frame) => {
567
+ if (done)
568
+ return;
569
+ try {
570
+ write(frame);
571
+ }
572
+ catch {
573
+ // 写失败 = 连接已断:与 res close 同一收尾路径
574
+ clientGone();
575
+ }
576
+ };
577
+ const sendEvent = (event, data) => send(sseFrame(event, data));
578
+ const handle = { end: finish };
579
+ activeStreams.add(handle);
580
+ res.on?.('close', clientGone);
581
+ heartbeat = setInterval(() => send(': ping\n\n'), SSE_HEARTBEAT_MS);
582
+ heartbeat.unref?.();
583
+ try {
584
+ const code = await options.run(sendEvent, controller.signal);
585
+ if (done)
586
+ return;
587
+ sendEvent('end', { reason: options.reason, code, ...(options.endData?.(code) ?? {}) });
588
+ finish();
589
+ }
590
+ catch (error) {
591
+ if (done)
592
+ return;
593
+ sendEvent('error', { message: error instanceof Error ? error.message : String(error) });
594
+ finish();
595
+ }
596
+ };
447
597
  /* ---------- 配置热应用 ---------- */
448
598
  const applySection = (section) => {
599
+ // 目标 / 凭证 / 启用态都可能变:已开的日志流按旧配置在跑,先统一收尾;
600
+ // 浏览器侧 EventSource 会自动重连(服务端已停机就停在断开态)
601
+ closeAllStreams();
449
602
  // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
450
603
  const merged = { ...live, ...section };
451
604
  if (section.hostKeys === undefined)
@@ -484,6 +637,16 @@ const plugin = definePlugin({
484
637
  return `目标 ${target}:没有运行中的容器。`;
485
638
  return `目标 ${target} 的资源占用:` + rows.map((row) => `\n- ${row.name} cpu=${row.cpuPercent === null ? '?' : String(row.cpuPercent) + '%'} mem=${row.memUsage} (${row.memPercent === null ? '?' : String(row.memPercent) + '%'}) net=${row.netIO} block=${row.blockIO} pids=${row.pids === null ? '?' : String(row.pids)}`).join('');
486
639
  };
640
+ const renderEvents = (target, since, rows) => {
641
+ if (rows.length === 0)
642
+ return `目标 ${target}:最近 ${since} 没有容器事件。`;
643
+ return `目标 ${target} 的容器事件(最近 ${since},${String(rows.length)} 条):` + rows.map((row) => {
644
+ const at = row.time === null ? '--:--:--' : formatEventTime(row.time);
645
+ const exit = row.exitCode === null ? '' : ` exit=${String(row.exitCode)}`;
646
+ const compose = row.composeProject === null ? '' : ` compose=${row.composeProject}`;
647
+ return `\n- ${at} ${row.name} ${row.action}${exit}${compose} image=${row.image}`;
648
+ }).join('');
649
+ };
487
650
  const renderDetail = (detail) => {
488
651
  const lines = [
489
652
  `容器 ${detail.name}(${detail.shortId})`,
@@ -500,11 +663,56 @@ const plugin = definePlugin({
500
663
  lines.push(`- 最近健康检查:${detail.healthLogTail}`);
501
664
  return lines.join('\n');
502
665
  };
666
+ const renderNetworks = (target, rows) => {
667
+ if (rows.length === 0)
668
+ return `目标 ${target}:没有网络。`;
669
+ return `目标 ${target} 的网络(${String(rows.length)} 个):` + rows.map((row) => {
670
+ const internal = row.internal ? ' internal=true' : '';
671
+ return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${internal} id=${row.shortId}`;
672
+ }).join('');
673
+ };
674
+ const renderVolumes = (target, rows) => {
675
+ if (rows.length === 0)
676
+ return `目标 ${target}:没有卷。`;
677
+ return `目标 ${target} 的卷(${String(rows.length)} 个):` + rows.map((row) => {
678
+ const mount = row.mountpoint === '' ? '' : ` mount=${row.mountpoint}`;
679
+ return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${mount}`;
680
+ }).join('');
681
+ };
503
682
  const renderImages = (target, rows) => {
504
683
  if (rows.length === 0)
505
684
  return `目标 ${target}:没有镜像。`;
506
685
  return `目标 ${target} 的镜像(${String(rows.length)} 个):` + rows.map((row) => `\n- ${row.reference} ${row.sizeText}${row.createdSince === '' ? '' : ' (' + row.createdSince + ')'} id=${row.shortId}`).join('');
507
686
  };
687
+ /** 镜像详情 + 构建历史(agent 工具 docker_image_inspect 的可读渲染)。 */
688
+ const renderImageDetail = (payload) => {
689
+ const d = payload.detail;
690
+ const title = d.repoTags.length === 0 ? `<none>(${d.shortId})` : d.repoTags.join(', ');
691
+ const lines = [
692
+ `镜像 ${title}`,
693
+ `- ID:${d.id}`,
694
+ `- 大小:${d.size === null ? '—' : formatBytes(d.size)}${d.virtualSize === null || d.virtualSize === d.size ? '' : `(含父层 ${formatBytes(d.virtualSize)})`}`,
695
+ `- 创建:${d.created === '' ? '—' : d.created}`,
696
+ `- 平台:${d.os === '' && d.architecture === '' ? '—' : `${d.os}/${d.architecture}`}`,
697
+ `- 层:${String(d.layerCount)} 层`,
698
+ `- 入口:${(d.entrypoint + ' ' + d.command).trim() || '—'}`,
699
+ ...(d.exposedPorts.length === 0 ? [] : [`- 暴露端口:${d.exposedPorts.join(', ')}`]),
700
+ ...(d.repoDigests.length === 0 ? [] : [`- digest:${d.repoDigests.join(', ')}`]),
701
+ ];
702
+ if (payload.historyError !== null)
703
+ lines.push(`- 构建历史:读取失败(${payload.historyError})`);
704
+ else if (payload.history.length === 0)
705
+ lines.push('- 构建历史:无');
706
+ else {
707
+ lines.push(`- 构建历史(${String(payload.history.length)} 步,从新到旧):`);
708
+ for (const step of payload.history.slice(0, 30)) {
709
+ lines.push(` · ${step.shortId} ${step.createdSince} ${step.sizeText} ${step.createdBy.slice(0, 160)}`);
710
+ }
711
+ if (payload.history.length > 30)
712
+ lines.push(` …(其余 ${String(payload.history.length - 30)} 步略)`);
713
+ }
714
+ return lines.join('\n');
715
+ };
508
716
  /** 重新注册 agent 工具(能力开关变化时调用;幂等)。 */
509
717
  const refreshTools = () => {
510
718
  for (const dispose of toolDisposers) {
@@ -791,6 +999,72 @@ const plugin = definePlugin({
791
999
  };
792
1000
  },
793
1001
  }));
1002
+ add('docker_events', defineTool({
1003
+ name: 'docker_events',
1004
+ description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 八类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1005
+ parameters: {
1006
+ target: targetParam,
1007
+ since: { type: 'string', description: '起始时间(docker --since 语法,如 30m、2h;默认 10m)' },
1008
+ },
1009
+ output: {
1010
+ schema: {
1011
+ type: 'object',
1012
+ additionalProperties: false,
1013
+ properties: {
1014
+ target: { type: 'string', required: true },
1015
+ since: { type: 'string', required: true },
1016
+ events: {
1017
+ type: 'array',
1018
+ required: true,
1019
+ items: {
1020
+ type: 'object',
1021
+ additionalProperties: false,
1022
+ properties: {
1023
+ action: { type: 'string', required: true },
1024
+ name: { type: 'string', required: true },
1025
+ image: { type: 'string', required: true },
1026
+ composeProject: { type: 'string' },
1027
+ time: { type: 'number' },
1028
+ exitCode: { type: 'number' },
1029
+ },
1030
+ },
1031
+ },
1032
+ },
1033
+ },
1034
+ render: (_args, value) => {
1035
+ const v = value;
1036
+ return [{ type: 'text', text: renderEvents(v.target ?? '?', v.since ?? '10m', v.events ?? []) }];
1037
+ },
1038
+ },
1039
+ async execute(args) {
1040
+ const input = (args ?? {});
1041
+ const picked = pickTarget(input.target);
1042
+ if (picked.name === undefined)
1043
+ throw new Error(picked.error ?? '无效的 target');
1044
+ // since 直接进 argv(不是 shell 字符串),但仍限制字符集:它会被拼进
1045
+ // docker 的命令行,留个 ';' 之类只会得到一个难懂的 docker 报错
1046
+ const since = typeof input.since === 'string' && input.since.trim() !== '' ? input.since.trim() : '10m';
1047
+ if (!/^[0-9]+(ns|us|ms|s|m|h)?$/.test(since) && !/^[0-9]{4}-[0-9]{2}-[0-9]{2}([T ][0-9:.]+(Z|[+-][0-9:]{2,5})?)?$/.test(since)) {
1048
+ throw new Error('since 只支持时长(如 30m、2h)或时间戳(如 2026-09-13T10:00:00)');
1049
+ }
1050
+ const { api } = apiFor(picked.name);
1051
+ if (api === undefined)
1052
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1053
+ const rows = await api.events(since);
1054
+ return {
1055
+ target: picked.name,
1056
+ since,
1057
+ events: rows.map((row) => ({
1058
+ action: row.action,
1059
+ name: row.name,
1060
+ image: row.image,
1061
+ ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
1062
+ ...(row.time === null ? {} : { time: row.time }),
1063
+ ...(row.exitCode === null ? {} : { exitCode: row.exitCode }),
1064
+ })),
1065
+ };
1066
+ },
1067
+ }));
794
1068
  add('docker_images', defineTool({
795
1069
  name: 'docker_images',
796
1070
  description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
@@ -842,6 +1116,146 @@ const plugin = definePlugin({
842
1116
  };
843
1117
  },
844
1118
  }));
1119
+ add('docker_image_inspect', defineTool({
1120
+ name: 'docker_image_inspect',
1121
+ description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
1122
+ parameters: {
1123
+ target: targetParam,
1124
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag、镜像 ID(sha256:…)或 digest' },
1125
+ },
1126
+ output: {
1127
+ schema: {
1128
+ type: 'object',
1129
+ additionalProperties: false,
1130
+ properties: {
1131
+ target: { type: 'string', required: true },
1132
+ ref: { type: 'string', required: true },
1133
+ detail: { type: 'string', required: true },
1134
+ },
1135
+ },
1136
+ render: (_args, value) => {
1137
+ const v = value;
1138
+ return [{ type: 'text', text: v.detail ?? '' }];
1139
+ },
1140
+ },
1141
+ async execute(args) {
1142
+ const input = (args ?? {});
1143
+ const picked = pickTarget(input.target);
1144
+ if (picked.name === undefined)
1145
+ throw new Error(picked.error ?? '无效的 target');
1146
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1147
+ throw new Error('ref 必填');
1148
+ const { api } = apiFor(picked.name);
1149
+ if (api === undefined)
1150
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1151
+ const image = await api.imageInspect(input.ref);
1152
+ return { target: picked.name, ref: image.ref, detail: renderImageDetail(image) };
1153
+ },
1154
+ }));
1155
+ add('docker_networks', defineTool({
1156
+ name: 'docker_networks',
1157
+ description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
1158
+ parameters: { target: targetParam },
1159
+ output: {
1160
+ schema: {
1161
+ type: 'object',
1162
+ additionalProperties: false,
1163
+ properties: {
1164
+ target: { type: 'string', required: true },
1165
+ networks: {
1166
+ type: 'array',
1167
+ required: true,
1168
+ items: {
1169
+ type: 'object',
1170
+ additionalProperties: false,
1171
+ properties: {
1172
+ name: { type: 'string', required: true },
1173
+ driver: { type: 'string', required: true },
1174
+ scope: { type: 'string', required: true },
1175
+ internal: { type: 'boolean' },
1176
+ id: { type: 'string', required: true },
1177
+ },
1178
+ },
1179
+ },
1180
+ },
1181
+ },
1182
+ render: (_args, value) => {
1183
+ const v = value;
1184
+ return [{ type: 'text', text: renderNetworks(v.target ?? '?', v.networks ?? []) }];
1185
+ },
1186
+ },
1187
+ async execute(args) {
1188
+ const input = (args ?? {});
1189
+ const picked = pickTarget(input.target);
1190
+ if (picked.name === undefined)
1191
+ throw new Error(picked.error ?? '无效的 target');
1192
+ const { api } = apiFor(picked.name);
1193
+ if (api === undefined)
1194
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1195
+ const rows = await api.networks();
1196
+ return {
1197
+ target: picked.name,
1198
+ networks: rows.map((row) => ({
1199
+ name: row.name,
1200
+ driver: row.driver,
1201
+ scope: row.scope,
1202
+ ...(row.internal ? { internal: true } : {}),
1203
+ id: row.shortId,
1204
+ })),
1205
+ };
1206
+ },
1207
+ }));
1208
+ add('docker_volumes', defineTool({
1209
+ name: 'docker_volumes',
1210
+ description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
1211
+ parameters: { target: targetParam },
1212
+ output: {
1213
+ schema: {
1214
+ type: 'object',
1215
+ additionalProperties: false,
1216
+ properties: {
1217
+ target: { type: 'string', required: true },
1218
+ volumes: {
1219
+ type: 'array',
1220
+ required: true,
1221
+ items: {
1222
+ type: 'object',
1223
+ additionalProperties: false,
1224
+ properties: {
1225
+ name: { type: 'string', required: true },
1226
+ driver: { type: 'string', required: true },
1227
+ scope: { type: 'string', required: true },
1228
+ mountpoint: { type: 'string' },
1229
+ },
1230
+ },
1231
+ },
1232
+ },
1233
+ },
1234
+ render: (_args, value) => {
1235
+ const v = value;
1236
+ return [{ type: 'text', text: renderVolumes(v.target ?? '?', v.volumes ?? []) }];
1237
+ },
1238
+ },
1239
+ async execute(args) {
1240
+ const input = (args ?? {});
1241
+ const picked = pickTarget(input.target);
1242
+ if (picked.name === undefined)
1243
+ throw new Error(picked.error ?? '无效的 target');
1244
+ const { api } = apiFor(picked.name);
1245
+ if (api === undefined)
1246
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1247
+ const rows = await api.volumes();
1248
+ return {
1249
+ target: picked.name,
1250
+ volumes: rows.map((row) => ({
1251
+ name: row.name,
1252
+ driver: row.driver,
1253
+ scope: row.scope,
1254
+ ...(row.mountpoint === '' ? {} : { mountpoint: row.mountpoint }),
1255
+ })),
1256
+ };
1257
+ },
1258
+ }));
845
1259
  if (live.allowMutations) {
846
1260
  add('docker_action', defineTool({
847
1261
  name: 'docker_action',
@@ -887,6 +1301,125 @@ const plugin = definePlugin({
887
1301
  return { target: picked.name, id: result.id, action: result.action, message: result.message };
888
1302
  },
889
1303
  }));
1304
+ add('docker_image_remove', defineTool({
1305
+ name: 'docker_image_remove',
1306
+ description: '删除一个镜像(docker image rm,不带 -f)。**破坏性**:镜像被容器或子镜像引用时会失败;执行前须向用户确认目标镜像,并复述后果(需要重新拉取或构建才能恢复)。仅当用户在设置里打开「允许变更操作」时可用。',
1307
+ parameters: {
1308
+ target: targetParam,
1309
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或镜像 ID(sha256:…)' },
1310
+ },
1311
+ output: {
1312
+ schema: {
1313
+ type: 'object',
1314
+ additionalProperties: false,
1315
+ properties: {
1316
+ target: { type: 'string', required: true },
1317
+ ref: { type: 'string', required: true },
1318
+ message: { type: 'string', required: true },
1319
+ },
1320
+ },
1321
+ render: (_args, value) => {
1322
+ const v = value;
1323
+ return [{ type: 'text', text: '已删除镜像 ' + String(v.ref ?? '?') + '(目标 ' + String(v.target ?? '?') + '):' + String(v.message ?? 'ok') }];
1324
+ },
1325
+ },
1326
+ async execute(args) {
1327
+ if (!live.allowMutations)
1328
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1329
+ const input = (args ?? {});
1330
+ const picked = pickTarget(input.target);
1331
+ if (picked.name === undefined)
1332
+ throw new Error(picked.error ?? '无效的 target');
1333
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1334
+ throw new Error('ref 必填');
1335
+ const { api } = apiFor(picked.name);
1336
+ if (api === undefined)
1337
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1338
+ const result = await api.imageRemove(input.ref);
1339
+ return { target: picked.name, ref: result.ref, message: result.message };
1340
+ },
1341
+ }));
1342
+ add('docker_image_prune', defineTool({
1343
+ name: 'docker_image_prune',
1344
+ description: '清理 dangling(无标签 <none>:<none>)镜像(docker image prune -f)。只删无标签镜像,不动有 tag 的镜像(刻意不加 --all,避免误删未使用的普通镜像)。返回删除列表与释放的空间。仅当用户在设置里打开「允许变更操作」时可用。',
1345
+ parameters: { target: targetParam },
1346
+ output: {
1347
+ schema: {
1348
+ type: 'object',
1349
+ additionalProperties: false,
1350
+ properties: {
1351
+ target: { type: 'string', required: true },
1352
+ message: { type: 'string', required: true },
1353
+ },
1354
+ },
1355
+ render: (_args, value) => {
1356
+ const v = value;
1357
+ return [{ type: 'text', text: '已清理 dangling 镜像(目标 ' + String(v.target ?? '?') + '):\n' + String(v.message ?? 'ok') }];
1358
+ },
1359
+ },
1360
+ async execute(args) {
1361
+ if (!live.allowMutations)
1362
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1363
+ const input = (args ?? {});
1364
+ const picked = pickTarget(input.target);
1365
+ if (picked.name === undefined)
1366
+ throw new Error(picked.error ?? '无效的 target');
1367
+ const { api } = apiFor(picked.name);
1368
+ if (api === undefined)
1369
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1370
+ const result = await api.imagePrune();
1371
+ return { target: picked.name, message: result.message };
1372
+ },
1373
+ }));
1374
+ add('docker_image_pull', defineTool({
1375
+ name: 'docker_image_pull',
1376
+ description: '拉取镜像(docker pull),例 nginx:1.27、ghcr.io/org/app:latest。**可能耗时数分钟**(逐层下载);可用 timeoutSec 调整上限(10~1800 秒,默认 600)。仅当用户在设置里打开「允许变更操作」时可用。交互式观察进度请让用户到面板镜像页的「拉取」里看 SSE 进度流。',
1377
+ parameters: {
1378
+ target: targetParam,
1379
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或 digest' },
1380
+ timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600)' },
1381
+ },
1382
+ output: {
1383
+ schema: {
1384
+ type: 'object',
1385
+ additionalProperties: false,
1386
+ properties: {
1387
+ target: { type: 'string', required: true },
1388
+ ref: { type: 'string', required: true },
1389
+ code: { type: 'number' },
1390
+ text: { type: 'string', required: true },
1391
+ truncated: { type: 'boolean' },
1392
+ },
1393
+ },
1394
+ render: (_args, value) => {
1395
+ const v = value;
1396
+ const head = '拉取 ' + String(v.ref ?? '?') + '(目标 ' + String(v.target ?? '?') + ')退出码 ' + String(v.code ?? '?') + (v.truncated === true ? ' · 输出已截断' : '') + ':\n\n';
1397
+ return [{ type: 'text', text: head + ((v.text ?? '') === '' ? '(无输出)' : (v.text ?? '')) }];
1398
+ },
1399
+ },
1400
+ async execute(args) {
1401
+ if (!live.allowMutations)
1402
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1403
+ const input = (args ?? {});
1404
+ const picked = pickTarget(input.target);
1405
+ if (picked.name === undefined)
1406
+ throw new Error(picked.error ?? '无效的 target');
1407
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1408
+ throw new Error('ref 必填');
1409
+ const { api } = apiFor(picked.name);
1410
+ if (api === undefined)
1411
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1412
+ const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : 600;
1413
+ const result = await api.pull(input.ref, timeoutSec * 1000);
1414
+ return {
1415
+ target: picked.name,
1416
+ ref: result.ref,
1417
+ ...(result.code === null ? {} : { code: result.code }),
1418
+ text: result.text,
1419
+ ...(result.truncated ? { truncated: true } : {}),
1420
+ };
1421
+ },
1422
+ }));
890
1423
  }
891
1424
  if (live.allowExec) {
892
1425
  add('docker_exec', defineTool({
@@ -993,6 +1526,276 @@ const plugin = definePlugin({
993
1526
  return;
994
1527
  announcementDispose = systemPrompt.section({ name: 'plugin:dsh-docker', order: 152, text: DOCKER_GUIDANCE });
995
1528
  };
1529
+ /* ---------- 日志实时流(SSE) ---------- */
1530
+ /**
1531
+ * GET /logs/stream — 容器日志实时流(`docker logs --follow` → SSE)。
1532
+ *
1533
+ * 事件协议(每帧 `event:` + 单行 JSON `data:`):
1534
+ * - `line` `{"d":"..."}` stdout 分片 / `{"e":"..."}` stderr 分片
1535
+ * - `end` `{"reason":"container-exit","code":N}` 容器停止、docker logs -f 自然退出
1536
+ * - `error` `{"message":"..."}` 后关闭(参数 / 执行失败)
1537
+ * 心跳:每 15s 一帧 `: ping` 注释;客户端断开则静默中止执行器(SIGTERM 阶梯 /
1538
+ * channel KILL),不写任何帧。安全语义与快照 /logs 一致:只读能力,不走
1539
+ * allowMutations / allowExec 门控;loopback 围栏与容器 ID 白名单在外层校验。
1540
+ */
1541
+ const serveLogsStream = async (req, res, params) => {
1542
+ const picked = pickTarget(params.get('target'));
1543
+ if (picked.name === undefined) {
1544
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1545
+ return;
1546
+ }
1547
+ const idParam = params.get('id');
1548
+ if (idParam === null || idParam.trim() === '') {
1549
+ writeJson(res, 400, { error: 'id 必填' });
1550
+ return;
1551
+ }
1552
+ let safeId;
1553
+ try {
1554
+ safeId = assertRef(idParam, 'container');
1555
+ }
1556
+ catch (error) {
1557
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1558
+ return;
1559
+ }
1560
+ const built = apiFor(picked.name);
1561
+ const api = built.api;
1562
+ if (api === undefined) {
1563
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1564
+ return;
1565
+ }
1566
+ const tailParam = params.get('tail');
1567
+ const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
1568
+ const timestampsParam = params.get('timestamps');
1569
+ const since = params.get('since');
1570
+ await openSseStream(res, {
1571
+ reason: 'container-exit',
1572
+ run: async (sendEvent, signal) => {
1573
+ // 事件协议与快照 /logs 完全一致,只多一个 end.reason
1574
+ const handlers = {
1575
+ onStdout: (chunk) => sendEvent('line', { d: chunk }),
1576
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1577
+ };
1578
+ const result = await api.logsStream(safeId, {
1579
+ // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
1580
+ tail: Number.isInteger(tail) ? tail : live.logTailDefault,
1581
+ timestamps: timestampsParam === '1' || timestampsParam === 'true',
1582
+ ...(since !== null && since.trim() !== '' ? { since } : {}),
1583
+ }, handlers, signal);
1584
+ return result.code;
1585
+ },
1586
+ });
1587
+ };
1588
+ /**
1589
+ * GET /stats/stream — 容器资源占用实时流(`docker stats` → SSE)。
1590
+ *
1591
+ * 与日志流的**本质差异**:这条流不会自然结束(容器在跑,docker stats 就每秒
1592
+ * 出一行),关闭语义是「浏览器主动断」——EventSource.close() → res close →
1593
+ * 静默 abort。docker stats 因全部容器退出而自行退出时,发 `end`
1594
+ * (reason=stats-exit)让客户端回到快照轮询。
1595
+ *
1596
+ * 事件:`stats` 每行一个 ContainerStats JSON;`end` / `error` 同日志流。
1597
+ * 只读能力:不受 allowMutations / allowExec 门控。
1598
+ */
1599
+ const serveStatsStream = async (req, res, params) => {
1600
+ const picked = pickTarget(params.get('target'));
1601
+ if (picked.name === undefined) {
1602
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1603
+ return;
1604
+ }
1605
+ const rawIds = params.get('ids');
1606
+ let safeIds;
1607
+ try {
1608
+ safeIds = (rawIds === null || rawIds.trim() === '' ? [] : rawIds.split(','))
1609
+ .map((id) => id.trim())
1610
+ .filter((id) => id !== '')
1611
+ .map((id) => assertRef(id, 'container'));
1612
+ }
1613
+ catch (error) {
1614
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1615
+ return;
1616
+ }
1617
+ const built = apiFor(picked.name);
1618
+ const api = built.api;
1619
+ if (api === undefined) {
1620
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1621
+ return;
1622
+ }
1623
+ await openSseStream(res, {
1624
+ reason: 'stats-exit',
1625
+ run: async (sendEvent, signal) => {
1626
+ /*
1627
+ * docker stats 的输出**不看 stdout 是不是 TTY**——它一律走 TTY 渲染器,
1628
+ * 每帧都被 ESC[H / ESC[K / ESC[J 包着,而且同一个采样会重复渲染两次:
1629
+ * \x1b[H{"CPUPerc":"0.04%",...}\n\x1b[H{"CPUPerc":"0.04%",...} \x1b[K\n \x1b[K\n\x1b[J
1630
+ * 所以不能按行解析:行首是 ESC 不是 `{`,parseJsonLines 会把整行跳过,
1631
+ * 结果就是「流建上了、一个采样都不来」。这里直接抽取扁平的 {...} 对象——
1632
+ * docker stats 的 {{json .}} 字段全是标量、没有嵌套括号,抽取是安全的——
1633
+ * 再用与 /stats 快照同一个 parseStatsJson 归一,客户端拿到的形状与快照一致。
1634
+ * 同一 chunk 内的重复采样(键相同)只发一次,免得环形缓冲被重复点占掉半窗。
1635
+ */
1636
+ let pending = '';
1637
+ /*
1638
+ * 跨 chunk 去重:实测 docker stats 会把**同一个采样渲染两次**(约 500ms 一轮,
1639
+ * 0.04 / 0.04 / 0.16 / 0.16 …),两次是逐字节相同的 JSON 且可能落在不同 chunk 里。
1640
+ * 不去重的话,客户端 60 点环形缓冲会被重复点占掉一半窗口(实际只剩 ~30 秒)。
1641
+ * 用「与上一条完全相同的原始对象」作为去重键:值确实没变的相邻采样会少一个点,
1642
+ * 但那一个点的值与上一点相同,趋势形状不受影响,时间轴反而更接近真实采样间隔。
1643
+ */
1644
+ let lastRaw = '';
1645
+ const handlers = {
1646
+ onStdout: (chunk) => {
1647
+ pending += chunk;
1648
+ const re = /\{[^{}]*\}/g;
1649
+ const found = [];
1650
+ let consumed = 0;
1651
+ let hit;
1652
+ while ((hit = re.exec(pending)) !== null) {
1653
+ found.push(hit[0]);
1654
+ consumed = hit.index + hit[0].length;
1655
+ }
1656
+ // 只丢已消费的前缀:半截 JSON(未闭合的对象)留给下一个 chunk
1657
+ if (consumed > 0)
1658
+ pending = pending.slice(consumed);
1659
+ // 对方吐的不是我们认识的输出时别让缓冲无限涨(最多留 4KB 尾巴)
1660
+ if (pending.length > 64 * 1024)
1661
+ pending = pending.slice(-4096);
1662
+ for (const raw of found) {
1663
+ if (raw === lastRaw)
1664
+ continue;
1665
+ lastRaw = raw;
1666
+ for (const row of parseStatsJson(raw))
1667
+ sendEvent('stats', row);
1668
+ }
1669
+ },
1670
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1671
+ };
1672
+ const result = await api.statsStream(safeIds, handlers, signal);
1673
+ // 收尾:最后一个对象可能还没闭合(进程退出前只写了半行),能解析就发
1674
+ if (pending !== '')
1675
+ for (const row of parseStatsJson(pending))
1676
+ sendEvent('stats', row);
1677
+ return result.code;
1678
+ },
1679
+ });
1680
+ };
1681
+ /**
1682
+ * GET /events/stream — 容器事件活动流(`docker events` → SSE)。
1683
+ *
1684
+ * 与统计流同构:**不会自然结束**(docker events 会一直跟着 daemon 推),关闭
1685
+ * 语义是浏览器主动断;docker events 自己退出时发 `end`(reason=events-exit)。
1686
+ *
1687
+ * 帧:`event` 一条一个 ContainerEvent(已在服务端过白名单,见 docker.ts 的
1688
+ * EVENT_ACTIONS);`line{e}` 透传 stderr;`end` / `error` 与其它流一致。
1689
+ * 只读能力:不受 allowMutations / allowExec 门控。
1690
+ */
1691
+ const serveEventsStream = async (req, res, params) => {
1692
+ const picked = pickTarget(params.get('target'));
1693
+ if (picked.name === undefined) {
1694
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1695
+ return;
1696
+ }
1697
+ const built = apiFor(picked.name);
1698
+ const api = built.api;
1699
+ if (api === undefined) {
1700
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1701
+ return;
1702
+ }
1703
+ await openSseStream(res, {
1704
+ reason: 'events-exit',
1705
+ run: async (sendEvent, signal) => {
1706
+ /*
1707
+ * docker events 与 docker stats 不同:输出是老老实实一行一个 JSON,没有 TTY
1708
+ * 渲染器的转义码,所以按行合帧即可(stats 那边才需要抽扁平对象)。这里**不能**
1709
+ * 学 stats 做「相同行去重」——两条内容完全一样的 health_status 是两次真实事件,
1710
+ * 去重会把活动条吞掉一半。
1711
+ */
1712
+ let pending = '';
1713
+ /** 帧里省略值为 null 的字段(agent 工具那边同样处理),客户端按缺失判断。 */
1714
+ const frame = (event) => ({
1715
+ action: event.action,
1716
+ name: event.name,
1717
+ image: event.image,
1718
+ ...(event.composeProject === null ? {} : { composeProject: event.composeProject }),
1719
+ ...(event.time === null ? {} : { time: event.time }),
1720
+ ...(event.exitCode === null ? {} : { exitCode: event.exitCode }),
1721
+ });
1722
+ const handlers = {
1723
+ onStdout: (chunk) => {
1724
+ pending += chunk;
1725
+ const parts = pending.split('\n');
1726
+ pending = parts.pop() ?? '';
1727
+ for (const line of parts) {
1728
+ const event = parseContainerEvent(line);
1729
+ if (event !== null)
1730
+ sendEvent('event', frame(event));
1731
+ }
1732
+ // 对方吐的不是我们认识的输出时别让缓冲无限涨(最多留 4KB 尾巴)
1733
+ if (pending.length > 64 * 1024)
1734
+ pending = pending.slice(-4096);
1735
+ },
1736
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1737
+ };
1738
+ const result = await api.eventsStream(handlers, signal);
1739
+ // 收尾:最后一行可能没换行符(进程退出前只写半行),能解析就发
1740
+ if (pending !== '') {
1741
+ const tail = parseContainerEvent(pending);
1742
+ if (tail !== null)
1743
+ sendEvent('event', frame(tail));
1744
+ }
1745
+ return result.code;
1746
+ },
1747
+ });
1748
+ };
1749
+ /**
1750
+ * GET /images/pull/stream — 镜像拉取进度流(`docker pull` → SSE)。
1751
+ *
1752
+ * **变更能力**:拉取会写入目标机的镜像存储、占用磁盘与带宽,因此与
1753
+ * /action、/images/remove、/images/prune 同一把 allowMutations 门(未开启
1754
+ * 时 403,且不建流)。逐层进度天然是流,直接复用 ssh-exec 的长流通道;
1755
+ * 事件与日志流同构(line{d|e} / end{reason:pull-exit,code,ref} / error)。
1756
+ */
1757
+ const servePullStream = async (req, res, params) => {
1758
+ if (!live.allowMutations) {
1759
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
1760
+ return;
1761
+ }
1762
+ const picked = pickTarget(params.get('target'));
1763
+ if (picked.name === undefined) {
1764
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1765
+ return;
1766
+ }
1767
+ const refParam = params.get('ref');
1768
+ if (refParam === null || refParam.trim() === '') {
1769
+ writeJson(res, 400, { error: 'ref 必填' });
1770
+ return;
1771
+ }
1772
+ let safeRef;
1773
+ try {
1774
+ safeRef = assertImageRef(refParam, 'image');
1775
+ }
1776
+ catch (error) {
1777
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1778
+ return;
1779
+ }
1780
+ const built = apiFor(picked.name);
1781
+ const api = built.api;
1782
+ if (api === undefined) {
1783
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1784
+ return;
1785
+ }
1786
+ await openSseStream(res, {
1787
+ reason: 'pull-exit',
1788
+ endData: () => ({ ref: safeRef }),
1789
+ run: async (sendEvent, signal) => {
1790
+ const handlers = {
1791
+ onStdout: (chunk) => sendEvent('line', { d: chunk }),
1792
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1793
+ };
1794
+ const result = await api.pullStream(safeRef, handlers, signal);
1795
+ return result.code;
1796
+ },
1797
+ });
1798
+ };
996
1799
  /* ---------- HTTP 路由(loopback 围栏) ---------- */
997
1800
  ctx.inject(['webServer'], (webCtx) => {
998
1801
  webCtx.effect(() => {
@@ -1084,6 +1887,27 @@ const plugin = definePlugin({
1084
1887
  });
1085
1888
  return;
1086
1889
  }
1890
+ // SSE 长连接(GET):必须放在下面「非 POST 一律 405」之前,否则
1891
+ // GET 会被 method 检查拦掉。四条流共用 openSseStream,各自只做参数
1892
+ // 校验与执行器接线;响应由对应的 serve*Stream 长持有。
1893
+ const serveStream = sub === '/logs/stream'
1894
+ ? serveLogsStream
1895
+ : sub === '/stats/stream'
1896
+ ? serveStatsStream
1897
+ : sub === '/events/stream'
1898
+ ? serveEventsStream
1899
+ : sub === '/images/pull/stream'
1900
+ ? servePullStream
1901
+ : undefined;
1902
+ if (serveStream !== undefined) {
1903
+ if (req.method !== 'GET') {
1904
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1905
+ return;
1906
+ }
1907
+ const params = new URL(req.url ?? '/', 'http://loopback').searchParams;
1908
+ await serveStream(req, res, params);
1909
+ return;
1910
+ }
1087
1911
  if (req.method !== 'POST') {
1088
1912
  writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1089
1913
  return;
@@ -1147,6 +1971,98 @@ const plugin = definePlugin({
1147
1971
  writeJson(res, 200, { ok: true, images: await api.images() });
1148
1972
  return;
1149
1973
  }
1974
+ case '/images/inspect': {
1975
+ if (typeof body.ref !== 'string' || body.ref.trim() === '') {
1976
+ writeJson(res, 400, { error: 'ref 必填' });
1977
+ return;
1978
+ }
1979
+ writeJson(res, 200, { ok: true, image: await api.imageInspect(body.ref) });
1980
+ return;
1981
+ }
1982
+ case '/images/remove': {
1983
+ if (!live.allowMutations) {
1984
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
1985
+ return;
1986
+ }
1987
+ if (typeof body.ref !== 'string' || body.ref.trim() === '') {
1988
+ writeJson(res, 400, { error: 'ref 必填' });
1989
+ return;
1990
+ }
1991
+ writeJson(res, 200, { ok: true, result: await api.imageRemove(body.ref) });
1992
+ return;
1993
+ }
1994
+ case '/images/prune': {
1995
+ if (!live.allowMutations) {
1996
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
1997
+ return;
1998
+ }
1999
+ writeJson(res, 200, { ok: true, result: await api.imagePrune() });
2000
+ return;
2001
+ }
2002
+ case '/networks': {
2003
+ writeJson(res, 200, { ok: true, networks: await api.networks() });
2004
+ return;
2005
+ }
2006
+ case '/networks/inspect': {
2007
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2008
+ writeJson(res, 400, { error: 'name 必填' });
2009
+ return;
2010
+ }
2011
+ writeJson(res, 200, { ok: true, network: await api.networkInspect(body.name) });
2012
+ return;
2013
+ }
2014
+ case '/networks/remove': {
2015
+ if (!live.allowMutations) {
2016
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2017
+ return;
2018
+ }
2019
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2020
+ writeJson(res, 400, { error: 'name 必填' });
2021
+ return;
2022
+ }
2023
+ writeJson(res, 200, { ok: true, result: await api.networkRemove(body.name) });
2024
+ return;
2025
+ }
2026
+ case '/networks/prune': {
2027
+ if (!live.allowMutations) {
2028
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2029
+ return;
2030
+ }
2031
+ writeJson(res, 200, { ok: true, result: await api.networkPrune() });
2032
+ return;
2033
+ }
2034
+ case '/volumes': {
2035
+ writeJson(res, 200, { ok: true, volumes: await api.volumes() });
2036
+ return;
2037
+ }
2038
+ case '/volumes/inspect': {
2039
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2040
+ writeJson(res, 400, { error: 'name 必填' });
2041
+ return;
2042
+ }
2043
+ writeJson(res, 200, { ok: true, volume: await api.volumeInspect(body.name) });
2044
+ return;
2045
+ }
2046
+ case '/volumes/remove': {
2047
+ if (!live.allowMutations) {
2048
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2049
+ return;
2050
+ }
2051
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2052
+ writeJson(res, 400, { error: 'name 必填' });
2053
+ return;
2054
+ }
2055
+ writeJson(res, 200, { ok: true, result: await api.volumeRemove(body.name) });
2056
+ return;
2057
+ }
2058
+ case '/volumes/prune': {
2059
+ if (!live.allowMutations) {
2060
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2061
+ return;
2062
+ }
2063
+ writeJson(res, 200, { ok: true, result: await api.volumePrune() });
2064
+ return;
2065
+ }
1150
2066
  case '/action': {
1151
2067
  if (!live.allowMutations) {
1152
2068
  writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
@@ -1240,6 +2156,8 @@ const plugin = definePlugin({
1240
2156
  /* ---------- 卸载清理 ---------- */
1241
2157
  ctx.effect(() => {
1242
2158
  return () => {
2159
+ // 先收掉长流(结束响应 + 中止 docker logs -f),再关连接池
2160
+ closeAllStreams();
1243
2161
  remote.disposeAll();
1244
2162
  };
1245
2163
  }, 'dsh-docker: cleanup');
@@ -1250,5 +2168,5 @@ export const { name, inject, apply } = plugin;
1250
2168
  /* ------------------------------------------------------------------ *
1251
2169
  * 供 scripts/smoke.mjs 直接复用的纯函数(解析器回归)
1252
2170
  * ------------------------------------------------------------------ */
1253
- export { parsePsJson, parseStatsJson, parseInspectJson, assertBin, DockerApi };
2171
+ export { parsePsJson, parseStatsJson, parseInspectJson, assertBin, assertImageRef, parseImageInspectJson, parseImageHistoryJson, parseImageHistoryText, DockerApi };
1254
2172
  //# sourceMappingURL=index.js.map