@hyzyn/dsh-docker 0.6.3 → 0.7.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
@@ -23,10 +23,11 @@ export function assertRef(value, field) {
23
23
  * 填不进自己的 docker —— 而填不进时拿到的是 400,却没有一句话解释为什么(真机实测)。
24
24
  *
25
25
  * 仍然拒绝:`;` `&` `|` `<` `>` 引号 反引号 `$` `%` `!` `^` 换行等 shell 元字符,
26
- * 以及首字符 `-`(看起来像 flag 的值会被 docker 当选项解析)。空格只允许出现在
26
+ * 以及**任何**以 `-` 开头的 token(看起来像 flag 的值会被 docker 当选项解析)——
27
+ * 校验的是每一个空格分隔的 token,不是只看整串首字符(D43)。空格只允许出现在
27
28
  * 内部且不成串。本机通道一律以 argv 数组启动、不经 shell(远程走 shJoin),这层是纵深防御。
28
29
  */
29
- const BIN_RE = /^(?!-)[A-Za-z0-9_./:\\-]+(?: [A-Za-z0-9_./:\\-]+)*$/;
30
+ const BIN_RE = /^(?!-)[A-Za-z0-9_./:\\-]+(?: (?!-)[A-Za-z0-9_./:\\-]+)*$/;
30
31
  /** 校验 docker CLI 可执行文件路径;空值回落到 `docker`。 */
31
32
  export function assertBin(value) {
32
33
  if (typeof value !== 'string' || value.trim() === '')
@@ -162,6 +163,37 @@ export function parseIOPair(text) {
162
163
  function firstLine(stderr, stdout, code) {
163
164
  return (stderr.trim() || stdout.trim() || `退出码 ${String(code)}`).split('\n')[0] ?? `退出码 ${String(code)}`;
164
165
  }
166
+ /**
167
+ * `--since` 的统一口径(D45):时长(docker 的 Go duration 语法,允许复合如
168
+ * `1h30m`)、Unix 秒或时间戳。events 与 logs 两条路径此前各有一套——events
169
+ * 白名单偏窄(复合 duration 被拒)、logs 完全不校验(docker 的参数错误变成
170
+ * 不可读的报错)。纯校验函数:合法返回原样串,不合法抛错。
171
+ *
172
+ * 与 docker 的口径**双向对齐**(D99):
173
+ * ① duration 支持小数与 `0`——Go 的 `time.ParseDuration` 接受 `1.5h` 与 `0`,
174
+ * 旧正则的 `\d+` 把它们当成非法输入拒了(用户明明写的是合法参数);
175
+ * ② 裸数字仍按 docker 语义当 Unix 秒(`--since 3600` = 一小时前);
176
+ * ③ 时间戳必须**带时间部分**——`2026-09-13` 这种裸日期此前被放行,docker
177
+ * 多半原样报参数错误,不如在这里拒绝;
178
+ * ④ 报错回显原始值,否则调用方(尤其是 agent)不知道是哪一段没通过。
179
+ */
180
+ export function assertSince(value, field = 'since') {
181
+ const usage = '时长(如 30m、2h、1h30m、1.5h)、Unix 秒(如 3600)或含时间的时间戳(如 2026-09-13T10:00:00)';
182
+ if (typeof value !== 'string' || value.trim() === '')
183
+ throw new Error(`${field} 必填(${usage})`);
184
+ const text = value.trim();
185
+ // Go duration:`0` 单独合法,其余每段「数字[.小数]+单位」,可复合。单位含
186
+ // µs(U+00B5,Go 文档里的写法)与 μs(U+03BC,常见的希腊字母替代)两种
187
+ if (text === '0' || /^(?:\d+(?:\.\d+)?(?:ns|us|µs|μs|ms|s|m|h))+$/.test(text))
188
+ return text;
189
+ // 裸数字:docker 按 Unix 秒解释
190
+ if (/^\d+$/.test(text))
191
+ return text;
192
+ // 时间戳:RFC3339 风格,但 `T` 可写成空格、秒与小数秒可省、时区可省
193
+ if (/^\d{4}-\d{2}-\d{2}[Tt ]\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/.test(text))
194
+ return text;
195
+ throw new Error(`${field} 无法识别:${text}(只支持 ${usage})`);
196
+ }
165
197
  /** 从 ps 的 `.Status` 解析退出码:`Exited (137) 2 hours ago` → 137。 */
166
198
  export function parseExitCode(status) {
167
199
  const match = /^\s*exited\s*\((\d+)\)/i.exec(status);
@@ -170,6 +202,64 @@ export function parseExitCode(status) {
170
202
  const code = Number(match[1]);
171
203
  return Number.isInteger(code) ? code : null;
172
204
  }
205
+ /* ------------------------------------------------------------------ *
206
+ * attention() 的判据常量(抽在模块级便于回归)
207
+ * ------------------------------------------------------------------ */
208
+ /**
209
+ * 「反复重启」的判据阈值(D11):重启计数 ≥ 该值且刚刚启动的运行中容器计入
210
+ * reasons。阈值不能太低——合法的重新部署也会重启一两次。
211
+ */
212
+ const ATTENTION_RESTART_THRESHOLD = 3;
213
+ /** crash-loop 疑似的「刚启动」窗口:重启退避最长 1 分钟,Up 时间几乎总是秒级。 */
214
+ const ATTENTION_FRESH_MS = 120_000;
215
+ /** inspect 批量上限(候选 + 补捞总量的保险丝):防巨型主机把输出顶到 maxBytes。 */
216
+ const ATTENTION_INSPECT_CAP = 300;
217
+ /**
218
+ * crash-loop **补捞的独立预算**(D87):合法三类候选(不健康 / 正在重启 / 非零退出)
219
+ * 先占 ATTENTION_INSPECT_CAP 的名额,补捞另有这 50 个名额。此前补捞与合法候选共用
220
+ * 同一个 300(`if (picked.length >= CAP) break`),主机越乱(300 个 unhealthy)
221
+ * 补捞越一个都进不去——与「反复重启最容易被忽略、才需要补捞」的初衷正好相反。
222
+ * 总量上限因此是 CAP + 本值(≈350),不会随主机规模放大。
223
+ */
224
+ const ATTENTION_FRESH_BUDGET = 50;
225
+ /**
226
+ * 单批 inspect 的 id 数硬上限(D86):默认 512KB ÷ 40 ≈ 12.8KB/容器,容得下真实的
227
+ * inspect JSON(Config/Labels/Mounts/NetworkSettings/HostConfig,2–6KB)。旧写法一次
228
+ * 送 300 个 id(512KB ÷ 300 ≈ 1.7KB/容器)必然截断,而 assertComplete 一抛错,
229
+ * catch 就把**整批**详情丢掉——比修复前的「尾部静默丢弃」更糟。
230
+ */
231
+ const ATTENTION_INSPECT_BATCH = 40;
232
+ /** 估算单个容器 inspect JSON 的字节数(D86):用于按 maxBytes 收缩单批大小。 */
233
+ const ATTENTION_INSPECT_BYTES_PER_CONTAINER = 8 * 1024;
234
+ /**
235
+ * 单批 inspect 的 id 数(D86):既有硬上限,也按调用方配置的 maxBytes 收缩——
236
+ * 把 maxOutputKb 调小的人不该每一批都撞截断(分批的意义就是让失败只影响一批)。
237
+ *
238
+ * 下限 8(而不是 1):`maxOutputKb` 被调到 1KB 这种极端值下,单容器 inspect 本身就会
239
+ * 超限,每批 1 个只会把「注定降级」变成最多 300 次串行 docker 调用;给个下限让调用次数
240
+ * 有界(≈38 批),降级信号照常由 `degraded` 给出、文案也会提示调大上限。
241
+ */
242
+ const ATTENTION_INSPECT_MIN_BATCH = 8;
243
+ function attentionInspectBatch(maxBytes) {
244
+ const byBudget = Math.floor(maxBytes / ATTENTION_INSPECT_BYTES_PER_CONTAINER);
245
+ return Math.min(ATTENTION_INSPECT_BATCH, Math.max(ATTENTION_INSPECT_MIN_BATCH, byBudget));
246
+ }
247
+ /**
248
+ * docker ps 的 Status 里「刚刚启动」的形状。**必须覆盖到 ATTENTION_FRESH_MS(D106)**:
249
+ * docker 的 HumanDuration 在 60–119s 给的是 `About a minute`,而判据窗口是 120s——
250
+ * 旧 RE 只认 `\d+ seconds?`(<60s),于是「跑 1–2 分钟才崩」的 crash-loop 在运行
251
+ * 阶段一次都不会入候选(只在刚好崩掉的那一瞬是 exited,很容易错过)。
252
+ * `\d+ minutes?` 由 RE 多放行、再由 freshStart 的 120s 判据挡回:多放行无害,
253
+ * 少放行才是静默漏报——两个窗口必须对齐。
254
+ */
255
+ const FRESH_UP_RE = /^up (less than a second|\d+ seconds?|about a minute|\d+ minutes?)( \(|$)/i;
256
+ /** `startedAt` 落在「刚启动」窗口内(crash-loop 容器最近一次拉起的时间)。 */
257
+ function freshStart(iso) {
258
+ if (iso === null || iso === '')
259
+ return false;
260
+ const time = Date.parse(iso);
261
+ return Number.isFinite(time) && Date.now() - time < ATTENTION_FRESH_MS;
262
+ }
173
263
  /** 从 ps 的 `.Status`(`Up 2 hours (healthy)`)推导状态。健康态单独由 deriveHealth 提供。 */
174
264
  export function deriveState(status) {
175
265
  const lower = status.toLowerCase();
@@ -203,7 +293,17 @@ export function deriveHealth(status) {
203
293
  const value = match[1].toLowerCase();
204
294
  return value === 'health: starting' ? 'starting' : value;
205
295
  }
206
- /** 解析 ps 的 `.Ports` 串:`0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 9000/tcp`。 */
296
+ /** 端口号 / 端口区间的解析结果(区间至少保留原文,不再整行丢弃);port 区间时取下界。 */
297
+ function parsePortToken(text) {
298
+ const single = Number(text);
299
+ if (Number.isInteger(single) && single > 0)
300
+ return { port: single };
301
+ const range = /^(\d+)-(\d+)$/.exec(text);
302
+ if (range !== null)
303
+ return { port: Number(range[1]), range: [Number(range[1]), Number(range[2])] };
304
+ return null;
305
+ }
306
+ /** 解析 ps 的 `.Ports` 串:`0.0.0.0:8080->80/tcp, [::]:8080->80/tcp, 9000/tcp, 0.0.0.0:8000-8005->8000-8005/tcp`。 */
207
307
  export function parsePorts(text) {
208
308
  const out = [];
209
309
  const seen = new Set();
@@ -215,34 +315,42 @@ export function parsePorts(text) {
215
315
  if (arrow.length === 2) {
216
316
  const [hostPart = '', containerPart = ''] = arrow;
217
317
  const [containerPortText = '', protocol = 'tcp'] = containerPart.split('/');
218
- const containerPort = Number(containerPortText);
219
- if (!Number.isInteger(containerPort))
318
+ const containerToken = parsePortToken(containerPortText);
319
+ if (containerToken === null)
220
320
  continue;
221
321
  const hostPortText = hostPart.slice(hostPart.lastIndexOf(':') + 1);
222
- const hostPort = Number(hostPortText);
322
+ const hostToken = parsePortToken(hostPortText);
223
323
  const hostIp = hostPart.slice(0, hostPart.lastIndexOf(':'));
224
324
  const key = `${hostIp}:${hostPortText}:${containerPortText}/${protocol}`;
225
325
  if (seen.has(key))
226
326
  continue;
227
327
  seen.add(key);
328
+ // 端口区间(`8000-8005->8000-8005/tcp`):保留区间字段(D40)——此前 Number 得
329
+ // NaN 直接 continue,整行端口凭空消失
228
330
  out.push({
229
331
  ...(hostIp === '' ? {} : { hostIp }),
230
- ...(Number.isInteger(hostPort) ? { hostPort } : {}),
231
- containerPort,
332
+ ...(hostToken?.port !== undefined ? { hostPort: hostToken.port } : {}),
333
+ ...(hostToken?.range !== undefined ? { hostPortRange: hostToken.range } : {}),
334
+ containerPort: containerToken.port,
335
+ ...(containerToken.range !== undefined ? { containerPortRange: containerToken.range } : {}),
232
336
  protocol,
233
337
  });
234
338
  continue;
235
339
  }
236
340
  // 仅暴露容器端口(未映射):`9000/tcp`
237
341
  const [containerPortText = '', protocol = 'tcp'] = item.split('/');
238
- const containerPort = Number(containerPortText);
239
- if (!Number.isInteger(containerPort))
342
+ const containerToken = parsePortToken(containerPortText);
343
+ if (containerToken === null)
240
344
  continue;
241
345
  const key = `-:${containerPortText}/${protocol}`;
242
346
  if (seen.has(key))
243
347
  continue;
244
348
  seen.add(key);
245
- out.push({ containerPort, protocol });
349
+ out.push({
350
+ containerPort: containerToken.port,
351
+ ...(containerToken.range !== undefined ? { containerPortRange: containerToken.range } : {}),
352
+ protocol,
353
+ });
246
354
  }
247
355
  return out;
248
356
  }
@@ -291,7 +399,10 @@ export function parseStatsJson(text) {
291
399
  const [memUsedText = '', memLimitText = ''] = mem.split('/');
292
400
  const net = parseIOPair(str(row, 'NetIO'));
293
401
  const block = parseIOPair(str(row, 'BlockIO'));
294
- const pids = Number(str(row, 'PIDs'));
402
+ // PIDs 缺字段时是空串:Number('') === 0 会显示「0 个进程」(D39),与同函数里
403
+ // memUsed / memLimit 缺字段时给 null 的口径对齐
404
+ const pidsText = str(row, 'PIDs');
405
+ const pids = pidsText.trim() === '' ? Number.NaN : Number(pidsText);
295
406
  return {
296
407
  id,
297
408
  shortId: id.slice(0, 12),
@@ -633,8 +744,8 @@ export function parseInspectJson(text) {
633
744
  health: typeof health.Status === 'string' ? health.Status : null,
634
745
  healthLogTail: typeof lastHealth.Output === 'string' ? lastHealth.Output.trim() : null,
635
746
  created: typeof row.Created === 'string' ? row.Created : null,
636
- startedAt: typeof state.StartedAt === 'string' ? state.StartedAt : null,
637
- finishedAt: typeof state.FinishedAt === 'string' ? state.FinishedAt : null,
747
+ startedAt: dockerTime(state.StartedAt),
748
+ finishedAt: dockerTime(state.FinishedAt),
638
749
  exitCode: typeof state.ExitCode === 'number' ? state.ExitCode : null,
639
750
  oomKilled: state.OOMKilled === true,
640
751
  restartCount: typeof row.RestartCount === 'number' ? row.RestartCount : null,
@@ -669,33 +780,59 @@ export function parseInspectJson(text) {
669
780
  function asRecord(value) {
670
781
  return typeof value === 'object' && value !== null ? value : {};
671
782
  }
783
+ /**
784
+ * docker 的零值时间不是有效时间(D41):从未退出的容器 `FinishedAt` 是
785
+ * `0001-01-01T00:00:00Z`(不是空串),`Date.parse` 还能解析成功 → 「最近出事
786
+ * 优先」排序被打乱、详情/hover 显示公元 1 年。归一成 null。
787
+ */
788
+ function dockerTime(value) {
789
+ if (typeof value !== 'string' || value === '')
790
+ return null;
791
+ if (value.startsWith('0001-01-01'))
792
+ return null;
793
+ return value;
794
+ }
672
795
  /** inspect 的 `NetworkSettings.Ports`:`{ "80/tcp": [{HostIp, HostPort}] }`。 */
673
796
  export function parseInspectPorts(value) {
674
797
  const ports = asRecord(value);
675
798
  const out = [];
676
799
  for (const [key, mappings] of Object.entries(ports)) {
677
800
  const [containerPortText = '', protocol = 'tcp'] = key.split('/');
678
- const containerPort = Number(containerPortText);
679
- if (!Number.isInteger(containerPort))
801
+ // 键本身可能是**端口区间**(`8000-8005/tcp`):复用 parsePortToken(D102)。
802
+ // 此前用 Number('8000-8005') 得 NaN → continue,整段端口凭空消失,详情页比
803
+ // 列表页(parsePorts 已在 D40 支持区间)少一条。
804
+ const containerToken = parsePortToken(containerPortText);
805
+ if (containerToken === null)
680
806
  continue;
807
+ const containerPort = containerToken.port;
808
+ const containerRange = containerToken.range === undefined ? {} : { containerPortRange: containerToken.range };
681
809
  const list = Array.isArray(mappings) ? mappings : [];
682
810
  if (list.length === 0) {
683
- out.push({ containerPort, protocol });
811
+ out.push({ containerPort, ...containerRange, protocol });
684
812
  continue;
685
813
  }
686
814
  const seen = new Set();
687
815
  for (const item of list) {
688
816
  const map = asRecord(item);
689
817
  const hostIp = typeof map.HostIp === 'string' ? map.HostIp : '';
690
- const hostPort = Number(map.HostPort);
691
- const dedupe = `${hostPort}`;
818
+ const hostPortRaw = map.HostPort;
819
+ const hostPortText = hostPortRaw === undefined || hostPortRaw === null ? '' : String(hostPortRaw);
820
+ // HostPort 与键同口径(D102):区间映射的 HostPort 也可能是 `8000-8005`;
821
+ // 缺失/空串时 parsePortToken 返回 null → 不产出 Number('') === 0 的幻影映射(D38)
822
+ const hostToken = parsePortToken(hostPortText);
823
+ const hostRange = hostToken === null || hostToken.range === undefined ? {} : { hostPortRange: hostToken.range };
824
+ // 去重键含 hostIp(D38):`-p 8080:8080` 的 inspect 会给 `0.0.0.0` 与 `::` 两条,
825
+ // 只按端口去重会把 IPv6 那条并掉,详情页比列表页少端口
826
+ const dedupe = `${hostIp}:${hostPortText}`;
692
827
  if (seen.has(dedupe))
693
828
  continue;
694
829
  seen.add(dedupe);
695
830
  out.push({
696
831
  ...(hostIp === '' ? {} : { hostIp }),
697
- ...(Number.isInteger(hostPort) ? { hostPort } : {}),
832
+ ...(hostToken === null ? {} : { hostPort: hostToken.port }),
833
+ ...hostRange,
698
834
  containerPort,
835
+ ...containerRange,
699
836
  protocol,
700
837
  });
701
838
  }
@@ -727,10 +864,37 @@ export class DockerApi {
727
864
  return { ok: false, serverVersion: null, error: error instanceof Error ? error.message : String(error), ...base };
728
865
  }
729
866
  }
867
+ /**
868
+ * assertOk + 截断检查(D13):列表 / 详情类方法拿到的必须是**完整**输出。
869
+ * 只检查 code 的话,「输出被截断」会被当成「本来就这么少」——面板静默少列
870
+ * 容器,inspect 系列更会把截断误报成「对象不存在」,把用户引向错误方向。
871
+ *
872
+ * `alternative` 是**这个调用方真正能执行的替代做法**(D105):agent 改不了插件
873
+ * 设置,「请调大 maxOutputKb」对它不可执行——ps 能改 all、stats 能传 ids、
874
+ * events 能缩小 since。文案里还会回显当前上限(见 truncationHint)。
875
+ */
876
+ assertComplete(result, what, alternative) {
877
+ this.assertOk(result, what);
878
+ if (result.truncated) {
879
+ throw new Error(`${what}的输出${this.truncationHint(alternative)}`);
880
+ }
881
+ }
882
+ /**
883
+ * 截断提示的统一文案(D105):带上当前上限(KB)与目标标签——只说「超过上限」
884
+ * 无法判断差多少;再拼上调用方的可执行替代做法。
885
+ * 不抛错的路径(imageInspect 的 history,D104)复用同一句话,保持口径一致。
886
+ */
887
+ truncationHint(alternative) {
888
+ const limitKb = Math.round(this.limits.maxBytes / 1024);
889
+ const suggest = alternative === undefined || alternative === ''
890
+ ? '调大「单次命令输出上限」(maxOutputKb)'
891
+ : `${alternative};或调大「单次命令输出上限」(maxOutputKb)`;
892
+ return `因超过上限被截断(目标 ${this.runner.label},当前上限 ${limitKb} KB):结果不完整。请${suggest}后重试`;
893
+ }
730
894
  async listContainers(all) {
731
895
  const argv = [this.bin, 'ps', ...(all ? ['-a'] : []), '--no-trunc', '--format', '{{json .}}'];
732
896
  const result = await this.runner.run(argv, { timeoutMs: this.limits.timeoutMs, maxBytes: this.limits.maxBytes });
733
- this.assertOk(result, '列出容器');
897
+ this.assertComplete(result, '列出容器', '改传 all=false(默认只看运行中的容器)');
734
898
  return parsePsJson(result.stdout);
735
899
  }
736
900
  async inspect(ids) {
@@ -741,16 +905,29 @@ export class DockerApi {
741
905
  timeoutMs: this.limits.timeoutMs,
742
906
  maxBytes: this.limits.maxBytes,
743
907
  });
744
- this.assertOk(result, '读取容器详情');
908
+ this.assertComplete(result, '读取容器详情', '改用更少的容器 id 分批读取');
745
909
  return parseInspectJson(result.stdout);
746
910
  }
747
911
  /**
748
912
  * 「需要关注」的容器(0.15.0):先按摘要筛候选(不健康 / 重启中 / 僵死 /
749
- * 非零退出),再**一次** `docker inspect` 补权威字段——OOM 与真实退出码在 ps
750
- * 摘要里拿不到(137 也可能是手动 kill),只看摘要会误报。inspect 失败时退回摘要。
913
+ * 非零退出),再**分块** `docker inspect` 补权威字段——OOM 与真实退出码在 ps
914
+ * 摘要里拿不到(137 也可能是手动 kill),只看摘要会误报。inspect 失败时退回摘要,
915
+ * 但一定带 `degraded` 信号(D85/D86)。
916
+ *
917
+ * 反复重启(D11):crash-loop 的容器多数时间显示 Up(退避最长 1 分钟,其余
918
+ * 时间在跑),RestartCount 只在 inspect 里有——摘要筛不出来。这里把「刚刚
919
+ * 启动」的运行中容器一并送进 inspect,按「重启计数高 + 刚启动」补捞;补捞有
920
+ * 独立预算(D87):合法候选再多也挤不掉它。
921
+ *
922
+ * 截断在过滤 + 排序**之后**(D12):先切后拍的话,名额被一堆老的非零退出
923
+ * 容器占满时,最严重的 OOM / unhealthy 反而会被切掉;返回值带 total / truncated
924
+ * 截断信号,不静默。
751
925
  */
752
926
  async attention(options) {
753
927
  const containers = await this.listContainers(true);
928
+ const limit = Math.min(Math.max(Math.trunc(options?.limit ?? 100), 1), 500);
929
+ // 合法三类候选(不健康 / 重启中 / 僵死 / 非零退出):**不设总量闸**——超出的
930
+ // 部分仍然进 items(它们是真实的问题容器),只是拿不到详情,见下面的 uninspected
754
931
  const candidates = containers.filter((item) => {
755
932
  if (item.health === 'unhealthy')
756
933
  return true;
@@ -760,28 +937,65 @@ export class DockerApi {
760
937
  return true;
761
938
  return false;
762
939
  });
763
- if (candidates.length === 0)
764
- return [];
765
- const limit = Math.min(Math.max(Math.trunc(options?.limit ?? 100), 1), 500);
766
- const picked = candidates.slice(0, limit);
767
- const details = new Map();
768
- try {
769
- for (const detail of await this.inspect(picked.map((item) => item.id)))
770
- details.set(detail.id, detail);
940
+ // crash-loop 补捞(D11):合法候选优先,但补捞有**独立预算**(D87)——此前共用
941
+ // ATTENTION_INSPECT_CAP,300 个 unhealthy 一占满,真正的 crash-loop 一个都进不来
942
+ const candidateIds = new Set(candidates.map((item) => item.id));
943
+ const supplemented = [];
944
+ let droppedFresh = 0;
945
+ for (const item of containers) {
946
+ if (candidateIds.has(item.id))
947
+ continue;
948
+ if (!(item.state === 'running' && FRESH_UP_RE.test(item.status)))
949
+ continue;
950
+ if (supplemented.length >= ATTENTION_FRESH_BUDGET) {
951
+ // 预算吃满后仍然「刚启动」的容器没被检查:计入降级信号(D87),不静默
952
+ droppedFresh += 1;
953
+ continue;
954
+ }
955
+ supplemented.push(item);
771
956
  }
772
- catch {
773
- /* inspect 不可用(权限/超时)时退回摘要数据 */
957
+ const picked = [...candidates, ...supplemented];
958
+ if (picked.length === 0)
959
+ return { items: [], total: 0, truncated: false, degraded: false };
960
+ // inspect 集 = 合法候选的前 CAP 条 + **全部**补捞(总量 ≤ CAP + FRESH_BUDGET)
961
+ const inspectIds = [...candidates.slice(0, ATTENTION_INSPECT_CAP), ...supplemented].map((item) => item.id);
962
+ const details = new Map();
963
+ // inspect 失败必须有信号(D42):静默退回摘要口径会把 OOM 降级成 exit-nonzero、
964
+ // 重启次数 / 时间全部缺失,调用方无法区分「没有 OOM」与「权威数据没取到」。
965
+ // 因此**分块** inspect、每块独立 catch(D86):一块 300 个 id 时 512KB 必然截断,
966
+ // 旧写法一抛错就是整批详情全丢——分块后失败只丢那一块,其余块照常可用。
967
+ let uninspected = picked.length - inspectIds.length + droppedFresh;
968
+ const batch = attentionInspectBatch(this.limits.maxBytes);
969
+ for (let offset = 0; offset < inspectIds.length; offset += batch) {
970
+ const chunk = inspectIds.slice(offset, offset + batch);
971
+ try {
972
+ for (const detail of await this.inspect(chunk))
973
+ details.set(detail.id, detail);
974
+ }
975
+ catch {
976
+ uninspected += chunk.length;
977
+ }
774
978
  }
979
+ // 「有候选没取到详情」同样是降级(D85):旧写法只在整批抛错时置位,于是 320 个
980
+ // 候选里第 301–320 条静默退回摘要口径(真 OOM 被降成 exit-nonzero、排到最后,
981
+ // 默认 limit 一截就没了),调用方却看到 degraded=false 而以为数据齐全
982
+ const degraded = uninspected > 0;
775
983
  const items = picked.map((item) => {
776
984
  const detail = details.get(item.id);
777
985
  const health = detail?.health ?? item.health;
778
986
  const exitCode = detail?.exitCode ?? item.exitCode;
779
987
  const oomKilled = detail?.oomKilled === true;
988
+ const restartCount = detail?.restartCount ?? null;
780
989
  const reasons = [];
781
990
  if (health === 'unhealthy')
782
991
  reasons.push('unhealthy');
783
- if (item.state === 'restarting' || detail?.state === 'restarting')
992
+ if (item.state === 'restarting' || detail?.state === 'restarting') {
784
993
  reasons.push('restarting');
994
+ }
995
+ else if (restartCount !== null && restartCount >= ATTENTION_RESTART_THRESHOLD && freshStart(detail?.startedAt ?? null)) {
996
+ // 运行中但刚启动 + 重启计数高 = 疑似 crash-loop(D11)
997
+ reasons.push('restarting');
998
+ }
785
999
  if (oomKilled)
786
1000
  reasons.push('oom');
787
1001
  else if (exitCode !== null && exitCode !== 0)
@@ -799,7 +1013,7 @@ export class DockerApi {
799
1013
  reasons,
800
1014
  exitCode,
801
1015
  oomKilled,
802
- restartCount: detail?.restartCount ?? null,
1016
+ restartCount,
803
1017
  startedAt: detail?.startedAt ?? null,
804
1018
  finishedAt: detail?.finishedAt ?? null,
805
1019
  };
@@ -824,9 +1038,10 @@ export class DockerApi {
824
1038
  const time = Date.parse(iso);
825
1039
  return Number.isFinite(time) ? time : 0;
826
1040
  };
827
- return items
1041
+ const sorted = items
828
1042
  .filter((item) => item.reasons.length > 0)
829
1043
  .sort((a, b) => weight(a) - weight(b) || at(b) - at(a) || a.name.localeCompare(b.name));
1044
+ return { items: sorted.slice(0, limit), total: sorted.length, truncated: sorted.length > limit, degraded };
830
1045
  }
831
1046
  async stats(ids) {
832
1047
  const safe = ids.map((id) => assertRef(id, 'container'));
@@ -834,7 +1049,7 @@ export class DockerApi {
834
1049
  timeoutMs: Math.max(this.limits.timeoutMs, 20_000),
835
1050
  maxBytes: this.limits.maxBytes,
836
1051
  });
837
- this.assertOk(result, '读取容器统计');
1052
+ this.assertComplete(result, '读取容器统计', '传具体 ids(docker_stats 的 ids 参数)而不是全量');
838
1053
  return parseStatsJson(result.stdout);
839
1054
  }
840
1055
  /**
@@ -873,7 +1088,7 @@ export class DockerApi {
873
1088
  timeoutMs: Math.max(this.limits.timeoutMs, 30_000),
874
1089
  maxBytes: this.limits.maxBytes,
875
1090
  });
876
- this.assertOk(result, '读取容器事件');
1091
+ this.assertComplete(result, '读取容器事件', '缩小 since 窗口(如 10m)');
877
1092
  return parseEventsJson(result.stdout);
878
1093
  }
879
1094
  /** 事件 argv 的唯一构造点:流式与快照只差 --since / --until。 */
@@ -894,7 +1109,7 @@ export class DockerApi {
894
1109
  timeoutMs: this.limits.timeoutMs,
895
1110
  maxBytes: this.limits.maxBytes,
896
1111
  });
897
- this.assertOk(result, '列出镜像');
1112
+ this.assertComplete(result, '列出镜像', 'agent 侧没有分页参数,这是该目标的全量镜像列表');
898
1113
  return parseImagesJson(result.stdout);
899
1114
  }
900
1115
  /**
@@ -911,7 +1126,7 @@ export class DockerApi {
911
1126
  timeoutMs: this.limits.timeoutMs,
912
1127
  maxBytes: this.limits.maxBytes,
913
1128
  });
914
- this.assertOk(result, '读取镜像详情');
1129
+ this.assertComplete(result, '读取镜像详情');
915
1130
  const detail = parseImageInspectJson(result.stdout)[0];
916
1131
  if (detail === undefined)
917
1132
  throw new Error(`镜像不存在或输出无法解析:${safe}`);
@@ -926,6 +1141,10 @@ export class DockerApi {
926
1141
  history = parseImageHistoryJson(jsonAttempt.stdout);
927
1142
  if (history.length === 0)
928
1143
  history = parseImageHistoryText(jsonAttempt.stdout);
1144
+ // 截断必须留痕(D104):层数多的镜像会静默「变短」,而 historyError 是这条
1145
+ // 降级路径唯一的信号位——写进去而不是抛错(历史取不到不该整页报错)
1146
+ if (jsonAttempt.truncated)
1147
+ historyError = `构建历史${this.truncationHint()}`;
929
1148
  }
930
1149
  else {
931
1150
  // 老 docker 没有 history --format:退回纯文本表格(仍带 --no-trunc 拿完整命令)
@@ -933,10 +1152,14 @@ export class DockerApi {
933
1152
  timeoutMs: this.limits.timeoutMs,
934
1153
  maxBytes: this.limits.maxBytes,
935
1154
  });
936
- if (plainAttempt.code === 0)
1155
+ if (plainAttempt.code === 0) {
937
1156
  history = parseImageHistoryText(plainAttempt.stdout);
938
- else
1157
+ if (plainAttempt.truncated)
1158
+ historyError = `构建历史${this.truncationHint()}`;
1159
+ }
1160
+ else {
939
1161
  historyError = firstLine(plainAttempt.stderr, plainAttempt.stdout, plainAttempt.code);
1162
+ }
940
1163
  }
941
1164
  }
942
1165
  catch (error) {
@@ -970,6 +1193,7 @@ export class DockerApi {
970
1193
  const result = await this.runner.run([this.bin, 'image', 'prune', '-f'], {
971
1194
  timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
972
1195
  maxBytes: 256 * 1024,
1196
+ keepTail: true, // Total reclaimed space 在输出末尾(D14)
973
1197
  });
974
1198
  this.assertOk(result, '清理 dangling 镜像');
975
1199
  return { message: result.stdout.trim() || 'ok' };
@@ -980,7 +1204,7 @@ export class DockerApi {
980
1204
  timeoutMs: this.limits.timeoutMs,
981
1205
  maxBytes: this.limits.maxBytes,
982
1206
  });
983
- this.assertOk(result, '列出网络');
1207
+ this.assertComplete(result, '列出网络', 'agent 侧没有分页参数,这是该目标的全量网络列表');
984
1208
  return parseNetworksJson(result.stdout);
985
1209
  }
986
1210
  /**
@@ -993,7 +1217,7 @@ export class DockerApi {
993
1217
  timeoutMs: this.limits.timeoutMs,
994
1218
  maxBytes: this.limits.maxBytes,
995
1219
  });
996
- this.assertOk(result, '读取网络详情');
1220
+ this.assertComplete(result, '读取网络详情');
997
1221
  const detail = parseNetworkInspectJson(result.stdout)[0];
998
1222
  if (detail === undefined)
999
1223
  throw new Error(`网络不存在或输出无法解析:${safe}`);
@@ -1025,6 +1249,7 @@ export class DockerApi {
1025
1249
  const result = await this.runner.run([this.bin, 'network', 'prune', '-f'], {
1026
1250
  timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
1027
1251
  maxBytes: 256 * 1024,
1252
+ keepTail: true,
1028
1253
  });
1029
1254
  this.assertOk(result, '清理未使用的网络');
1030
1255
  return { message: result.stdout.trim() || 'ok' };
@@ -1035,7 +1260,7 @@ export class DockerApi {
1035
1260
  timeoutMs: this.limits.timeoutMs,
1036
1261
  maxBytes: this.limits.maxBytes,
1037
1262
  });
1038
- this.assertOk(result, '列出卷');
1263
+ this.assertComplete(result, '列出卷', 'agent 侧没有分页参数,这是该目标的全量卷列表');
1039
1264
  return parseVolumesJson(result.stdout);
1040
1265
  }
1041
1266
  /** 卷详情(`docker volume inspect <name>`)。 */
@@ -1045,7 +1270,7 @@ export class DockerApi {
1045
1270
  timeoutMs: this.limits.timeoutMs,
1046
1271
  maxBytes: this.limits.maxBytes,
1047
1272
  });
1048
- this.assertOk(result, '读取卷详情');
1273
+ this.assertComplete(result, '读取卷详情');
1049
1274
  const detail = parseVolumeInspectJson(result.stdout)[0];
1050
1275
  if (detail === undefined)
1051
1276
  throw new Error(`卷不存在或输出无法解析:${safe}`);
@@ -1078,6 +1303,7 @@ export class DockerApi {
1078
1303
  const result = await this.runner.run([this.bin, 'volume', 'prune', '-f'], {
1079
1304
  timeoutMs: Math.max(this.limits.timeoutMs, 120_000),
1080
1305
  maxBytes: 256 * 1024,
1306
+ keepTail: true,
1081
1307
  });
1082
1308
  this.assertOk(result, '清理未使用的卷');
1083
1309
  return { message: result.stdout.trim() || 'ok' };
@@ -1098,16 +1324,29 @@ export class DockerApi {
1098
1324
  const result = await this.runner.run([this.bin, 'pull', safe], {
1099
1325
  timeoutMs: Math.min(Math.max(timeoutMs ?? 600_000, 10_000), 1_800_000),
1100
1326
  maxBytes: this.limits.maxBytes,
1327
+ keepTail: true, // digest / Downloaded 结论在输出末尾(D14)
1101
1328
  });
1329
+ // 超时必须报错而不是当成功返回(D16):模型看到半截「Downloading 12MB/80MB」
1330
+ // 且没有 error,会判定为已拉取,直到 start 才暴露 image not found
1331
+ if (result.timedOut) {
1332
+ throw new Error(`拉取 ${safe} 超时中止(已运行 ${String(Math.round(result.durationMs / 1000))}s),镜像未拉完。可加大 timeoutSec 后重试,或在面板镜像页用进度流观察`);
1333
+ }
1102
1334
  const text = result.stdout + (result.stderr === '' ? '' : (result.stdout === '' ? '' : '\n') + result.stderr);
1103
1335
  return { ref: safe, code: result.code, text, truncated: result.truncated, durationMs: result.durationMs };
1104
1336
  }
1105
- /** 日志:stdout / stderr 分别收,再按到达顺序合并(docker logs 两者都有内容)。 */
1337
+ /**
1338
+ * 日志:stdout / stderr 分别收,stdout 整段在前、stderr 在后。
1339
+ *
1340
+ * 注意这不是「按到达顺序合并」(D44 注释纠正):一次性命令的两路输出在
1341
+ * run() 里分别累积,时序信息已经丢了;容器交替写两路时快照日志的先后顺序
1342
+ * 与真实到达序可能不同。要真实时序请用实时跟随(FOLLOW 流是逐块按到达序推的)。
1343
+ */
1106
1344
  async logs(id, options) {
1107
1345
  const safe = assertRef(id, 'container');
1108
1346
  const result = await this.runner.run(this.logsArgv(safe, options, false), {
1109
1347
  timeoutMs: this.limits.timeoutMs,
1110
1348
  maxBytes: this.limits.maxBytes,
1349
+ keepTail: true, // 超限时该丢的是最旧的行,最新行在尾部(D14)
1111
1350
  });
1112
1351
  this.assertOk(result, '读取容器日志');
1113
1352
  const text = result.stdout + (result.stderr === '' ? '' : (result.stdout === '' ? '' : '\n') + result.stderr);