@hyzyn/dsh-docker 0.4.0 → 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
@@ -57,7 +57,7 @@ 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 主机)上的容器列表(含 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 权限,不要在用户未明确要求时执行变更操作。';
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
61
  /**
62
62
  * SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
63
63
  * 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
@@ -444,6 +444,65 @@ const plugin = definePlugin({
444
444
  return { error: '尚未配置任何 Docker 目标(设置 → 插件 → Docker 容器面板)' };
445
445
  return { error: 'target 必填(已配置多个目标:' + list.map((item) => item.name).join('、') + ')' };
446
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
+ };
447
506
  /* ---------- 配置快照(凭证不外泄) ---------- */
448
507
  const snapshotTarget = (target) => ({
449
508
  name: target.name,
@@ -620,6 +679,55 @@ const plugin = definePlugin({
620
679
  return `\n- ${row.name} (${row.kind}) ${row.label}${state}`;
621
680
  }).join('');
622
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
+ };
623
731
  const renderContainers = (target, rows) => {
624
732
  if (rows.length === 0)
625
733
  return `目标 ${target}:没有容器。`;
@@ -795,17 +903,19 @@ const plugin = definePlugin({
795
903
  }));
796
904
  add('docker_ps', defineTool({
797
905
  name: 'docker_ps',
798
- description: '列出某个目标上的容器(默认只列运行中的;all:true 含已停止)。返回名称/状态/健康/镜像/端口/compose 项目/短 ID。排障第一步。',
799
- 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
+ },
800
911
  output: {
801
912
  schema: {
802
913
  type: 'object',
803
914
  additionalProperties: false,
804
915
  properties: {
805
- target: { type: 'string', required: true },
916
+ target: { type: 'string' },
806
917
  containers: {
807
918
  type: 'array',
808
- required: true,
809
919
  items: {
810
920
  type: 'object',
811
921
  additionalProperties: false,
@@ -822,15 +932,73 @@ const plugin = definePlugin({
822
932
  },
823
933
  },
824
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
+ },
825
966
  },
826
967
  },
827
968
  render: (_args, value) => {
828
969
  const v = value;
970
+ if (Array.isArray(v.groups))
971
+ return [{ type: 'text', text: renderAggregatedContainers(v.groups) }];
829
972
  return [{ type: 'text', text: renderContainers(v.target ?? '?', v.containers ?? []) }];
830
973
  },
831
974
  },
832
975
  async execute(args) {
833
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
+ }
834
1002
  const picked = pickTarget(input.target);
835
1003
  if (picked.name === undefined)
836
1004
  throw new Error(picked.error ?? '无效的 target');
@@ -838,20 +1006,116 @@ const plugin = definePlugin({
838
1006
  if (api === undefined)
839
1007
  throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
840
1008
  const containers = await api.listContainers(input.all === true);
841
- return {
842
- target: picked.name,
843
- containers: containers.map((row) => ({
844
- id: row.id,
845
- name: row.name,
846
- image: row.image,
847
- state: row.state,
848
- status: row.status,
849
- ...(row.health === null ? {} : { health: row.health }),
850
- ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
851
- ...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
852
- ...(row.composeService === null ? {} : { composeService: row.composeService }),
853
- })),
854
- };
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) };
855
1119
  },
856
1120
  }));
857
1121
  add('docker_inspect', defineTool({
@@ -1917,6 +2181,25 @@ const plugin = definePlugin({
1917
2181
  writeJson(res, 400, { error: 'invalid JSON body' });
1918
2182
  return;
1919
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
+ }
1920
2203
  const picked = pickTarget(body.target);
1921
2204
  if (picked.name === undefined) {
1922
2205
  writeJson(res, 400, { error: picked.error ?? '无效的 target' });
@@ -1938,6 +2221,10 @@ const plugin = definePlugin({
1938
2221
  writeJson(res, 200, { ok: true, containers: await api.listContainers(body.all === true) });
1939
2222
  return;
1940
2223
  }
2224
+ case '/attention': {
2225
+ writeJson(res, 200, { ok: true, items: await api.attention() });
2226
+ return;
2227
+ }
1941
2228
  case '/inspect': {
1942
2229
  if (typeof body.id !== 'string') {
1943
2230
  writeJson(res, 400, { error: 'id 必填' });