@hyzyn/dsh-docker 0.13.7 → 0.15.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/docker.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { RemoteExec, runLocal, runLocalStream, sshTarget } from './ssh-exec.js';
2
+ import { diagnose } from './diagnose.js';
3
+ import { buildRunCommand } from './recreate.js';
2
4
  /* ------------------------------------------------------------------ *
3
5
  * 通用:校验与解析工具
4
6
  * ------------------------------------------------------------------ */
@@ -466,6 +468,101 @@ export function parseStatsJson(text) {
466
468
  };
467
469
  });
468
470
  }
471
+ /* ------------------------------------------------------------------ *
472
+ * 进程快照(docker top)
473
+ * ------------------------------------------------------------------ */
474
+ /**
475
+ * `docker top <id> -eo <列>` 的列清单:**唯一构造点**(argv 与解析共用同一份顺序)。
476
+ *
477
+ * 为什么钉这八列:本机实测(docker 28.3.2)`docker top <id> -eo <这串>` 的表头稳定,
478
+ * 且 `-eo` 选的列名与 `docker top` 的默认列不同(默认给 `PID TTY TIME CMD`,那是
479
+ * 「容器里那个 tty 在跑什么」,排障没用)。`pcpu` / `pmem` 的表头是 `%CPU` / `%MEM`
480
+ * (ps 的默认 header),所以解析**按表头名归一**而不是按下标猜——见 TOP_HEADER_ALIASES。
481
+ */
482
+ export const TOP_PS_FORMAT = 'pid,ppid,user,stat,etime,pcpu,pmem,cmd';
483
+ /** `docker top` 单次返回的进程行上限(第二道闸:字节闸之外按行兜一层,超了给 truncated)。 */
484
+ export const TOP_MAX_ROWS = 500;
485
+ /** 表头名(归一后)→ 输出字段。表头驱动:列顺序变了也不会错位。 */
486
+ const TOP_HEADER_ALIASES = {
487
+ pid: 'pid',
488
+ ppid: 'ppid',
489
+ user: 'user',
490
+ uname: 'user',
491
+ stat: 'stat',
492
+ etime: 'elapsed',
493
+ elapsed: 'elapsed',
494
+ pcpu: 'cpu',
495
+ cpu: 'cpu',
496
+ pmem: 'mem',
497
+ mem: 'mem',
498
+ cmd: 'command',
499
+ command: 'command',
500
+ args: 'command',
501
+ };
502
+ /** 表头单元格归一:小写、去掉 ps 给百分比列的 `%` 前缀。 */
503
+ function normalizeTopHeader(cell) {
504
+ return cell.trim().toLowerCase().replace(/^%/, '');
505
+ }
506
+ /** 空进程行(所有字段都是 string,缺列给空串)。 */
507
+ function emptyTopRow() {
508
+ return { pid: '', ppid: '', user: '', stat: '', elapsed: '', cpu: '', mem: '', command: '' };
509
+ }
510
+ /**
511
+ * `docker top -eo …` 的文本输出 → 结构化的进程行。
512
+ *
513
+ * 解析规则(全部由**表头行**驱动):
514
+ * - 第一行非空行是表头;每个单元格归一成字段名(`PID` → pid、`%CPU` → cpu);
515
+ * - 之后的每一行按空白切成 token:**token 数少于表头列数 → 丢弃该行**(半行 / 被
516
+ * 字节闸切断的行不能当成一条进程);
517
+ * - 多出来的 token 全部并进**命令列**(`cmd` 是唯一可以含空格的列,无论它排在
518
+ * 第几列)——其余列各取一个 token,这样列顺序变了也不会错位;
519
+ * - 表头里认不出的列(不同 ps 版本多给的列)忽略,认不出的行照样保留;
520
+ * - 输出为空(容器里没有进程 / docker 没打印)→ 空数组,不是错误。
521
+ */
522
+ export function parseTopOutput(text) {
523
+ const lines = text.split(/\r?\n/);
524
+ let headerAt = -1;
525
+ for (let i = 0; i < lines.length; i++) {
526
+ if ((lines[i] ?? '').trim() !== '') {
527
+ headerAt = i;
528
+ break;
529
+ }
530
+ }
531
+ if (headerAt === -1)
532
+ return { rows: [], truncated: false };
533
+ const headers = (lines[headerAt] ?? '').trim().split(/\s+/).map(normalizeTopHeader);
534
+ const mapped = headers.map((cell) => TOP_HEADER_ALIASES[cell] ?? null);
535
+ if (mapped.every((key) => key === null))
536
+ return { rows: [], truncated: false };
537
+ // 命令列的位置:表头里说是 cmd 的那一列;认不出来就按「最后一列」处理(ps 的惯例)
538
+ const commandAt = mapped.indexOf('command') === -1 ? headers.length - 1 : mapped.indexOf('command');
539
+ const rows = [];
540
+ for (let i = headerAt + 1; i < lines.length; i++) {
541
+ const line = (lines[i] ?? '').trim();
542
+ if (line === '')
543
+ continue;
544
+ const tokens = line.split(/\s+/);
545
+ // 行数不足 → 丢弃(契约):半行当成一行进程,比少一行糟得多
546
+ if (tokens.length < headers.length)
547
+ continue;
548
+ const row = emptyTopRow();
549
+ // 多出来的 token 全给命令列;其余列各取一个 token
550
+ const extra = tokens.length - headers.length;
551
+ let cursor = 0;
552
+ for (let column = 0; column < headers.length; column++) {
553
+ const take = column === commandAt ? extra + 1 : 1;
554
+ const value = tokens.slice(cursor, cursor + take).join(' ');
555
+ cursor += take;
556
+ const key = mapped[column];
557
+ if (key !== null)
558
+ row[key] = value;
559
+ }
560
+ rows.push(row);
561
+ if (rows.length >= TOP_MAX_ROWS)
562
+ return { rows, truncated: true };
563
+ }
564
+ return { rows, truncated: false };
565
+ }
469
566
  /* ------------------------------------------------------------------ *
470
567
  * 事件(docker events)
471
568
  * ------------------------------------------------------------------ */
@@ -836,6 +933,22 @@ function dockerTime(value) {
836
933
  return null;
837
934
  return value;
838
935
  }
936
+ /**
937
+ * `docker inspect` 里的**资源上限**(`diagnose()` 用)。
938
+ *
939
+ * 刻意不进 `ContainerDetail` 契约:那是「面板详情页要显示什么」的形状,而这两个字段
940
+ * 只有诊断判据(内存 / CPU 逼近上限)需要。0 一律归一成 `null`——docker 用 0 表示
941
+ * **不限制**,把 0 当成上限会让「内存上限 0B」这种假象混进判据与文案里。
942
+ */
943
+ export function parseInspectLimits(text) {
944
+ const row = parseJsonLines(text)[0];
945
+ const hostConfig = asRecord(row?.HostConfig);
946
+ const positive = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null);
947
+ return {
948
+ memoryLimitBytes: positive(hostConfig.Memory),
949
+ cpuQuota: positive(hostConfig.NanoCpus),
950
+ };
951
+ }
839
952
  /** inspect 的 `NetworkSettings.Ports`:`{ "80/tcp": [{HostIp, HostPort}] }`。 */
840
953
  export function parseInspectPorts(value) {
841
954
  const ports = asRecord(value);
@@ -883,6 +996,115 @@ export function parseInspectPorts(value) {
883
996
  }
884
997
  return out;
885
998
  }
999
+ /* ------------------------------------------------------------------ *
1000
+ * 磁盘占用(docker system df)
1001
+ * ------------------------------------------------------------------ */
1002
+ /**
1003
+ * 明细每类最多回多少行(宿主侧闸)。客户端要的是「治理视图」,不是迁移清单:
1004
+ * 259 个匿名卷全列出来既读不了、也顶爆模型上下文。
1005
+ */
1006
+ export const DISK_DETAIL_LIMIT = 100;
1007
+ /**
1008
+ * 明细采集的字节预算下限(与配置的单命令上限取较大者)。
1009
+ *
1010
+ * `docker system df -v --format '{{json .}}'` 是**一条**大 JSON(真机实测 259 个卷
1011
+ * ≈120KB),卷多时会线性涨;512KB 的默认上限在几千个卷的机器上会截断,而截断会让
1012
+ * JSON 解析失败 → 白白降级成「只有汇总」。给一份独立预算(同 logsGrep / diagnose 的
1013
+ * events 采集),超了仍然如实降级。
1014
+ */
1015
+ export const DISK_DETAIL_SCAN_BYTES = 8 * 1024 * 1024;
1016
+ /** 计数字段在 `{{json .}}` 里是**字符串**(`"TotalCount":"14"`),解析失败给 0。 */
1017
+ function countOf(value) {
1018
+ const n = typeof value === 'number' ? value : Number(value);
1019
+ return Number.isInteger(n) && n >= 0 ? n : 0;
1020
+ }
1021
+ /**
1022
+ * `docker system df --format '{{json .}}'` → 汇总行。
1023
+ *
1024
+ * 每行一个 JSON 对象(真机实测四行:Images / Containers / Local Volumes / Build Cache)。
1025
+ * `type` 原样保留英文——翻译是客户端 i18n 的事(`status.diskTypeImages` 等)。
1026
+ */
1027
+ export function parseDiskSummary(text) {
1028
+ return parseJsonLines(text)
1029
+ .map((row) => ({
1030
+ type: str(row, 'Type'),
1031
+ totalCount: countOf(row.TotalCount),
1032
+ active: countOf(row.Active),
1033
+ size: str(row, 'Size'),
1034
+ reclaimable: str(row, 'Reclaimable'),
1035
+ }))
1036
+ .filter((row) => row.type !== '');
1037
+ }
1038
+ /**
1039
+ * `docker system df -v --format '{{json .}}'` → 明细(images / volumes / buildCache)。
1040
+ *
1041
+ * 真机实测:整个输出是**一个 JSON 对象**(键 `Images` / `Containers` / `Volumes` /
1042
+ * `BuildCache`),`--format` 把它序列化成一行。`Containers` 段刻意不解析——容器明细
1043
+ * 与 `docker ps` 重复,而磁盘治理关心的是「谁在用」(Images[].Containers / Volumes[].Links)。
1044
+ *
1045
+ * **排序**:镜像按 `UniqueSize`、卷按 `Size` 降序(同值保持 docker 原序)。磁盘页要的
1046
+ * 「先看占用最大的」在这里就成立,客户端不必再排一遍、也不会排错(字符串大小不可比)。
1047
+ * 截断在排序**之后**(`truncated` 如实置位),否则名额会被一堆 0B 的空卷占满。
1048
+ *
1049
+ * 解析不了(老 docker 不支持 `-v --format`、输出被截断、JSON 坏了)→ `null`:
1050
+ * 降级由调用方决定,这里不抛——「明细没有」不该把汇总也带走。
1051
+ */
1052
+ export function parseDiskDetail(text, limit = DISK_DETAIL_LIMIT) {
1053
+ const root = parseJsonLines(text)[0];
1054
+ if (root === undefined)
1055
+ return null;
1056
+ const imagesRaw = Array.isArray(root.Images) ? root.Images : null;
1057
+ const volumesRaw = Array.isArray(root.Volumes) ? root.Volumes : null;
1058
+ // 三个数组一个都没有 = 这不是 `system df -v` 的形状(老版本会直接报 unknown flag)
1059
+ if (imagesRaw === null && volumesRaw === null && !Array.isArray(root.BuildCache))
1060
+ return null;
1061
+ const cap = Math.max(Math.trunc(limit), 1);
1062
+ const images = (imagesRaw ?? [])
1063
+ .map((item) => asRecord(item))
1064
+ .map((row) => ({
1065
+ id: str(row, 'ID'),
1066
+ repository: str(row, 'Repository'),
1067
+ tag: str(row, 'Tag'),
1068
+ size: str(row, 'Size'),
1069
+ sharedSize: str(row, 'SharedSize'),
1070
+ // 老 docker 没有 UniqueSize:用 VirtualSize 兜底、都没有就空串(客户端显示 —)
1071
+ uniqueSize: str(row, 'UniqueSize') || str(row, 'VirtualSize'),
1072
+ containers: countOf(row.Containers),
1073
+ }))
1074
+ .sort((a, b) => sizeBytesOf(b.uniqueSize) - sizeBytesOf(a.uniqueSize));
1075
+ const volumes = (volumesRaw ?? [])
1076
+ .map((item) => asRecord(item))
1077
+ .map((row) => ({
1078
+ name: str(row, 'Name'),
1079
+ driver: str(row, 'Driver'),
1080
+ size: str(row, 'Size'),
1081
+ links: countOf(row.Links),
1082
+ }))
1083
+ .sort((a, b) => sizeBytesOf(b.size) - sizeBytesOf(a.size));
1084
+ const buildCache = (Array.isArray(root.BuildCache) ? root.BuildCache : [])
1085
+ .map((item) => asRecord(item))
1086
+ .map((row) => ({
1087
+ id: str(row, 'ID'),
1088
+ type: str(row, 'Type'),
1089
+ size: str(row, 'Size'),
1090
+ inUse: boolish(row.InUse),
1091
+ }));
1092
+ return {
1093
+ images: images.slice(0, cap),
1094
+ volumes: volumes.slice(0, cap),
1095
+ buildCache: buildCache.slice(0, cap),
1096
+ totals: { images: images.length, volumes: volumes.length, buildCache: buildCache.length },
1097
+ truncated: {
1098
+ images: images.length > cap,
1099
+ volumes: volumes.length > cap,
1100
+ buildCache: buildCache.length > cap,
1101
+ },
1102
+ };
1103
+ }
1104
+ /** 大小字符串 → 字节(`264.1MB` → 字节);解析失败给 0,保证排序稳定。 */
1105
+ function sizeBytesOf(text) {
1106
+ return parseDockerSize(text) ?? 0;
1107
+ }
886
1108
  /**
887
1109
  * logsGrep 的**返回侧行闸**(与扫描侧的 8~32MB 字节预算是两件事)。
888
1110
  *
@@ -893,6 +1115,41 @@ export function parseInspectPorts(value) {
893
1115
  * `matched` 不受此闸影响:全量命中数仍如实上报,那是缩小窗口时最有用的信号。
894
1116
  */
895
1117
  export const MAX_RESULT_LINES = 4000;
1118
+ /** 诊断默认抓多少行日志(契约 §1.2)。 */
1119
+ export const DIAGNOSE_LOG_TAIL_DEFAULT = 80;
1120
+ /** 诊断日志行数的上限(与 `docker_logs` 的夹紧风格一致:越界夹到边界,不报错)。 */
1121
+ export const DIAGNOSE_LOG_TAIL_MAX = 500;
1122
+ /** 诊断默认的事件窗口(契约 §1.2)。 */
1123
+ export const DIAGNOSE_SINCE_DEFAULT = '1h';
1124
+ /**
1125
+ * 诊断的事件扫描预算(字节,下限;实际取与配置上限的较大者)。
1126
+ *
1127
+ * 这不是「调大上限」:`docker_events` 工具的默认预算不变。单容器 + `--since 1h` 的
1128
+ * 事件里,健康检查每个周期产生 exec_create / exec_start / exec_die 三条,每条都带
1129
+ * 整份镜像标签(真机实测 ≈2.3KB/条、256 条/小时、合计 588KB),而这三类在解析时会被
1130
+ * 白名单整条丢掉——花 512KB 的预算把 95% 的字节买来再扔掉,正是「时间线永远是空」的成因。
1131
+ */
1132
+ export const DIAGNOSE_EVENTS_SCAN_BYTES = 8 * 1024 * 1024;
1133
+ /** 采集失败的一句话原因(给 `*-unavailable` 判据与面板文案用;空错误也要有话说)。 */
1134
+ function describeCollectedError(error, what) {
1135
+ const message = error instanceof Error ? error.message : String(error);
1136
+ const trimmed = message.trim();
1137
+ return trimmed === '' ? `${what}采集失败(无错误信息)` : trimmed;
1138
+ }
1139
+ /**
1140
+ * 统计日志行数(`--tail N` 采到的原文)。
1141
+ *
1142
+ * 与 D136 同一口径:docker logs 每一行都以 `\n` 结尾,`text.split('\n')` 末尾会多出
1143
+ * 一个空串——那个终止符不是一行,计数时必须去掉,否则「抓 80 行」会显示成 81。
1144
+ */
1145
+ function countLogLines(text) {
1146
+ if (text === '')
1147
+ return 0;
1148
+ const lines = text.split('\n');
1149
+ if (lines.length > 0 && lines[lines.length - 1] === '')
1150
+ lines.pop();
1151
+ return lines.length;
1152
+ }
896
1153
  /** 单个目标上的 Docker 操作集合。 */
897
1154
  export class DockerApi {
898
1155
  runner;
@@ -1150,20 +1407,34 @@ export class DockerApi {
1150
1407
  * 事件快照(agent 工具用):必须先有 --until 才能让它退出——docker events
1151
1408
  * 只给 --since 时会一直 follow 下去,run() 会挂到超时。这里把 until 取成**请求
1152
1409
  * 时刻的 RFC3339**(不是字符串 'now':docker 的 --until 只认时间戳或时长)。
1410
+ *
1411
+ * `container`(诊断用)补一条 `--filter container=<id>`:过滤在**服务端**做,
1412
+ * 宿主侧绝不按名字匹配(名字会在 rename / compose 重建后对不上,而 id 不会)。
1413
+ * 不传时 argv 与引入前逐字一致。
1414
+ *
1415
+ * `options`(诊断用)只调**采集预算**,不动 argv:单容器窗口的事件里混着大量
1416
+ * `exec_*` 噪音(每次健康检查三连,每条都带着整份镜像标签),真机实测一个
1417
+ * 20s 健康检查的容器 1h 就 588KB——而 `exec_*` 在解析时本来就会被白名单丢掉。
1418
+ * 所以诊断给一份独立的扫描预算(同 `logsGrep` 的做法)并 `keepTail`(溢出丢最旧:
1419
+ * 判断「现在为什么不行」要的是最近的一段)。既有调用点不传,行为逐字不变。
1153
1420
  */
1154
- async events(since) {
1155
- const result = await this.runner.run(this.eventsArgv({ since, until: new Date().toISOString() }), {
1421
+ async events(since, container, options) {
1422
+ const requested = typeof container === 'string' ? container.trim() : '';
1423
+ const scoped = requested === '' ? undefined : assertRef(requested, 'container');
1424
+ const result = await this.runner.run(this.eventsArgv({ since, until: new Date().toISOString(), ...(scoped === undefined ? {} : { container: scoped }) }), {
1156
1425
  // 跨 10m 窗口的事件量取决于容器活动量,给足超时但别学流那样无上限
1157
1426
  timeoutMs: Math.max(this.limits.timeoutMs, 30_000),
1158
- maxBytes: this.limits.maxBytes,
1427
+ maxBytes: options?.maxBytes ?? this.limits.maxBytes,
1428
+ ...(options?.keepTail === true ? { keepTail: true } : {}),
1159
1429
  });
1160
1430
  this.assertComplete(result, '读取容器事件', '缩小 since 窗口(如 10m)');
1161
1431
  return parseEventsJson(result.stdout);
1162
1432
  }
1163
- /** 事件 argv 的唯一构造点:流式与快照只差 --since / --until。 */
1433
+ /** 事件 argv 的唯一构造点:流式与快照只差 --since / --until,诊断再多一条 container 过滤。 */
1164
1434
  eventsArgv(options) {
1165
1435
  const since = options.since === undefined ? '' : options.since.trim();
1166
1436
  const until = options.until === undefined ? '' : options.until.trim();
1437
+ const container = options.container === undefined ? '' : options.container.trim();
1167
1438
  return [
1168
1439
  this.bin,
1169
1440
  'events',
@@ -1171,6 +1442,7 @@ export class DockerApi {
1171
1442
  ...(until === '' ? [] : ['--until', until]),
1172
1443
  '--format', '{{json .}}',
1173
1444
  '--filter', 'type=container',
1445
+ ...(container === '' ? [] : ['--filter', `container=${container}`]),
1174
1446
  ];
1175
1447
  }
1176
1448
  async images() {
@@ -1377,6 +1649,75 @@ export class DockerApi {
1377
1649
  this.assertOk(result, '清理未使用的卷');
1378
1650
  return { message: result.stdout.trim() || 'ok' };
1379
1651
  }
1652
+ /**
1653
+ * 磁盘占用(`docker system df`):**两条命令、两份预算**。
1654
+ *
1655
+ * - 汇总 `system df --format '{{json .}}'`:失败即整体失败(连汇总都拿不到,
1656
+ * 这个视图没有意义);
1657
+ * - 明细 `system df -v --format '{{json .}}'`:**独立失败**——老 docker 不认
1658
+ * `-v --format`(unknown flag)、输出被截断、JSON 坏了,都只让 `degraded=true`
1659
+ * 并带一句人话原因,汇总照常返回。
1660
+ *
1661
+ * 两条命令不合并(契约 §2.1):`-v` 是重活(会去 stat 每个卷),而汇总很便宜——
1662
+ * 合并后一个慢/一个失败就互相拖累。
1663
+ */
1664
+ async systemDf() {
1665
+ const summaryResult = await this.runner.run([this.bin, 'system', 'df', '--format', '{{json .}}'], {
1666
+ timeoutMs: Math.max(this.limits.timeoutMs, 30_000),
1667
+ maxBytes: this.limits.maxBytes,
1668
+ });
1669
+ this.assertComplete(summaryResult, '读取磁盘占用汇总', '换到目标机本地执行 `docker system df`');
1670
+ const summary = parseDiskSummary(summaryResult.stdout);
1671
+ try {
1672
+ const detailResult = await this.runner.run([this.bin, 'system', 'df', '-v', '--format', '{{json .}}'], {
1673
+ // `-v` 会去 stat 每个卷 / 镜像层:比汇总慢得多,超时给两档
1674
+ timeoutMs: Math.max(this.limits.timeoutMs, 60_000),
1675
+ maxBytes: Math.max(this.limits.maxBytes, DISK_DETAIL_SCAN_BYTES),
1676
+ });
1677
+ this.assertOk(detailResult, '读取磁盘占用明细');
1678
+ if (detailResult.truncated) {
1679
+ throw new Error(`明细输出超过上限被截断(明细 ${String(Math.round(Math.max(this.limits.maxBytes, DISK_DETAIL_SCAN_BYTES) / 1024 / 1024))}MB 档)`);
1680
+ }
1681
+ const detail = parseDiskDetail(detailResult.stdout);
1682
+ if (detail === null)
1683
+ throw new Error('无法解析明细(这个 docker 版本可能不支持 `system df -v --format`)');
1684
+ return { summary, ...detail, degraded: false, degradedReason: null };
1685
+ }
1686
+ catch (error) {
1687
+ const message = error instanceof Error ? error.message : String(error);
1688
+ return {
1689
+ summary,
1690
+ images: [],
1691
+ volumes: [],
1692
+ buildCache: [],
1693
+ // 明细没拿到 ≠ 明细是空的:三个 truncated 都给 false(它们描述的是「有但被截」),
1694
+ // totals 也归零(与「明细取到了但一条都没有」由 degraded 区分)
1695
+ totals: { images: 0, volumes: 0, buildCache: 0 },
1696
+ truncated: { images: false, volumes: false, buildCache: false },
1697
+ degraded: true,
1698
+ degradedReason: `只拿到汇总:${message}`,
1699
+ };
1700
+ }
1701
+ }
1702
+ /**
1703
+ * 现场还原:从 inspect 的原始 JSON 反推等价 `docker run`(只生成文本,**不执行**)。
1704
+ *
1705
+ * 为什么另起一条读 inspect 的路径而不是复用 `inspect()`:`ContainerDetail` 刻意不含
1706
+ * env / binds / hostConfig(那会把凭证灌进 `docker_inspect` 的模型上下文),而还原
1707
+ * 需要它们。所以这里拿**原始行**交给纯模块 `src/recreate.ts`,产出纯文本。
1708
+ */
1709
+ async recreate(id) {
1710
+ const safe = assertRef(id, 'container');
1711
+ const result = await this.runner.run([this.bin, 'inspect', safe], {
1712
+ timeoutMs: this.limits.timeoutMs,
1713
+ maxBytes: this.limits.maxBytes,
1714
+ });
1715
+ this.assertComplete(result, '读取容器详情', '改用更少的容器 id 分批读取');
1716
+ const row = parseJsonLines(result.stdout)[0];
1717
+ if (row === undefined)
1718
+ throw new Error(`容器不存在或输出无法解析:${safe}`);
1719
+ return buildRunCommand(row);
1720
+ }
1380
1721
  /**
1381
1722
  * 拉取镜像(`docker pull`)。逐层进度天然是流:非 TTY 下 docker 按状态行输出
1382
1723
  * (Pulling fs layer / Downloading / Extracting / Pull complete),直接复用
@@ -1630,6 +1971,125 @@ export class DockerApi {
1630
1971
  durationMs: result.durationMs,
1631
1972
  };
1632
1973
  }
1974
+ /* ---------------------------------------------------------------- *
1975
+ * 诊断(批一):进程快照 + 「为什么不行」的结构化结论
1976
+ * ---------------------------------------------------------------- */
1977
+ /**
1978
+ * 容器内的进程快照(`docker top <id> -eo <TOP_PS_FORMAT>`)。
1979
+ *
1980
+ * **容器名要单独取一次**:`docker top` 的输出里没有容器名(本机实测表头就是那八列),
1981
+ * 而契约要求返回 `name`。这条是**次要信息**——只读、输出一行、失败不影响 top 的
1982
+ * 成功路径(回落成传入的 id,绝不编造)。所以它**不在** top 的 fail-fast 里:
1983
+ * top 自己失败照旧走 assertOk 抛错(容器不存在 / 未运行时 docker 就是这么答的)。
1984
+ *
1985
+ * 容器没在运行时 `docker top` 报错 → assertOk 抛错,**不返回空列表**:空列表会被
1986
+ * 读成「容器里没有进程」,而真实情况是「问不了」。
1987
+ */
1988
+ async top(id) {
1989
+ const safe = assertRef(id, 'container');
1990
+ const result = await this.runner.run([this.bin, 'top', safe, '-eo', TOP_PS_FORMAT], {
1991
+ timeoutMs: this.limits.timeoutMs,
1992
+ maxBytes: this.limits.maxBytes,
1993
+ });
1994
+ this.assertOk(result, '读取容器进程');
1995
+ const parsed = parseTopOutput(result.stdout);
1996
+ const name = await this.containerName(safe);
1997
+ // 两道截断取或:字节闸(runner)与行闸(TOP_MAX_ROWS)任一触发都算「不全」
1998
+ return { id: safe, name, rows: parsed.rows, truncated: parsed.truncated || result.truncated };
1999
+ }
2000
+ /**
2001
+ * 容器名(`docker inspect --format '{{.Name}}'`)。
2002
+ *
2003
+ * `.Name` 带前导斜杠(`/dsh-remote-server`),必须 strip;strip 后为空、或这条命令
2004
+ * 失败,都回落成传入的 id——名字是次要信息,不该把已经成功的 top 带下去。
2005
+ */
2006
+ async containerName(id) {
2007
+ try {
2008
+ const result = await this.runner.run(this.containerNameArgv(id), {
2009
+ timeoutMs: this.limits.timeoutMs,
2010
+ maxBytes: 64 * 1024,
2011
+ });
2012
+ if (result.code !== 0)
2013
+ return id;
2014
+ const name = result.stdout.trim().replace(/^\//, '');
2015
+ return name === '' ? id : name;
2016
+ }
2017
+ catch {
2018
+ return id;
2019
+ }
2020
+ }
2021
+ /** 容器名 argv 的唯一构造点(`docker top` 的输出里没有名字,top 用这条补)。 */
2022
+ containerNameArgv(id) {
2023
+ return [this.bin, 'inspect', '--format', '{{.Name}}', id];
2024
+ }
2025
+ /**
2026
+ * 「容器为什么不行」:一次采集四路,交给纯判据(`src/diagnose.ts`)出结论。
2027
+ *
2028
+ * **顺序与预算**(契约 §1.2):
2029
+ * ① 先 `inspect`——它是权威状态(退出码 / OOM / 重启次数 / 健康),**失败即整体失败**;
2030
+ * ② 再并发 `logs` / `events`(带 `--filter container=<id>`,服务端过滤)/ `stats`——
2031
+ * 任何一路失败**只降级不抛**:该路给 `null`(与「确实是空的」区分),
2032
+ * findings 里多一条 `*-unavailable` 说明原因,evidence 对应字段给 null。
2033
+ *
2034
+ * 三路各自独立 catch 而不是 `Promise.allSettled`:失败原因要变成给人看的文案
2035
+ * (`*Error`),而 allSettled 的 reason 还得再剖一层。
2036
+ *
2037
+ * `logTail` 夹到 1~500(默认 80),`since` 走 `assertSince`(D45 同一口径,默认 1h)——
2038
+ * 两处夹紧都在这里做,HTTP 路由与 agent 工具拿到的是同一套语义。
2039
+ */
2040
+ async diagnose(id, options) {
2041
+ const safe = assertRef(id, 'container');
2042
+ // 非有限数(NaN / Infinity)不能进 argv:String(NaN) 会变成字面量 "NaN",docker 报的是参数错误
2043
+ const requestedTail = typeof options?.logTail === 'number' && Number.isFinite(options.logTail)
2044
+ ? Math.trunc(options.logTail)
2045
+ : DIAGNOSE_LOG_TAIL_DEFAULT;
2046
+ const logTail = Math.min(Math.max(requestedTail, 1), DIAGNOSE_LOG_TAIL_MAX);
2047
+ const since = assertSince(typeof options?.since === 'string' && options.since.trim() !== '' ? options.since : DIAGNOSE_SINCE_DEFAULT);
2048
+ /* ① 权威状态:失败即整体失败(其余三路都是以它为前提的补充证据) */
2049
+ const inspectResult = await this.runner.run([this.bin, 'inspect', safe], {
2050
+ timeoutMs: this.limits.timeoutMs,
2051
+ maxBytes: this.limits.maxBytes,
2052
+ });
2053
+ this.assertComplete(inspectResult, '读取容器详情', '改用更少的容器 id 分批读取');
2054
+ const detail = parseInspectJson(inspectResult.stdout)[0];
2055
+ if (detail === undefined)
2056
+ throw new Error(`容器不存在或输出无法解析:${safe}`);
2057
+ const limits = parseInspectLimits(inspectResult.stdout);
2058
+ /* ② 三路并发采集:任何一路失败只降级,不抛 */
2059
+ const logsOutcome = this.logs(safe, { tail: logTail, since })
2060
+ .then((logs) => ({ ok: true, text: logs.text }))
2061
+ .catch((error) => ({ ok: false, error: describeCollectedError(error, '日志') }));
2062
+ const eventsOutcome = this.events(since, safe, {
2063
+ maxBytes: Math.max(this.limits.maxBytes, DIAGNOSE_EVENTS_SCAN_BYTES),
2064
+ keepTail: true,
2065
+ })
2066
+ .then((events) => ({ ok: true, events }))
2067
+ .catch((error) => ({ ok: false, error: describeCollectedError(error, '事件') }));
2068
+ const statsOutcome = this.stats([safe])
2069
+ .then((rows) => ({ ok: true, row: rows[0] ?? null }))
2070
+ .catch((error) => ({ ok: false, error: describeCollectedError(error, '资源统计') }));
2071
+ const [logsSettled, eventsSettled, statsSettled] = await Promise.all([logsOutcome, eventsOutcome, statsOutcome]);
2072
+ const diagnosis = diagnose({
2073
+ detail,
2074
+ memoryLimitBytes: limits.memoryLimitBytes,
2075
+ cpuQuota: limits.cpuQuota,
2076
+ logText: logsSettled.ok ? logsSettled.text : null,
2077
+ logLines: logsSettled.ok ? countLogLines(logsSettled.text) : null,
2078
+ events: eventsSettled.ok ? eventsSettled.events : null,
2079
+ stats: statsSettled.ok ? statsSettled.row : null,
2080
+ logError: logsSettled.ok ? null : logsSettled.error,
2081
+ eventError: eventsSettled.ok ? null : eventsSettled.error,
2082
+ statsError: statsSettled.ok ? null : statsSettled.error,
2083
+ // 只给 evidence.status 的相对时长用(如 `Up 4 hours`);判据本身仍然不读时钟
2084
+ nowMs: Date.now(),
2085
+ });
2086
+ return {
2087
+ diagnosis,
2088
+ logError: logsSettled.ok ? null : logsSettled.error,
2089
+ eventError: eventsSettled.ok ? null : eventsSettled.error,
2090
+ statsError: statsSettled.ok ? null : statsSettled.error,
2091
+ };
2092
+ }
1633
2093
  assertOk(result, what) {
1634
2094
  if (result.code === 0)
1635
2095
  return;