@hyzyn/dsh-docker 0.3.3 → 0.5.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 项目与服务;**target 传 `*` 可一次列出所有目标**)、docker_attention(**需关注汇总**:unhealthy / 反复重启 / OOM / 非零退出 / 僵死,同样支持 `*` 跨目标)、docker_inspect(容器详情)、docker_logs(日志快照)、docker_stats(CPU/内存/IO 快照)、docker_images(镜像列表)、docker_image_inspect(镜像详情 + 构建历史)、docker_events(容器事件快照,见面板容器列表的「活动」条)、docker_networks(网络列表)、docker_volumes(卷列表);排障推荐顺序:不确定从哪台/哪个容器看起时先 docker_attention(可 `*` 跨目标)→ 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();
@@ -409,6 +444,65 @@ const plugin = definePlugin({
409
444
  return { error: '尚未配置任何 Docker 目标(设置 → 插件 → Docker 容器面板)' };
410
445
  return { error: 'target 必填(已配置多个目标:' + list.map((item) => item.name).join('、') + ')' };
411
446
  };
447
+ /**
448
+ * 跨目标聚合(0.15.0):所有目标的容器 / 需关注列表在面板与 agent 工具里共用同
449
+ * 一套逻辑。三个必须的性质:
450
+ * - **并发上限**:ssh exec 扇出太多会互相挤(远端 sshd MaxStartups / 本机 fd);
451
+ * - **单目标超时**:一台网络不通不能把整个聚合页拖住;
452
+ * - **错误隔离**:失败的组带上 error 照常返回,其余目标的结果照常可用。
453
+ */
454
+ const AGG_CONCURRENCY = 4;
455
+ const AGG_TIMEOUT_MS = 45_000;
456
+ async function mapLimit(items, limit, run) {
457
+ const results = new Array(items.length);
458
+ let cursor = 0;
459
+ const workers = new Array(Math.min(Math.max(limit, 1), Math.max(items.length, 1))).fill(null).map(async () => {
460
+ for (;;) {
461
+ const index = cursor;
462
+ cursor += 1;
463
+ if (index >= items.length)
464
+ return;
465
+ results[index] = await run(items[index], index);
466
+ }
467
+ });
468
+ await Promise.all(workers);
469
+ return results;
470
+ }
471
+ /** 在所有(或指定)目标上跑同一件事,返回按目标分组的「部分成功」结果。 */
472
+ const aggregateAcrossTargets = async (names, run) => {
473
+ return await mapLimit(names, AGG_CONCURRENCY, async (name) => {
474
+ const built = apiFor(name);
475
+ const resolved = built.resolved;
476
+ const label = resolved === undefined
477
+ ? name
478
+ : (resolved.kind === 'local' ? '本机' : sshTarget(resolved.spec));
479
+ if (built.api === undefined)
480
+ return { target: name, label, ok: false, error: built.error ?? '无法构造执行通道' };
481
+ const api = built.api;
482
+ let timer = null;
483
+ try {
484
+ const timeout = new Promise((_resolve, reject) => {
485
+ timer = setTimeout(() => reject(new Error(`聚合超时(>${String(AGG_TIMEOUT_MS / 1000)}s)`)), AGG_TIMEOUT_MS);
486
+ timer.unref?.();
487
+ });
488
+ const data = await Promise.race([run(api, name), timeout]);
489
+ return { target: name, label, ok: true, data };
490
+ }
491
+ catch (error) {
492
+ return { target: name, label, ok: false, error: error instanceof Error ? error.message : String(error) };
493
+ }
494
+ finally {
495
+ if (timer !== null)
496
+ clearTimeout(timer);
497
+ }
498
+ });
499
+ };
500
+ /** 目标名列表:'*' / 空 表示全部(保持配置顺序)。 */
501
+ const targetsFor = (input) => {
502
+ if (typeof input === 'string' && input.trim() !== '' && input.trim() !== '*')
503
+ return [input.trim()];
504
+ return targetsNow().map((item) => item.name);
505
+ };
412
506
  /* ---------- 配置快照(凭证不外泄) ---------- */
413
507
  const snapshotTarget = (target) => ({
414
508
  name: target.name,
@@ -444,8 +538,126 @@ const plugin = definePlugin({
444
538
  toolsRegistered: registeredNames,
445
539
  };
446
540
  };
541
+ /* ---------- 活跃 SSE 长流清点 ---------- */
542
+ /**
543
+ * 活跃的 SSE 长流句柄(日志 / 统计 / 拉取三条流共用)。流本身由 HTTP handler
544
+ * 持有并自清理;这里登记一份,供插件禁用 / 配置热更新 / 卸载时统一收尾
545
+ * (end + abort 执行器)。句柄是每个插件实例一份(不共享模块级集合),
546
+ * 多实例挂载时不会互相误杀。
547
+ */
548
+ const activeStreams = new Set();
549
+ const closeAllStreams = () => {
550
+ for (const stream of [...activeStreams]) {
551
+ try {
552
+ stream.end();
553
+ }
554
+ catch {
555
+ /* 连接已断开 */
556
+ }
557
+ }
558
+ activeStreams.clear();
559
+ };
560
+ /* ---------- 通用 SSE 长连接(三条流共用一份基建) ---------- */
561
+ /**
562
+ * 通用 SSE 长连接。日志流(`docker logs -f`)、统计流(`docker stats`)、
563
+ * 拉取流(`docker pull`)三者只差「执行器 + 结束原因 + end 附加字段」,
564
+ * 其余全部共用这一份实现:
565
+ *
566
+ * - 响应头(text/event-stream / no-cache / keep-alive / x-accel-buffering)
567
+ * 统一在这里写,写完立即 flushHeaders(宿主 gzip 对 SSE 显式跳过);
568
+ * - 每 15s 一帧 `: ping` 注释心跳(SSE 规范里客户端忽略);
569
+ * - 活跃流登记:插件禁用 / 配置热更新 / 卸载时由 closeAllStreams 统一收尾;
570
+ * - 客户端断开 → 静默清理(不写任何帧)+ abort 执行器;
571
+ * - 执行器正常退出 → `end` 帧(带 reason / code / 附加字段)后关闭响应;
572
+ * - 执行器抛错 → `error` 帧后关闭响应。
573
+ *
574
+ * 调用方负责「参数校验」——校验失败时写常规 JSON(4xx)并直接返回,不建流。
575
+ */
576
+ const openSseStream = async (res, options) => {
577
+ const write = res.write?.bind(res);
578
+ const flushHeaders = res.flushHeaders?.bind(res);
579
+ if (write === undefined || flushHeaders === undefined) {
580
+ writeJson(res, 500, { error: '宿主响应不支持 SSE 长连接' });
581
+ return;
582
+ }
583
+ res.writeHead(200, {
584
+ 'content-type': 'text/event-stream; charset=utf-8',
585
+ 'cache-control': 'no-cache',
586
+ connection: 'keep-alive',
587
+ 'referrer-policy': 'no-referrer',
588
+ // 反代(nginx 等)默认缓冲响应体:不关掉的话流要等缓冲区满才到浏览器
589
+ 'x-accel-buffering': 'no',
590
+ });
591
+ flushHeaders();
592
+ const controller = new AbortController();
593
+ let done = false;
594
+ let heartbeat = null;
595
+ const stopHeartbeat = () => {
596
+ if (heartbeat === null)
597
+ return;
598
+ clearInterval(heartbeat);
599
+ heartbeat = null;
600
+ };
601
+ /** 客户端断开 / 写失败:静默清理(不写帧),中止执行器。 */
602
+ const clientGone = () => {
603
+ if (done)
604
+ return;
605
+ done = true;
606
+ stopHeartbeat();
607
+ activeStreams.delete(handle);
608
+ controller.abort();
609
+ };
610
+ /** 服务端主动收尾(正常结束 / 出错 / 插件禁用 / 卸载):关响应。 */
611
+ const finish = () => {
612
+ if (done)
613
+ return;
614
+ done = true;
615
+ stopHeartbeat();
616
+ activeStreams.delete(handle);
617
+ controller.abort();
618
+ try {
619
+ res.end();
620
+ }
621
+ catch {
622
+ /* 连接已断开 */
623
+ }
624
+ };
625
+ const send = (frame) => {
626
+ if (done)
627
+ return;
628
+ try {
629
+ write(frame);
630
+ }
631
+ catch {
632
+ // 写失败 = 连接已断:与 res close 同一收尾路径
633
+ clientGone();
634
+ }
635
+ };
636
+ const sendEvent = (event, data) => send(sseFrame(event, data));
637
+ const handle = { end: finish };
638
+ activeStreams.add(handle);
639
+ res.on?.('close', clientGone);
640
+ heartbeat = setInterval(() => send(': ping\n\n'), SSE_HEARTBEAT_MS);
641
+ heartbeat.unref?.();
642
+ try {
643
+ const code = await options.run(sendEvent, controller.signal);
644
+ if (done)
645
+ return;
646
+ sendEvent('end', { reason: options.reason, code, ...(options.endData?.(code) ?? {}) });
647
+ finish();
648
+ }
649
+ catch (error) {
650
+ if (done)
651
+ return;
652
+ sendEvent('error', { message: error instanceof Error ? error.message : String(error) });
653
+ finish();
654
+ }
655
+ };
447
656
  /* ---------- 配置热应用 ---------- */
448
657
  const applySection = (section) => {
658
+ // 目标 / 凭证 / 启用态都可能变:已开的日志流按旧配置在跑,先统一收尾;
659
+ // 浏览器侧 EventSource 会自动重连(服务端已停机就停在断开态)
660
+ closeAllStreams();
449
661
  // hostKeys 只在显式传入时覆盖(避免把 TOFU 运行期新增的记录冲掉)
450
662
  const merged = { ...live, ...section };
451
663
  if (section.hostKeys === undefined)
@@ -467,6 +679,55 @@ const plugin = definePlugin({
467
679
  return `\n- ${row.name} (${row.kind}) ${row.label}${state}`;
468
680
  }).join('');
469
681
  };
682
+ /** 跨目标容器渲染:按目标分组,失败的目标单独一行说明(部分成功也要可读)。 */
683
+ const renderAggregatedContainers = (groups) => {
684
+ if (groups.length === 0)
685
+ return '尚未配置任何 Docker 目标(设置 → 插件 → Docker 容器面板)。';
686
+ const total = groups.reduce((sum, group) => sum + (group.containers?.length ?? 0), 0);
687
+ const failed = groups.filter((group) => !group.ok);
688
+ const head = `所有目标共 ${String(total)} 个容器(${String(groups.length)} 个目标${failed.length > 0 ? `,${String(failed.length)} 个不可达` : ''}):`;
689
+ return head + groups.map((group) => {
690
+ if (!group.ok)
691
+ return `\n\n■ ${group.target}(${group.label})— 不可用:${group.error ?? '未知错误'}`;
692
+ const rows = group.containers ?? [];
693
+ return `\n\n■ ${group.target}(${group.label})— ${String(rows.length)} 个容器` + rows.map((row) => {
694
+ const ports = row.ports.length === 0
695
+ ? ''
696
+ : ' ports=' + row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(',');
697
+ const health = row.health === null ? '' : ` health=${row.health}`;
698
+ return `\n- ${row.name} [${row.state}]${health} image=${row.image}${ports} id=${row.shortId}`;
699
+ }).join('');
700
+ }).join('');
701
+ };
702
+ const ATTENTION_LABEL = {
703
+ unhealthy: '不健康',
704
+ restarting: '反复重启',
705
+ oom: '被 OOM 杀',
706
+ 'exit-nonzero': '非零退出',
707
+ dead: '僵死',
708
+ };
709
+ /** 需关注列表渲染(单目标 / 跨目标共用)。 */
710
+ const renderAttention = (groups) => {
711
+ const total = groups.reduce((sum, group) => sum + (group.items?.length ?? 0), 0);
712
+ if (total === 0 && groups.every((group) => group.ok))
713
+ return '所有目标上没有需要关注的容器(无 unhealthy / 重启中 / OOM / 非零退出)。';
714
+ return `需关注容器共 ${String(total)} 个:` + groups.map((group) => {
715
+ if (!group.ok)
716
+ return `\n\n■ ${group.target}(${group.label})— 不可用:${group.error ?? '未知错误'}`;
717
+ const items = group.items ?? [];
718
+ if (items.length === 0)
719
+ return `\n\n■ ${group.target}(${group.label})— 无异常`;
720
+ return `\n\n■ ${group.target}(${group.label})` + items.map((item) => {
721
+ const reasons = item.reasons.map((reason) => ATTENTION_LABEL[reason] ?? reason).join(' + ');
722
+ const extra = [
723
+ item.exitCode === null ? '' : `exit=${String(item.exitCode)}`,
724
+ item.restartCount === null ? '' : `restarts=${String(item.restartCount)}`,
725
+ item.oomKilled ? 'OOMKilled=true' : '',
726
+ ].filter((part) => part !== '').join(' ');
727
+ return `\n- ${item.name} [${item.state}${item.health === null ? '' : '/' + item.health}] ${reasons} image=${item.image}${extra === '' ? '' : ' ' + extra} id=${item.shortId}`;
728
+ }).join('');
729
+ }).join('');
730
+ };
470
731
  const renderContainers = (target, rows) => {
471
732
  if (rows.length === 0)
472
733
  return `目标 ${target}:没有容器。`;
@@ -484,6 +745,16 @@ const plugin = definePlugin({
484
745
  return `目标 ${target}:没有运行中的容器。`;
485
746
  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
747
  };
748
+ const renderEvents = (target, since, rows) => {
749
+ if (rows.length === 0)
750
+ return `目标 ${target}:最近 ${since} 没有容器事件。`;
751
+ return `目标 ${target} 的容器事件(最近 ${since},${String(rows.length)} 条):` + rows.map((row) => {
752
+ const at = row.time === null ? '--:--:--' : formatEventTime(row.time);
753
+ const exit = row.exitCode === null ? '' : ` exit=${String(row.exitCode)}`;
754
+ const compose = row.composeProject === null ? '' : ` compose=${row.composeProject}`;
755
+ return `\n- ${at} ${row.name} ${row.action}${exit}${compose} image=${row.image}`;
756
+ }).join('');
757
+ };
487
758
  const renderDetail = (detail) => {
488
759
  const lines = [
489
760
  `容器 ${detail.name}(${detail.shortId})`,
@@ -500,11 +771,56 @@ const plugin = definePlugin({
500
771
  lines.push(`- 最近健康检查:${detail.healthLogTail}`);
501
772
  return lines.join('\n');
502
773
  };
774
+ const renderNetworks = (target, rows) => {
775
+ if (rows.length === 0)
776
+ return `目标 ${target}:没有网络。`;
777
+ return `目标 ${target} 的网络(${String(rows.length)} 个):` + rows.map((row) => {
778
+ const internal = row.internal ? ' internal=true' : '';
779
+ return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${internal} id=${row.shortId}`;
780
+ }).join('');
781
+ };
782
+ const renderVolumes = (target, rows) => {
783
+ if (rows.length === 0)
784
+ return `目标 ${target}:没有卷。`;
785
+ return `目标 ${target} 的卷(${String(rows.length)} 个):` + rows.map((row) => {
786
+ const mount = row.mountpoint === '' ? '' : ` mount=${row.mountpoint}`;
787
+ return `\n- ${row.name} driver=${row.driver} scope=${row.scope}${mount}`;
788
+ }).join('');
789
+ };
503
790
  const renderImages = (target, rows) => {
504
791
  if (rows.length === 0)
505
792
  return `目标 ${target}:没有镜像。`;
506
793
  return `目标 ${target} 的镜像(${String(rows.length)} 个):` + rows.map((row) => `\n- ${row.reference} ${row.sizeText}${row.createdSince === '' ? '' : ' (' + row.createdSince + ')'} id=${row.shortId}`).join('');
507
794
  };
795
+ /** 镜像详情 + 构建历史(agent 工具 docker_image_inspect 的可读渲染)。 */
796
+ const renderImageDetail = (payload) => {
797
+ const d = payload.detail;
798
+ const title = d.repoTags.length === 0 ? `<none>(${d.shortId})` : d.repoTags.join(', ');
799
+ const lines = [
800
+ `镜像 ${title}`,
801
+ `- ID:${d.id}`,
802
+ `- 大小:${d.size === null ? '—' : formatBytes(d.size)}${d.virtualSize === null || d.virtualSize === d.size ? '' : `(含父层 ${formatBytes(d.virtualSize)})`}`,
803
+ `- 创建:${d.created === '' ? '—' : d.created}`,
804
+ `- 平台:${d.os === '' && d.architecture === '' ? '—' : `${d.os}/${d.architecture}`}`,
805
+ `- 层:${String(d.layerCount)} 层`,
806
+ `- 入口:${(d.entrypoint + ' ' + d.command).trim() || '—'}`,
807
+ ...(d.exposedPorts.length === 0 ? [] : [`- 暴露端口:${d.exposedPorts.join(', ')}`]),
808
+ ...(d.repoDigests.length === 0 ? [] : [`- digest:${d.repoDigests.join(', ')}`]),
809
+ ];
810
+ if (payload.historyError !== null)
811
+ lines.push(`- 构建历史:读取失败(${payload.historyError})`);
812
+ else if (payload.history.length === 0)
813
+ lines.push('- 构建历史:无');
814
+ else {
815
+ lines.push(`- 构建历史(${String(payload.history.length)} 步,从新到旧):`);
816
+ for (const step of payload.history.slice(0, 30)) {
817
+ lines.push(` · ${step.shortId} ${step.createdSince} ${step.sizeText} ${step.createdBy.slice(0, 160)}`);
818
+ }
819
+ if (payload.history.length > 30)
820
+ lines.push(` …(其余 ${String(payload.history.length - 30)} 步略)`);
821
+ }
822
+ return lines.join('\n');
823
+ };
508
824
  /** 重新注册 agent 工具(能力开关变化时调用;幂等)。 */
509
825
  const refreshTools = () => {
510
826
  for (const dispose of toolDisposers) {
@@ -587,17 +903,19 @@ const plugin = definePlugin({
587
903
  }));
588
904
  add('docker_ps', defineTool({
589
905
  name: 'docker_ps',
590
- description: '列出某个目标上的容器(默认只列运行中的;all:true 含已停止)。返回名称/状态/健康/镜像/端口/compose 项目/短 ID。排障第一步。',
591
- parameters: { target: targetParam, all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' } },
906
+ description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。',
907
+ parameters: {
908
+ target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
909
+ all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' },
910
+ },
592
911
  output: {
593
912
  schema: {
594
913
  type: 'object',
595
914
  additionalProperties: false,
596
915
  properties: {
597
- target: { type: 'string', required: true },
916
+ target: { type: 'string' },
598
917
  containers: {
599
918
  type: 'array',
600
- required: true,
601
919
  items: {
602
920
  type: 'object',
603
921
  additionalProperties: false,
@@ -614,15 +932,73 @@ const plugin = definePlugin({
614
932
  },
615
933
  },
616
934
  },
935
+ groups: {
936
+ type: 'array',
937
+ items: {
938
+ type: 'object',
939
+ additionalProperties: false,
940
+ properties: {
941
+ target: { type: 'string', required: true },
942
+ label: { type: 'string', required: true },
943
+ ok: { type: 'boolean', required: true },
944
+ error: { type: 'string' },
945
+ containers: {
946
+ type: 'array',
947
+ items: {
948
+ type: 'object',
949
+ additionalProperties: false,
950
+ properties: {
951
+ id: { type: 'string', required: true },
952
+ name: { type: 'string', required: true },
953
+ image: { type: 'string', required: true },
954
+ state: { type: 'string', required: true },
955
+ status: { type: 'string', required: true },
956
+ health: { type: 'string' },
957
+ ports: { type: 'string' },
958
+ composeProject: { type: 'string' },
959
+ composeService: { type: 'string' },
960
+ },
961
+ },
962
+ },
963
+ },
964
+ },
965
+ },
617
966
  },
618
967
  },
619
968
  render: (_args, value) => {
620
969
  const v = value;
970
+ if (Array.isArray(v.groups))
971
+ return [{ type: 'text', text: renderAggregatedContainers(v.groups) }];
621
972
  return [{ type: 'text', text: renderContainers(v.target ?? '?', v.containers ?? []) }];
622
973
  },
623
974
  },
624
975
  async execute(args) {
625
976
  const input = (args ?? {});
977
+ const isAll = typeof input.target === 'string' && input.target.trim() === '*';
978
+ const toRow = (row) => ({
979
+ id: row.id,
980
+ name: row.name,
981
+ image: row.image,
982
+ state: row.state,
983
+ status: row.status,
984
+ ...(row.health === null ? {} : { health: row.health }),
985
+ ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
986
+ ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
987
+ ...(row.composeService === null ? {} : { composeService: row.composeService }),
988
+ });
989
+ if (isAll || (input.target === undefined && targetsNow().length > 1)) {
990
+ const names = targetsFor('*');
991
+ const collected = await aggregateAcrossTargets(names, (targetApi) => targetApi.listContainers(input.all === true));
992
+ return {
993
+ groups: collected.map((group) => ({
994
+ target: group.target,
995
+ label: group.label,
996
+ ok: group.ok,
997
+ ...(group.error === undefined ? {} : { error: group.error }),
998
+ ...(group.data === undefined ? {} : { containers: group.data.map(toRow) }),
999
+ })),
1000
+ };
1001
+ }
626
1002
  const picked = pickTarget(input.target);
627
1003
  if (picked.name === undefined)
628
1004
  throw new Error(picked.error ?? '无效的 target');
@@ -630,20 +1006,116 @@ const plugin = definePlugin({
630
1006
  if (api === undefined)
631
1007
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
632
1008
  const containers = await api.listContainers(input.all === true);
633
- return {
634
- target: picked.name,
635
- containers: containers.map((row) => ({
636
- id: row.id,
637
- name: row.name,
638
- image: row.image,
639
- state: row.state,
640
- status: row.status,
641
- ...(row.health === null ? {} : { health: row.health }),
642
- ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
643
- ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
644
- ...(row.composeService === null ? {} : { composeService: row.composeService }),
645
- })),
646
- };
1009
+ return { target: picked.name, containers: containers.map(toRow) };
1010
+ },
1011
+ }));
1012
+ add('docker_attention', defineTool({
1013
+ name: 'docker_attention',
1014
+ description: '列出「需要关注」的容器:不健康(unhealthy)/ 反复重启 / 被 OOM 杀 / 非零退出 / 僵死。target 传 `*` 时**跨所有目标聚合**(单目标不可达不影响其他目标)。排障入口:不确定从哪台机器看起时先调它。',
1015
+ parameters: {
1016
+ target: { type: 'string', description: '目标名;传 `*` 表示全部目标(docker_targets 列出)' },
1017
+ limit: { type: 'number', description: '每个目标最多返回多少条(1~500,默认 100)' },
1018
+ },
1019
+ output: {
1020
+ schema: {
1021
+ type: 'object',
1022
+ additionalProperties: false,
1023
+ properties: {
1024
+ target: { type: 'string' },
1025
+ items: {
1026
+ type: 'array',
1027
+ items: {
1028
+ type: 'object',
1029
+ additionalProperties: false,
1030
+ properties: {
1031
+ id: { type: 'string', required: true },
1032
+ name: { type: 'string', required: true },
1033
+ image: { type: 'string', required: true },
1034
+ state: { type: 'string', required: true },
1035
+ health: { type: 'string' },
1036
+ reasons: { type: 'array', required: true, items: { type: 'string' } },
1037
+ exitCode: { type: 'number' },
1038
+ oomKilled: { type: 'boolean' },
1039
+ restartCount: { type: 'number' },
1040
+ },
1041
+ },
1042
+ },
1043
+ groups: {
1044
+ type: 'array',
1045
+ items: {
1046
+ type: 'object',
1047
+ additionalProperties: false,
1048
+ properties: {
1049
+ target: { type: 'string', required: true },
1050
+ label: { type: 'string', required: true },
1051
+ ok: { type: 'boolean', required: true },
1052
+ error: { type: 'string' },
1053
+ items: {
1054
+ type: 'array',
1055
+ items: {
1056
+ type: 'object',
1057
+ additionalProperties: false,
1058
+ properties: {
1059
+ id: { type: 'string', required: true },
1060
+ name: { type: 'string', required: true },
1061
+ image: { type: 'string', required: true },
1062
+ state: { type: 'string', required: true },
1063
+ health: { type: 'string' },
1064
+ reasons: { type: 'array', required: true, items: { type: 'string' } },
1065
+ exitCode: { type: 'number' },
1066
+ oomKilled: { type: 'boolean' },
1067
+ restartCount: { type: 'number' },
1068
+ },
1069
+ },
1070
+ },
1071
+ },
1072
+ },
1073
+ },
1074
+ },
1075
+ },
1076
+ render: (_args, value) => {
1077
+ const v = value;
1078
+ if (Array.isArray(v.groups))
1079
+ return [{ type: 'text', text: renderAttention(v.groups) }];
1080
+ return [{ type: 'text', text: renderAttention([{ target: v.target ?? '?', label: v.target ?? '?', ok: true, items: v.items ?? [] }]) }];
1081
+ },
1082
+ },
1083
+ async execute(args) {
1084
+ const input = (args ?? {});
1085
+ const limit = typeof input.limit === 'number' && Number.isInteger(input.limit) ? input.limit : undefined;
1086
+ const toRow = (item) => ({
1087
+ id: item.shortId,
1088
+ name: item.name,
1089
+ image: item.image,
1090
+ state: item.state,
1091
+ ...(item.health === null ? {} : { health: item.health }),
1092
+ reasons: item.reasons,
1093
+ ...(item.exitCode === null ? {} : { exitCode: item.exitCode }),
1094
+ oomKilled: item.oomKilled,
1095
+ ...(item.restartCount === null ? {} : { restartCount: item.restartCount }),
1096
+ });
1097
+ const isAll = typeof input.target === 'string' && input.target.trim() === '*';
1098
+ if (isAll || (input.target === undefined && targetsNow().length > 1)) {
1099
+ const names = targetsFor('*');
1100
+ const collected = await aggregateAcrossTargets(names, (targetApi) => targetApi.attention(limit === undefined ? undefined : { limit }));
1101
+ return {
1102
+ groups: collected.map((group) => ({
1103
+ target: group.target,
1104
+ label: group.label,
1105
+ ok: group.ok,
1106
+ ...(group.error === undefined ? {} : { error: group.error }),
1107
+ ...(group.data === undefined ? {} : { items: group.data.map(toRow) }),
1108
+ })),
1109
+ };
1110
+ }
1111
+ const picked = pickTarget(input.target);
1112
+ if (picked.name === undefined)
1113
+ throw new Error(picked.error ?? '无效的 target');
1114
+ const { api } = apiFor(picked.name);
1115
+ if (api === undefined)
1116
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1117
+ const items = await api.attention(limit === undefined ? undefined : { limit });
1118
+ return { target: picked.name, items: items.map(toRow) };
647
1119
  },
648
1120
  }));
649
1121
  add('docker_inspect', defineTool({
@@ -791,6 +1263,72 @@ const plugin = definePlugin({
791
1263
  };
792
1264
  },
793
1265
  }));
1266
+ add('docker_events', defineTool({
1267
+ name: 'docker_events',
1268
+ description: '读取某个目标最近的容器事件(docker events 快照):start / die / stop / kill / oom / health_status / destroy / rename / update 八类,已过滤掉 exec_* 等噪音。默认看最近 10m。要持续观察请让用户打开面板容器列表的「活动」条。',
1269
+ parameters: {
1270
+ target: targetParam,
1271
+ since: { type: 'string', description: '起始时间(docker --since 语法,如 30m、2h;默认 10m)' },
1272
+ },
1273
+ output: {
1274
+ schema: {
1275
+ type: 'object',
1276
+ additionalProperties: false,
1277
+ properties: {
1278
+ target: { type: 'string', required: true },
1279
+ since: { type: 'string', required: true },
1280
+ events: {
1281
+ type: 'array',
1282
+ required: true,
1283
+ items: {
1284
+ type: 'object',
1285
+ additionalProperties: false,
1286
+ properties: {
1287
+ action: { type: 'string', required: true },
1288
+ name: { type: 'string', required: true },
1289
+ image: { type: 'string', required: true },
1290
+ composeProject: { type: 'string' },
1291
+ time: { type: 'number' },
1292
+ exitCode: { type: 'number' },
1293
+ },
1294
+ },
1295
+ },
1296
+ },
1297
+ },
1298
+ render: (_args, value) => {
1299
+ const v = value;
1300
+ return [{ type: 'text', text: renderEvents(v.target ?? '?', v.since ?? '10m', v.events ?? []) }];
1301
+ },
1302
+ },
1303
+ async execute(args) {
1304
+ const input = (args ?? {});
1305
+ const picked = pickTarget(input.target);
1306
+ if (picked.name === undefined)
1307
+ throw new Error(picked.error ?? '无效的 target');
1308
+ // since 直接进 argv(不是 shell 字符串),但仍限制字符集:它会被拼进
1309
+ // docker 的命令行,留个 ';' 之类只会得到一个难懂的 docker 报错
1310
+ const since = typeof input.since === 'string' && input.since.trim() !== '' ? input.since.trim() : '10m';
1311
+ 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)) {
1312
+ throw new Error('since 只支持时长(如 30m、2h)或时间戳(如 2026-09-13T10:00:00)');
1313
+ }
1314
+ const { api } = apiFor(picked.name);
1315
+ if (api === undefined)
1316
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1317
+ const rows = await api.events(since);
1318
+ return {
1319
+ target: picked.name,
1320
+ since,
1321
+ events: rows.map((row) => ({
1322
+ action: row.action,
1323
+ name: row.name,
1324
+ image: row.image,
1325
+ ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
1326
+ ...(row.time === null ? {} : { time: row.time }),
1327
+ ...(row.exitCode === null ? {} : { exitCode: row.exitCode }),
1328
+ })),
1329
+ };
1330
+ },
1331
+ }));
794
1332
  add('docker_images', defineTool({
795
1333
  name: 'docker_images',
796
1334
  description: '列出某个目标上的镜像(仓库:标签、大小、创建时间、短 ID)。',
@@ -842,30 +1380,170 @@ const plugin = definePlugin({
842
1380
  };
843
1381
  },
844
1382
  }));
845
- if (live.allowMutations) {
846
- add('docker_action', defineTool({
847
- name: 'docker_action',
848
- description: '对容器执行生命周期操作:start / stop / restart / remove。**破坏性**:remove 会删除容器(数据卷不在其中,但容器配置与可写层丢失),执行前必须向用户确认目标容器。仅当用户在设置里打开「允许变更操作」时可用。',
849
- parameters: {
850
- target: targetParam,
851
- action: { type: 'string', required: true, description: 'start | stop | restart | remove' },
852
- id: { type: 'string', required: true, description: '容器名或 ID' },
1383
+ add('docker_image_inspect', defineTool({
1384
+ name: 'docker_image_inspect',
1385
+ description: '读取某个镜像的详情(docker image inspect)与构建历史(docker history):大小 / 创建时间 / 平台 / 层数与层列表 / 入口与命令 / 暴露端口 / digest / 每步构建命令与大小。',
1386
+ parameters: {
1387
+ target: targetParam,
1388
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag、镜像 ID(sha256:…)或 digest' },
1389
+ },
1390
+ output: {
1391
+ schema: {
1392
+ type: 'object',
1393
+ additionalProperties: false,
1394
+ properties: {
1395
+ target: { type: 'string', required: true },
1396
+ ref: { type: 'string', required: true },
1397
+ detail: { type: 'string', required: true },
1398
+ },
853
1399
  },
854
- output: {
855
- schema: {
856
- type: 'object',
857
- additionalProperties: false,
858
- properties: {
859
- target: { type: 'string', required: true },
860
- id: { type: 'string', required: true },
861
- action: { type: 'string', required: true },
862
- message: { type: 'string', required: true },
1400
+ render: (_args, value) => {
1401
+ const v = value;
1402
+ return [{ type: 'text', text: v.detail ?? '' }];
1403
+ },
1404
+ },
1405
+ async execute(args) {
1406
+ const input = (args ?? {});
1407
+ const picked = pickTarget(input.target);
1408
+ if (picked.name === undefined)
1409
+ throw new Error(picked.error ?? '无效的 target');
1410
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1411
+ throw new Error('ref 必填');
1412
+ const { api } = apiFor(picked.name);
1413
+ if (api === undefined)
1414
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1415
+ const image = await api.imageInspect(input.ref);
1416
+ return { target: picked.name, ref: image.ref, detail: renderImageDetail(image) };
1417
+ },
1418
+ }));
1419
+ add('docker_networks', defineTool({
1420
+ name: 'docker_networks',
1421
+ description: '列出某个目标上的 docker 网络(名称 / 驱动 / 范围 / 是否 internal / 短 ID)。接入的容器列表要进详情页看,不在列表里逐条 inspect。',
1422
+ parameters: { target: targetParam },
1423
+ output: {
1424
+ schema: {
1425
+ type: 'object',
1426
+ additionalProperties: false,
1427
+ properties: {
1428
+ target: { type: 'string', required: true },
1429
+ networks: {
1430
+ type: 'array',
1431
+ required: true,
1432
+ items: {
1433
+ type: 'object',
1434
+ additionalProperties: false,
1435
+ properties: {
1436
+ name: { type: 'string', required: true },
1437
+ driver: { type: 'string', required: true },
1438
+ scope: { type: 'string', required: true },
1439
+ internal: { type: 'boolean' },
1440
+ id: { type: 'string', required: true },
1441
+ },
1442
+ },
863
1443
  },
864
1444
  },
865
- render: (_args, value) => {
866
- const v = value;
867
- return [{ type: 'text', text: `${v.action ?? '?'} ${v.id ?? '?'}(目标 ${v.target ?? '?'}):${v.message ?? 'ok'}` }];
868
- },
1445
+ },
1446
+ render: (_args, value) => {
1447
+ const v = value;
1448
+ return [{ type: 'text', text: renderNetworks(v.target ?? '?', v.networks ?? []) }];
1449
+ },
1450
+ },
1451
+ async execute(args) {
1452
+ const input = (args ?? {});
1453
+ const picked = pickTarget(input.target);
1454
+ if (picked.name === undefined)
1455
+ throw new Error(picked.error ?? '无效的 target');
1456
+ const { api } = apiFor(picked.name);
1457
+ if (api === undefined)
1458
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1459
+ const rows = await api.networks();
1460
+ return {
1461
+ target: picked.name,
1462
+ networks: rows.map((row) => ({
1463
+ name: row.name,
1464
+ driver: row.driver,
1465
+ scope: row.scope,
1466
+ ...(row.internal ? { internal: true } : {}),
1467
+ id: row.shortId,
1468
+ })),
1469
+ };
1470
+ },
1471
+ }));
1472
+ add('docker_volumes', defineTool({
1473
+ name: 'docker_volumes',
1474
+ description: '列出某个目标上的 docker 卷(名称 / 驱动 / 范围 / 挂载点)。',
1475
+ parameters: { target: targetParam },
1476
+ output: {
1477
+ schema: {
1478
+ type: 'object',
1479
+ additionalProperties: false,
1480
+ properties: {
1481
+ target: { type: 'string', required: true },
1482
+ volumes: {
1483
+ type: 'array',
1484
+ required: true,
1485
+ items: {
1486
+ type: 'object',
1487
+ additionalProperties: false,
1488
+ properties: {
1489
+ name: { type: 'string', required: true },
1490
+ driver: { type: 'string', required: true },
1491
+ scope: { type: 'string', required: true },
1492
+ mountpoint: { type: 'string' },
1493
+ },
1494
+ },
1495
+ },
1496
+ },
1497
+ },
1498
+ render: (_args, value) => {
1499
+ const v = value;
1500
+ return [{ type: 'text', text: renderVolumes(v.target ?? '?', v.volumes ?? []) }];
1501
+ },
1502
+ },
1503
+ async execute(args) {
1504
+ const input = (args ?? {});
1505
+ const picked = pickTarget(input.target);
1506
+ if (picked.name === undefined)
1507
+ throw new Error(picked.error ?? '无效的 target');
1508
+ const { api } = apiFor(picked.name);
1509
+ if (api === undefined)
1510
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1511
+ const rows = await api.volumes();
1512
+ return {
1513
+ target: picked.name,
1514
+ volumes: rows.map((row) => ({
1515
+ name: row.name,
1516
+ driver: row.driver,
1517
+ scope: row.scope,
1518
+ ...(row.mountpoint === '' ? {} : { mountpoint: row.mountpoint }),
1519
+ })),
1520
+ };
1521
+ },
1522
+ }));
1523
+ if (live.allowMutations) {
1524
+ add('docker_action', defineTool({
1525
+ name: 'docker_action',
1526
+ description: '对容器执行生命周期操作:start / stop / restart / remove。**破坏性**:remove 会删除容器(数据卷不在其中,但容器配置与可写层丢失),执行前必须向用户确认目标容器。仅当用户在设置里打开「允许变更操作」时可用。',
1527
+ parameters: {
1528
+ target: targetParam,
1529
+ action: { type: 'string', required: true, description: 'start | stop | restart | remove' },
1530
+ id: { type: 'string', required: true, description: '容器名或 ID' },
1531
+ },
1532
+ output: {
1533
+ schema: {
1534
+ type: 'object',
1535
+ additionalProperties: false,
1536
+ properties: {
1537
+ target: { type: 'string', required: true },
1538
+ id: { type: 'string', required: true },
1539
+ action: { type: 'string', required: true },
1540
+ message: { type: 'string', required: true },
1541
+ },
1542
+ },
1543
+ render: (_args, value) => {
1544
+ const v = value;
1545
+ return [{ type: 'text', text: `${v.action ?? '?'} ${v.id ?? '?'}(目标 ${v.target ?? '?'}):${v.message ?? 'ok'}` }];
1546
+ },
869
1547
  },
870
1548
  async execute(args) {
871
1549
  if (!live.allowMutations)
@@ -887,6 +1565,125 @@ const plugin = definePlugin({
887
1565
  return { target: picked.name, id: result.id, action: result.action, message: result.message };
888
1566
  },
889
1567
  }));
1568
+ add('docker_image_remove', defineTool({
1569
+ name: 'docker_image_remove',
1570
+ description: '删除一个镜像(docker image rm,不带 -f)。**破坏性**:镜像被容器或子镜像引用时会失败;执行前须向用户确认目标镜像,并复述后果(需要重新拉取或构建才能恢复)。仅当用户在设置里打开「允许变更操作」时可用。',
1571
+ parameters: {
1572
+ target: targetParam,
1573
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或镜像 ID(sha256:…)' },
1574
+ },
1575
+ output: {
1576
+ schema: {
1577
+ type: 'object',
1578
+ additionalProperties: false,
1579
+ properties: {
1580
+ target: { type: 'string', required: true },
1581
+ ref: { type: 'string', required: true },
1582
+ message: { type: 'string', required: true },
1583
+ },
1584
+ },
1585
+ render: (_args, value) => {
1586
+ const v = value;
1587
+ return [{ type: 'text', text: '已删除镜像 ' + String(v.ref ?? '?') + '(目标 ' + String(v.target ?? '?') + '):' + String(v.message ?? 'ok') }];
1588
+ },
1589
+ },
1590
+ async execute(args) {
1591
+ if (!live.allowMutations)
1592
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1593
+ const input = (args ?? {});
1594
+ const picked = pickTarget(input.target);
1595
+ if (picked.name === undefined)
1596
+ throw new Error(picked.error ?? '无效的 target');
1597
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1598
+ throw new Error('ref 必填');
1599
+ const { api } = apiFor(picked.name);
1600
+ if (api === undefined)
1601
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1602
+ const result = await api.imageRemove(input.ref);
1603
+ return { target: picked.name, ref: result.ref, message: result.message };
1604
+ },
1605
+ }));
1606
+ add('docker_image_prune', defineTool({
1607
+ name: 'docker_image_prune',
1608
+ description: '清理 dangling(无标签 <none>:<none>)镜像(docker image prune -f)。只删无标签镜像,不动有 tag 的镜像(刻意不加 --all,避免误删未使用的普通镜像)。返回删除列表与释放的空间。仅当用户在设置里打开「允许变更操作」时可用。',
1609
+ parameters: { target: targetParam },
1610
+ output: {
1611
+ schema: {
1612
+ type: 'object',
1613
+ additionalProperties: false,
1614
+ properties: {
1615
+ target: { type: 'string', required: true },
1616
+ message: { type: 'string', required: true },
1617
+ },
1618
+ },
1619
+ render: (_args, value) => {
1620
+ const v = value;
1621
+ return [{ type: 'text', text: '已清理 dangling 镜像(目标 ' + String(v.target ?? '?') + '):\n' + String(v.message ?? 'ok') }];
1622
+ },
1623
+ },
1624
+ async execute(args) {
1625
+ if (!live.allowMutations)
1626
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1627
+ const input = (args ?? {});
1628
+ const picked = pickTarget(input.target);
1629
+ if (picked.name === undefined)
1630
+ throw new Error(picked.error ?? '无效的 target');
1631
+ const { api } = apiFor(picked.name);
1632
+ if (api === undefined)
1633
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1634
+ const result = await api.imagePrune();
1635
+ return { target: picked.name, message: result.message };
1636
+ },
1637
+ }));
1638
+ add('docker_image_pull', defineTool({
1639
+ name: 'docker_image_pull',
1640
+ description: '拉取镜像(docker pull),例 nginx:1.27、ghcr.io/org/app:latest。**可能耗时数分钟**(逐层下载);可用 timeoutSec 调整上限(10~1800 秒,默认 600)。仅当用户在设置里打开「允许变更操作」时可用。交互式观察进度请让用户到面板镜像页的「拉取」里看 SSE 进度流。',
1641
+ parameters: {
1642
+ target: targetParam,
1643
+ ref: { type: 'string', required: true, description: '镜像引用:repository:tag 或 digest' },
1644
+ timeoutSec: { type: 'number', description: '超时秒数(10~1800,默认 600)' },
1645
+ },
1646
+ output: {
1647
+ schema: {
1648
+ type: 'object',
1649
+ additionalProperties: false,
1650
+ properties: {
1651
+ target: { type: 'string', required: true },
1652
+ ref: { type: 'string', required: true },
1653
+ code: { type: 'number' },
1654
+ text: { type: 'string', required: true },
1655
+ truncated: { type: 'boolean' },
1656
+ },
1657
+ },
1658
+ render: (_args, value) => {
1659
+ const v = value;
1660
+ const head = '拉取 ' + String(v.ref ?? '?') + '(目标 ' + String(v.target ?? '?') + ')退出码 ' + String(v.code ?? '?') + (v.truncated === true ? ' · 输出已截断' : '') + ':\n\n';
1661
+ return [{ type: 'text', text: head + ((v.text ?? '') === '' ? '(无输出)' : (v.text ?? '')) }];
1662
+ },
1663
+ },
1664
+ async execute(args) {
1665
+ if (!live.allowMutations)
1666
+ throw new Error('变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)');
1667
+ const input = (args ?? {});
1668
+ const picked = pickTarget(input.target);
1669
+ if (picked.name === undefined)
1670
+ throw new Error(picked.error ?? '无效的 target');
1671
+ if (typeof input.ref !== 'string' || input.ref.trim() === '')
1672
+ throw new Error('ref 必填');
1673
+ const { api } = apiFor(picked.name);
1674
+ if (api === undefined)
1675
+ throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
1676
+ const timeoutSec = typeof input.timeoutSec === 'number' && Number.isInteger(input.timeoutSec) ? input.timeoutSec : 600;
1677
+ const result = await api.pull(input.ref, timeoutSec * 1000);
1678
+ return {
1679
+ target: picked.name,
1680
+ ref: result.ref,
1681
+ ...(result.code === null ? {} : { code: result.code }),
1682
+ text: result.text,
1683
+ ...(result.truncated ? { truncated: true } : {}),
1684
+ };
1685
+ },
1686
+ }));
890
1687
  }
891
1688
  if (live.allowExec) {
892
1689
  add('docker_exec', defineTool({
@@ -993,6 +1790,276 @@ const plugin = definePlugin({
993
1790
  return;
994
1791
  announcementDispose = systemPrompt.section({ name: 'plugin:dsh-docker', order: 152, text: DOCKER_GUIDANCE });
995
1792
  };
1793
+ /* ---------- 日志实时流(SSE) ---------- */
1794
+ /**
1795
+ * GET /logs/stream — 容器日志实时流(`docker logs --follow` → SSE)。
1796
+ *
1797
+ * 事件协议(每帧 `event:` + 单行 JSON `data:`):
1798
+ * - `line` `{"d":"..."}` stdout 分片 / `{"e":"..."}` stderr 分片
1799
+ * - `end` `{"reason":"container-exit","code":N}` 容器停止、docker logs -f 自然退出
1800
+ * - `error` `{"message":"..."}` 后关闭(参数 / 执行失败)
1801
+ * 心跳:每 15s 一帧 `: ping` 注释;客户端断开则静默中止执行器(SIGTERM 阶梯 /
1802
+ * channel KILL),不写任何帧。安全语义与快照 /logs 一致:只读能力,不走
1803
+ * allowMutations / allowExec 门控;loopback 围栏与容器 ID 白名单在外层校验。
1804
+ */
1805
+ const serveLogsStream = async (req, res, params) => {
1806
+ const picked = pickTarget(params.get('target'));
1807
+ if (picked.name === undefined) {
1808
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1809
+ return;
1810
+ }
1811
+ const idParam = params.get('id');
1812
+ if (idParam === null || idParam.trim() === '') {
1813
+ writeJson(res, 400, { error: 'id 必填' });
1814
+ return;
1815
+ }
1816
+ let safeId;
1817
+ try {
1818
+ safeId = assertRef(idParam, 'container');
1819
+ }
1820
+ catch (error) {
1821
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1822
+ return;
1823
+ }
1824
+ const built = apiFor(picked.name);
1825
+ const api = built.api;
1826
+ if (api === undefined) {
1827
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1828
+ return;
1829
+ }
1830
+ const tailParam = params.get('tail');
1831
+ const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
1832
+ const timestampsParam = params.get('timestamps');
1833
+ const since = params.get('since');
1834
+ await openSseStream(res, {
1835
+ reason: 'container-exit',
1836
+ run: async (sendEvent, signal) => {
1837
+ // 事件协议与快照 /logs 完全一致,只多一个 end.reason
1838
+ const handlers = {
1839
+ onStdout: (chunk) => sendEvent('line', { d: chunk }),
1840
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1841
+ };
1842
+ const result = await api.logsStream(safeId, {
1843
+ // 与 POST /logs 同一条取值规则:非法/越界交给 DockerApi 内的夹紧
1844
+ tail: Number.isInteger(tail) ? tail : live.logTailDefault,
1845
+ timestamps: timestampsParam === '1' || timestampsParam === 'true',
1846
+ ...(since !== null && since.trim() !== '' ? { since } : {}),
1847
+ }, handlers, signal);
1848
+ return result.code;
1849
+ },
1850
+ });
1851
+ };
1852
+ /**
1853
+ * GET /stats/stream — 容器资源占用实时流(`docker stats` → SSE)。
1854
+ *
1855
+ * 与日志流的**本质差异**:这条流不会自然结束(容器在跑,docker stats 就每秒
1856
+ * 出一行),关闭语义是「浏览器主动断」——EventSource.close() → res close →
1857
+ * 静默 abort。docker stats 因全部容器退出而自行退出时,发 `end`
1858
+ * (reason=stats-exit)让客户端回到快照轮询。
1859
+ *
1860
+ * 事件:`stats` 每行一个 ContainerStats JSON;`end` / `error` 同日志流。
1861
+ * 只读能力:不受 allowMutations / allowExec 门控。
1862
+ */
1863
+ const serveStatsStream = async (req, res, params) => {
1864
+ const picked = pickTarget(params.get('target'));
1865
+ if (picked.name === undefined) {
1866
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1867
+ return;
1868
+ }
1869
+ const rawIds = params.get('ids');
1870
+ let safeIds;
1871
+ try {
1872
+ safeIds = (rawIds === null || rawIds.trim() === '' ? [] : rawIds.split(','))
1873
+ .map((id) => id.trim())
1874
+ .filter((id) => id !== '')
1875
+ .map((id) => assertRef(id, 'container'));
1876
+ }
1877
+ catch (error) {
1878
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
1879
+ return;
1880
+ }
1881
+ const built = apiFor(picked.name);
1882
+ const api = built.api;
1883
+ if (api === undefined) {
1884
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1885
+ return;
1886
+ }
1887
+ await openSseStream(res, {
1888
+ reason: 'stats-exit',
1889
+ run: async (sendEvent, signal) => {
1890
+ /*
1891
+ * docker stats 的输出**不看 stdout 是不是 TTY**——它一律走 TTY 渲染器,
1892
+ * 每帧都被 ESC[H / ESC[K / ESC[J 包着,而且同一个采样会重复渲染两次:
1893
+ * \x1b[H{"CPUPerc":"0.04%",...}\n\x1b[H{"CPUPerc":"0.04%",...} \x1b[K\n \x1b[K\n\x1b[J
1894
+ * 所以不能按行解析:行首是 ESC 不是 `{`,parseJsonLines 会把整行跳过,
1895
+ * 结果就是「流建上了、一个采样都不来」。这里直接抽取扁平的 {...} 对象——
1896
+ * docker stats 的 {{json .}} 字段全是标量、没有嵌套括号,抽取是安全的——
1897
+ * 再用与 /stats 快照同一个 parseStatsJson 归一,客户端拿到的形状与快照一致。
1898
+ * 同一 chunk 内的重复采样(键相同)只发一次,免得环形缓冲被重复点占掉半窗。
1899
+ */
1900
+ let pending = '';
1901
+ /*
1902
+ * 跨 chunk 去重:实测 docker stats 会把**同一个采样渲染两次**(约 500ms 一轮,
1903
+ * 0.04 / 0.04 / 0.16 / 0.16 …),两次是逐字节相同的 JSON 且可能落在不同 chunk 里。
1904
+ * 不去重的话,客户端 60 点环形缓冲会被重复点占掉一半窗口(实际只剩 ~30 秒)。
1905
+ * 用「与上一条完全相同的原始对象」作为去重键:值确实没变的相邻采样会少一个点,
1906
+ * 但那一个点的值与上一点相同,趋势形状不受影响,时间轴反而更接近真实采样间隔。
1907
+ */
1908
+ let lastRaw = '';
1909
+ const handlers = {
1910
+ onStdout: (chunk) => {
1911
+ pending += chunk;
1912
+ const re = /\{[^{}]*\}/g;
1913
+ const found = [];
1914
+ let consumed = 0;
1915
+ let hit;
1916
+ while ((hit = re.exec(pending)) !== null) {
1917
+ found.push(hit[0]);
1918
+ consumed = hit.index + hit[0].length;
1919
+ }
1920
+ // 只丢已消费的前缀:半截 JSON(未闭合的对象)留给下一个 chunk
1921
+ if (consumed > 0)
1922
+ pending = pending.slice(consumed);
1923
+ // 对方吐的不是我们认识的输出时别让缓冲无限涨(最多留 4KB 尾巴)
1924
+ if (pending.length > 64 * 1024)
1925
+ pending = pending.slice(-4096);
1926
+ for (const raw of found) {
1927
+ if (raw === lastRaw)
1928
+ continue;
1929
+ lastRaw = raw;
1930
+ for (const row of parseStatsJson(raw))
1931
+ sendEvent('stats', row);
1932
+ }
1933
+ },
1934
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
1935
+ };
1936
+ const result = await api.statsStream(safeIds, handlers, signal);
1937
+ // 收尾:最后一个对象可能还没闭合(进程退出前只写了半行),能解析就发
1938
+ if (pending !== '')
1939
+ for (const row of parseStatsJson(pending))
1940
+ sendEvent('stats', row);
1941
+ return result.code;
1942
+ },
1943
+ });
1944
+ };
1945
+ /**
1946
+ * GET /events/stream — 容器事件活动流(`docker events` → SSE)。
1947
+ *
1948
+ * 与统计流同构:**不会自然结束**(docker events 会一直跟着 daemon 推),关闭
1949
+ * 语义是浏览器主动断;docker events 自己退出时发 `end`(reason=events-exit)。
1950
+ *
1951
+ * 帧:`event` 一条一个 ContainerEvent(已在服务端过白名单,见 docker.ts 的
1952
+ * EVENT_ACTIONS);`line{e}` 透传 stderr;`end` / `error` 与其它流一致。
1953
+ * 只读能力:不受 allowMutations / allowExec 门控。
1954
+ */
1955
+ const serveEventsStream = async (req, res, params) => {
1956
+ const picked = pickTarget(params.get('target'));
1957
+ if (picked.name === undefined) {
1958
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
1959
+ return;
1960
+ }
1961
+ const built = apiFor(picked.name);
1962
+ const api = built.api;
1963
+ if (api === undefined) {
1964
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
1965
+ return;
1966
+ }
1967
+ await openSseStream(res, {
1968
+ reason: 'events-exit',
1969
+ run: async (sendEvent, signal) => {
1970
+ /*
1971
+ * docker events 与 docker stats 不同:输出是老老实实一行一个 JSON,没有 TTY
1972
+ * 渲染器的转义码,所以按行合帧即可(stats 那边才需要抽扁平对象)。这里**不能**
1973
+ * 学 stats 做「相同行去重」——两条内容完全一样的 health_status 是两次真实事件,
1974
+ * 去重会把活动条吞掉一半。
1975
+ */
1976
+ let pending = '';
1977
+ /** 帧里省略值为 null 的字段(agent 工具那边同样处理),客户端按缺失判断。 */
1978
+ const frame = (event) => ({
1979
+ action: event.action,
1980
+ name: event.name,
1981
+ image: event.image,
1982
+ ...(event.composeProject === null ? {} : { composeProject: event.composeProject }),
1983
+ ...(event.time === null ? {} : { time: event.time }),
1984
+ ...(event.exitCode === null ? {} : { exitCode: event.exitCode }),
1985
+ });
1986
+ const handlers = {
1987
+ onStdout: (chunk) => {
1988
+ pending += chunk;
1989
+ const parts = pending.split('\n');
1990
+ pending = parts.pop() ?? '';
1991
+ for (const line of parts) {
1992
+ const event = parseContainerEvent(line);
1993
+ if (event !== null)
1994
+ sendEvent('event', frame(event));
1995
+ }
1996
+ // 对方吐的不是我们认识的输出时别让缓冲无限涨(最多留 4KB 尾巴)
1997
+ if (pending.length > 64 * 1024)
1998
+ pending = pending.slice(-4096);
1999
+ },
2000
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
2001
+ };
2002
+ const result = await api.eventsStream(handlers, signal);
2003
+ // 收尾:最后一行可能没换行符(进程退出前只写半行),能解析就发
2004
+ if (pending !== '') {
2005
+ const tail = parseContainerEvent(pending);
2006
+ if (tail !== null)
2007
+ sendEvent('event', frame(tail));
2008
+ }
2009
+ return result.code;
2010
+ },
2011
+ });
2012
+ };
2013
+ /**
2014
+ * GET /images/pull/stream — 镜像拉取进度流(`docker pull` → SSE)。
2015
+ *
2016
+ * **变更能力**:拉取会写入目标机的镜像存储、占用磁盘与带宽,因此与
2017
+ * /action、/images/remove、/images/prune 同一把 allowMutations 门(未开启
2018
+ * 时 403,且不建流)。逐层进度天然是流,直接复用 ssh-exec 的长流通道;
2019
+ * 事件与日志流同构(line{d|e} / end{reason:pull-exit,code,ref} / error)。
2020
+ */
2021
+ const servePullStream = async (req, res, params) => {
2022
+ if (!live.allowMutations) {
2023
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2024
+ return;
2025
+ }
2026
+ const picked = pickTarget(params.get('target'));
2027
+ if (picked.name === undefined) {
2028
+ writeJson(res, 400, { error: picked.error ?? '无效的 target' });
2029
+ return;
2030
+ }
2031
+ const refParam = params.get('ref');
2032
+ if (refParam === null || refParam.trim() === '') {
2033
+ writeJson(res, 400, { error: 'ref 必填' });
2034
+ return;
2035
+ }
2036
+ let safeRef;
2037
+ try {
2038
+ safeRef = assertImageRef(refParam, 'image');
2039
+ }
2040
+ catch (error) {
2041
+ writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
2042
+ return;
2043
+ }
2044
+ const built = apiFor(picked.name);
2045
+ const api = built.api;
2046
+ if (api === undefined) {
2047
+ writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
2048
+ return;
2049
+ }
2050
+ await openSseStream(res, {
2051
+ reason: 'pull-exit',
2052
+ endData: () => ({ ref: safeRef }),
2053
+ run: async (sendEvent, signal) => {
2054
+ const handlers = {
2055
+ onStdout: (chunk) => sendEvent('line', { d: chunk }),
2056
+ onStderr: (chunk) => sendEvent('line', { e: chunk }),
2057
+ };
2058
+ const result = await api.pullStream(safeRef, handlers, signal);
2059
+ return result.code;
2060
+ },
2061
+ });
2062
+ };
996
2063
  /* ---------- HTTP 路由(loopback 围栏) ---------- */
997
2064
  ctx.inject(['webServer'], (webCtx) => {
998
2065
  webCtx.effect(() => {
@@ -1084,6 +2151,27 @@ const plugin = definePlugin({
1084
2151
  });
1085
2152
  return;
1086
2153
  }
2154
+ // SSE 长连接(GET):必须放在下面「非 POST 一律 405」之前,否则
2155
+ // GET 会被 method 检查拦掉。四条流共用 openSseStream,各自只做参数
2156
+ // 校验与执行器接线;响应由对应的 serve*Stream 长持有。
2157
+ const serveStream = sub === '/logs/stream'
2158
+ ? serveLogsStream
2159
+ : sub === '/stats/stream'
2160
+ ? serveStatsStream
2161
+ : sub === '/events/stream'
2162
+ ? serveEventsStream
2163
+ : sub === '/images/pull/stream'
2164
+ ? servePullStream
2165
+ : undefined;
2166
+ if (serveStream !== undefined) {
2167
+ if (req.method !== 'GET') {
2168
+ writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
2169
+ return;
2170
+ }
2171
+ const params = new URL(req.url ?? '/', 'http://loopback').searchParams;
2172
+ await serveStream(req, res, params);
2173
+ return;
2174
+ }
1087
2175
  if (req.method !== 'POST') {
1088
2176
  writeJson(res, 405, { error: 'method not allowed: ' + String(req.method) });
1089
2177
  return;
@@ -1093,6 +2181,25 @@ const plugin = definePlugin({
1093
2181
  writeJson(res, 400, { error: 'invalid JSON body' });
1094
2182
  return;
1095
2183
  }
2184
+ // 跨目标聚合(0.15.0):target='*' 不是目标名,必须在 pickTarget 之前分流,
2185
+ // 否则会被当成「未知目标」直接 400
2186
+ const wantsAllTargets = typeof body.target === 'string' && body.target.trim() === '*';
2187
+ if (wantsAllTargets && (sub === '/containers' || sub === '/attention')) {
2188
+ const names = targetsFor('*');
2189
+ try {
2190
+ if (sub === '/containers') {
2191
+ const groups = await aggregateAcrossTargets(names, (targetApi) => targetApi.listContainers(body.all === true));
2192
+ writeJson(res, 200, { ok: true, groups });
2193
+ return;
2194
+ }
2195
+ const groups = await aggregateAcrossTargets(names, (targetApi) => targetApi.attention());
2196
+ writeJson(res, 200, { ok: true, groups });
2197
+ }
2198
+ catch (error) {
2199
+ writeJson(res, 500, { error: error instanceof Error ? error.message : String(error) });
2200
+ }
2201
+ return;
2202
+ }
1096
2203
  const picked = pickTarget(body.target);
1097
2204
  if (picked.name === undefined) {
1098
2205
  writeJson(res, 400, { error: picked.error ?? '无效的 target' });
@@ -1114,6 +2221,10 @@ const plugin = definePlugin({
1114
2221
  writeJson(res, 200, { ok: true, containers: await api.listContainers(body.all === true) });
1115
2222
  return;
1116
2223
  }
2224
+ case '/attention': {
2225
+ writeJson(res, 200, { ok: true, items: await api.attention() });
2226
+ return;
2227
+ }
1117
2228
  case '/inspect': {
1118
2229
  if (typeof body.id !== 'string') {
1119
2230
  writeJson(res, 400, { error: 'id 必填' });
@@ -1147,6 +2258,98 @@ const plugin = definePlugin({
1147
2258
  writeJson(res, 200, { ok: true, images: await api.images() });
1148
2259
  return;
1149
2260
  }
2261
+ case '/images/inspect': {
2262
+ if (typeof body.ref !== 'string' || body.ref.trim() === '') {
2263
+ writeJson(res, 400, { error: 'ref 必填' });
2264
+ return;
2265
+ }
2266
+ writeJson(res, 200, { ok: true, image: await api.imageInspect(body.ref) });
2267
+ return;
2268
+ }
2269
+ case '/images/remove': {
2270
+ if (!live.allowMutations) {
2271
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2272
+ return;
2273
+ }
2274
+ if (typeof body.ref !== 'string' || body.ref.trim() === '') {
2275
+ writeJson(res, 400, { error: 'ref 必填' });
2276
+ return;
2277
+ }
2278
+ writeJson(res, 200, { ok: true, result: await api.imageRemove(body.ref) });
2279
+ return;
2280
+ }
2281
+ case '/images/prune': {
2282
+ if (!live.allowMutations) {
2283
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2284
+ return;
2285
+ }
2286
+ writeJson(res, 200, { ok: true, result: await api.imagePrune() });
2287
+ return;
2288
+ }
2289
+ case '/networks': {
2290
+ writeJson(res, 200, { ok: true, networks: await api.networks() });
2291
+ return;
2292
+ }
2293
+ case '/networks/inspect': {
2294
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2295
+ writeJson(res, 400, { error: 'name 必填' });
2296
+ return;
2297
+ }
2298
+ writeJson(res, 200, { ok: true, network: await api.networkInspect(body.name) });
2299
+ return;
2300
+ }
2301
+ case '/networks/remove': {
2302
+ if (!live.allowMutations) {
2303
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2304
+ return;
2305
+ }
2306
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2307
+ writeJson(res, 400, { error: 'name 必填' });
2308
+ return;
2309
+ }
2310
+ writeJson(res, 200, { ok: true, result: await api.networkRemove(body.name) });
2311
+ return;
2312
+ }
2313
+ case '/networks/prune': {
2314
+ if (!live.allowMutations) {
2315
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2316
+ return;
2317
+ }
2318
+ writeJson(res, 200, { ok: true, result: await api.networkPrune() });
2319
+ return;
2320
+ }
2321
+ case '/volumes': {
2322
+ writeJson(res, 200, { ok: true, volumes: await api.volumes() });
2323
+ return;
2324
+ }
2325
+ case '/volumes/inspect': {
2326
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2327
+ writeJson(res, 400, { error: 'name 必填' });
2328
+ return;
2329
+ }
2330
+ writeJson(res, 200, { ok: true, volume: await api.volumeInspect(body.name) });
2331
+ return;
2332
+ }
2333
+ case '/volumes/remove': {
2334
+ if (!live.allowMutations) {
2335
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2336
+ return;
2337
+ }
2338
+ if (typeof body.name !== 'string' || body.name.trim() === '') {
2339
+ writeJson(res, 400, { error: 'name 必填' });
2340
+ return;
2341
+ }
2342
+ writeJson(res, 200, { ok: true, result: await api.volumeRemove(body.name) });
2343
+ return;
2344
+ }
2345
+ case '/volumes/prune': {
2346
+ if (!live.allowMutations) {
2347
+ writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
2348
+ return;
2349
+ }
2350
+ writeJson(res, 200, { ok: true, result: await api.volumePrune() });
2351
+ return;
2352
+ }
1150
2353
  case '/action': {
1151
2354
  if (!live.allowMutations) {
1152
2355
  writeJson(res, 403, { error: '变更操作未启用(设置 → 插件 → Docker 容器面板 → 允许变更操作)' });
@@ -1240,6 +2443,8 @@ const plugin = definePlugin({
1240
2443
  /* ---------- 卸载清理 ---------- */
1241
2444
  ctx.effect(() => {
1242
2445
  return () => {
2446
+ // 先收掉长流(结束响应 + 中止 docker logs -f),再关连接池
2447
+ closeAllStreams();
1243
2448
  remote.disposeAll();
1244
2449
  };
1245
2450
  }, 'dsh-docker: cleanup');
@@ -1250,5 +2455,5 @@ export const { name, inject, apply } = plugin;
1250
2455
  /* ------------------------------------------------------------------ *
1251
2456
  * 供 scripts/smoke.mjs 直接复用的纯函数(解析器回归)
1252
2457
  * ------------------------------------------------------------------ */
1253
- export { parsePsJson, parseStatsJson, parseInspectJson, assertBin, DockerApi };
2458
+ export { parsePsJson, parseStatsJson, parseInspectJson, assertBin, assertImageRef, parseImageInspectJson, parseImageHistoryJson, parseImageHistoryText, DockerApi };
1254
2459
  //# sourceMappingURL=index.js.map