@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/README.en.md +31 -16
- package/README.md +31 -18
- package/client-src/current-session.js +52 -0
- package/client-src/docker.css +2180 -0
- package/client-src/index.js +6691 -0
- package/client-src/session-target.js +92 -0
- package/client.js +46 -24
- package/lib/docker.d.ts +62 -5
- package/lib/docker.js +287 -48
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +19 -1
- package/lib/index.js +606 -80
- package/lib/index.js.map +1 -1
- package/lib/ssh-exec.d.ts +30 -11
- package/lib/ssh-exec.js +360 -143
- package/lib/ssh-exec.js.map +1 -1
- package/package.json +4 -2
- package/scripts/build-client.mjs +33 -0
- package/scripts/client-smoke.mjs +1851 -0
- package/scripts/route-smoke.mjs +1091 -0
- package/scripts/smoke.mjs +874 -0
package/lib/docker.js
CHANGED
|
@@ -23,10 +23,11 @@ export function assertRef(value, field) {
|
|
|
23
23
|
* 填不进自己的 docker —— 而填不进时拿到的是 400,却没有一句话解释为什么(真机实测)。
|
|
24
24
|
*
|
|
25
25
|
* 仍然拒绝:`;` `&` `|` `<` `>` 引号 反引号 `$` `%` `!` `^` 换行等 shell 元字符,
|
|
26
|
-
*
|
|
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
|
-
/**
|
|
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
|
|
219
|
-
if (
|
|
318
|
+
const containerToken = parsePortToken(containerPortText);
|
|
319
|
+
if (containerToken === null)
|
|
220
320
|
continue;
|
|
221
321
|
const hostPortText = hostPart.slice(hostPart.lastIndexOf(':') + 1);
|
|
222
|
-
const
|
|
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
|
-
...(
|
|
231
|
-
|
|
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
|
|
239
|
-
if (
|
|
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({
|
|
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
|
-
|
|
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:
|
|
637
|
-
finishedAt:
|
|
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
|
-
|
|
679
|
-
|
|
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
|
|
691
|
-
const
|
|
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
|
-
...(
|
|
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.
|
|
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.
|
|
908
|
+
this.assertComplete(result, '读取容器详情', '改用更少的容器 id 分批读取');
|
|
745
909
|
return parseInspectJson(result.stdout);
|
|
746
910
|
}
|
|
747
911
|
/**
|
|
748
912
|
* 「需要关注」的容器(0.15.0):先按摘要筛候选(不健康 / 重启中 / 僵死 /
|
|
749
|
-
*
|
|
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
|
-
|
|
764
|
-
|
|
765
|
-
const
|
|
766
|
-
const
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
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
|
-
|
|
773
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
/**
|
|
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);
|