@hyzyn/dsh-docker 0.7.2 → 0.8.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 +20 -5
- package/README.md +31 -13
- package/client-src/docker.css +13 -13
- package/client-src/index.js +466 -246
- package/client-src/log-buffer.js +205 -0
- package/client-src/log-stream.js +143 -0
- package/client.js +37 -36
- package/lib/docker.d.ts +18 -3
- package/lib/docker.js +54 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.d.ts +13 -2
- package/lib/index.js +149 -37
- package/lib/index.js.map +1 -1
- package/package.json +14 -6
- package/scripts/client-smoke.mjs +261 -0
- package/scripts/log-perf.mjs +206 -0
- package/scripts/route-smoke.mjs +51 -19
- package/scripts/smoke.mjs +59 -1
package/lib/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import z from '@deepseek-ai/schemastery';
|
|
2
|
-
import { definePlugin } from '@hyzyn/dsh-kit';
|
|
2
|
+
import { definePlugin, plainConfig, readSettingsEntry, settingsEntryScope, suppressAutoSettingsPage } from '@hyzyn/dsh-kit';
|
|
3
3
|
import { defineTool } from '@deepseek-ai/dsh-tools';
|
|
4
4
|
import * as dns from 'node:dns';
|
|
5
|
-
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, } from './docker.js';
|
|
5
|
+
import { DockerApi, assertBin, assertImageRef, assertName, assertRef, assertSince, createRunner, parseImageHistoryJson, parseImageHistoryText, parseContainerEvent, parseEventsJson, parseImageInspectJson, parseInspectJson, parsePsJson, parseStatsJson, suggestContainerNames, } from './docker.js';
|
|
6
6
|
import { RemoteExec, setCredentialResolver, sshTarget } from './ssh-exec.js';
|
|
7
7
|
const TARGET_SCHEMA = z.object({
|
|
8
8
|
name: z.string().required(),
|
|
@@ -27,18 +27,26 @@ const HOST_KEY_SCHEMA = z.object({
|
|
|
27
27
|
/** 旧版单指纹字段:仅作迁移输入(sanitizeHostKeys 会并进 fingerprints)。 */
|
|
28
28
|
fingerprint: z.string().default(''),
|
|
29
29
|
});
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
/**
|
|
31
|
+
* 运行时 Config schema——DSH ≥0.1.7 起同时就是本插件的 settings 存储。
|
|
32
|
+
*
|
|
33
|
+
* 全部字段都标 `.volatile()`:它们都是卡片可改项(见 KNOWN_CONFIG_KEYS),而
|
|
34
|
+
* `settings.update(entryId, patch)` 只接受 volatile 路径;loader 对 volatile-only
|
|
35
|
+
* 变更原地更新引用并发 `loader/volatile-update`,不重挂插件——插件订阅后走
|
|
36
|
+
* `applySection` 热应用(见 kit 的 settingsEntryScope)。
|
|
37
|
+
*/
|
|
38
|
+
export const Config = z.object({
|
|
39
|
+
enabled: z.boolean().default(true).volatile(),
|
|
40
|
+
announceToAgent: z.boolean().default(true).volatile(),
|
|
41
|
+
dockerBin: z.string().default('docker').volatile(),
|
|
42
|
+
allowMutations: z.boolean().default(false).volatile(),
|
|
43
|
+
allowExec: z.boolean().default(false).volatile(),
|
|
44
|
+
execTimeoutSec: z.natural().max(120).default(30).volatile(),
|
|
45
|
+
pollIntervalSec: z.natural().max(60).default(5).volatile(),
|
|
46
|
+
logTailDefault: z.natural().max(5000).default(200).volatile(),
|
|
47
|
+
maxOutputKb: z.natural().max(8192).default(512).volatile(),
|
|
48
|
+
targets: z.array(TARGET_SCHEMA).default([]).volatile(),
|
|
49
|
+
hostKeys: z.array(HOST_KEY_SCHEMA).default([]).volatile(),
|
|
42
50
|
});
|
|
43
51
|
/** 可经 HTTP POST 写入的配置键(白名单)。 */
|
|
44
52
|
const KNOWN_CONFIG_KEYS = new Set([
|
|
@@ -74,7 +82,49 @@ const MUTATION_SUBROUTES = new Set([
|
|
|
74
82
|
'/volumes/prune',
|
|
75
83
|
'/exec',
|
|
76
84
|
]);
|
|
77
|
-
|
|
85
|
+
/** 「这条错误来自目标侧」的标记(见 guardTargetFailures)。 */
|
|
86
|
+
const TARGET_FAILURE = Symbol('dsh-docker.target-failure');
|
|
87
|
+
/**
|
|
88
|
+
* 给 `DockerApi` 包一层:**只有**从目标 API 抛出的错误才打上 `TARGET_FAILURE` 标记。
|
|
89
|
+
*
|
|
90
|
+
* 为什么需要这个标记:单目标数据路由原来是「api 抛什么,外层 catch 就统一回 500」。
|
|
91
|
+
* 而 SSH 连不上 / 私钥读不到 / docker 不在 PATH 这些都是**运维状况**,不是插件 bug——
|
|
92
|
+
* 多目标聚合路径对同一件事早就回 200 + `ok:false`(`aggregateAcrossTargets` 把 `run()`
|
|
93
|
+
* 的抛错收成每个目标一条 `ok:false`)。于是同一句 `EPERM: open '…id_ed25519'` 在
|
|
94
|
+
* `target='*'` 下是「可读原因」、在单目标下是「不透明 500」:面板点一个连不上的目标就长这样。
|
|
95
|
+
*
|
|
96
|
+
* 为什么不逐个调用点包 try/catch:那要改 20 处,而且**必然漏几个**——本仓「修到一半」
|
|
97
|
+
* 的老毛病(D80–D125 那批 29 条)。包一次就没有漏网之鱼。
|
|
98
|
+
*
|
|
99
|
+
* 为什么不是「把外层 catch 全改成 200 + ok:false」:那会把我们自己的 bug(比如某天
|
|
100
|
+
* 解析写错抛的 TypeError)也伪装成「目标不可达」,正是本仓最忌讳的**静默错误结果**。
|
|
101
|
+
* 打标记之后,没标记的错误仍旧 500。
|
|
102
|
+
*/
|
|
103
|
+
function guardTargetFailures(api) {
|
|
104
|
+
return new Proxy(api, {
|
|
105
|
+
get(target, prop, receiver) {
|
|
106
|
+
const value = Reflect.get(target, prop, receiver);
|
|
107
|
+
if (typeof value !== 'function')
|
|
108
|
+
return value;
|
|
109
|
+
return async (...args) => {
|
|
110
|
+
try {
|
|
111
|
+
return await value.apply(target, args);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (error !== null && typeof error === 'object') {
|
|
115
|
+
Object.defineProperty(error, TARGET_FAILURE, { value: true, configurable: true });
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
/** 这条错误是不是从目标 API 抛出来的(见 guardTargetFailures)。 */
|
|
124
|
+
function isTargetFailure(error) {
|
|
125
|
+
return error !== null && typeof error === 'object' && error[TARGET_FAILURE] === true;
|
|
126
|
+
}
|
|
127
|
+
const DOCKER_GUIDANCE = '本机已安装 dsh-docker 插件(Docker 容器面板):Web GUI 侧边栏「容器」入口可查看各目标(本机 / SSH 主机)上的容器列表(含 Compose 项目视图、事件「活动」条)、状态、端口、日志(含实时跟随)与资源占用(含实时跟随 + 迷你趋势图),以及镜像列表与镜像详情(层 / 大小 / 构建历史、拉取进度流)、网络与卷(列表 + 详情;删除 / 清理同样在开关之后);目标在 插件配置 → Docker 容器面板 里维护(SSH 目标可直接引用 tty 终端面板的连接簿条目)。**默认只读**:启动/停止/重启/删除容器、删除镜像 / 清理 dangling / 拉取镜像、docker exec,都需要用户在设置里显式打开「允许变更操作」「允许 exec」后才有对应工具与按钮。agent 侧配套只读工具 docker_targets(列目标)、docker_ps(列容器,含 compose 项目与服务;**target 传 `*` 可一次列出所有目标**;端口已按 IPv4/IPv6 双栈归并,`ports` 为空时看 `net`——host 网络容器的端口即宿主机端口)、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 权限,不要在用户未明确要求时执行变更操作。';
|
|
78
128
|
/**
|
|
79
129
|
* SSE 帧封装:data 一律 `JSON.stringify` 成**单行**——换行 / 引号被转义,
|
|
80
130
|
* 多字节字符也不会被 SSE 的 `\n` 行边界截断(客户端 JSON.parse 还原)。
|
|
@@ -432,7 +482,7 @@ export function formatBytes(value) {
|
|
|
432
482
|
const text = unit === 0 ? String(Math.round(size)) : size.toFixed(size >= 100 ? 0 : 1);
|
|
433
483
|
return `${text} ${units[unit] ?? 'B'}`;
|
|
434
484
|
}
|
|
435
|
-
/** 从 tty 的 settings
|
|
485
|
+
/** 从 tty 的 entry settings 读取连接簿(只读;tty 未安装时为空表)。 */
|
|
436
486
|
function readTtyBooks(settings) {
|
|
437
487
|
const out = new Map();
|
|
438
488
|
if (settings === undefined)
|
|
@@ -526,8 +576,10 @@ export function resolveTarget(target, books) {
|
|
|
526
576
|
* ------------------------------------------------------------------ */
|
|
527
577
|
const plugin = definePlugin({
|
|
528
578
|
name: 'docker',
|
|
529
|
-
apply(ctx,
|
|
530
|
-
|
|
579
|
+
apply(ctx, rawConfig) {
|
|
580
|
+
// volatile 字段解析后是 `{ get() }` 引用,先还原成纯数据(见 @hyzyn/dsh-kit 的 plainConfig)。
|
|
581
|
+
const config = plainConfig((rawConfig ?? {}));
|
|
582
|
+
let live = normalizeConfig(config);
|
|
531
583
|
if (!live.enabled)
|
|
532
584
|
return;
|
|
533
585
|
const logger = {
|
|
@@ -536,6 +588,11 @@ const plugin = definePlugin({
|
|
|
536
588
|
};
|
|
537
589
|
/* ---------- 主机指纹(TOFU,本插件自持;tty 记录作种子) ---------- */
|
|
538
590
|
let settingsScope;
|
|
591
|
+
/**
|
|
592
|
+
* 只读他人 entry(如 tty 的连接簿):旧 `settings.get(ns)` 在新宿主已不存在,
|
|
593
|
+
* 改走 `settings.describe()` 的 entry 查找(kit 的 readSettingsEntry)。settings
|
|
594
|
+
* 子上下文就绪前保持 undefined,调用方当空表处理——与旧行为一致。
|
|
595
|
+
*/
|
|
539
596
|
let settingsApi;
|
|
540
597
|
const ttySeed = () => {
|
|
541
598
|
const map = new Map();
|
|
@@ -589,7 +646,7 @@ const plugin = definePlugin({
|
|
|
589
646
|
const remote = new RemoteExec(logger, hostKeyStore);
|
|
590
647
|
/**
|
|
591
648
|
* 当前生效的 targets:**以 settings 解析值为准**。
|
|
592
|
-
* 为什么不能只读内存里的 live:settings
|
|
649
|
+
* 为什么不能只读内存里的 live:settings 解析是异步的(服务就绪后才读一次),存在一个
|
|
593
650
|
* 窗口期内 live 仍是 composition 配置(targets 为空)。若此时 GET /config 被
|
|
594
651
|
* 调用,卡片会拿到空目标列表,用户随后保存就把空数组写回 → 目标被清空。
|
|
595
652
|
*/
|
|
@@ -827,6 +884,9 @@ const plugin = definePlugin({
|
|
|
827
884
|
* 客户端事实上已死(消费速度跟不上产出),主动收尾——宿主内存上限从
|
|
828
885
|
* 「无界」变成「每条流 ≤ MAX_PENDING_BYTES」。上游(docker logs -f 的
|
|
829
886
|
* stdout)由 finish/clientGone 里的 abort 停掉,不需要逐帧 pause。
|
|
887
|
+
*
|
|
888
|
+
* 溢出收尾会补一条 `end{reason:'output-limit'}`(D133):客户端据此提示
|
|
889
|
+
* 「主机侧积压」而不是当成正常结束。
|
|
830
890
|
*/
|
|
831
891
|
const MAX_PENDING_BYTES = 8 * 1024 * 1024;
|
|
832
892
|
let pendingFrames = [];
|
|
@@ -901,8 +961,16 @@ const plugin = definePlugin({
|
|
|
901
961
|
return;
|
|
902
962
|
pendingFrames.push(frame);
|
|
903
963
|
pendingBytes += frame.length;
|
|
904
|
-
if (pendingBytes > MAX_PENDING_BYTES)
|
|
964
|
+
if (pendingBytes > MAX_PENDING_BYTES) {
|
|
965
|
+
/*
|
|
966
|
+
* 队列溢出 = 客户端消费速度跟不上产出(D133):收尾前补一条 end,
|
|
967
|
+
* 让客户端知道「是主机侧积压」而不是把它当成正常结束。静默 res.end()
|
|
968
|
+
* 会被 EventSource 判为流正常结束并自动重连——而自动重连复用带 tail
|
|
969
|
+
* 的 URL,服务端就会把历史整段重推一遍(重复回填)。
|
|
970
|
+
*/
|
|
971
|
+
pendingFrames.push(sseFrame('end', { reason: 'output-limit', code: null }));
|
|
905
972
|
finish();
|
|
973
|
+
}
|
|
906
974
|
};
|
|
907
975
|
const sendEvent = (event, data) => send(sseFrame(event, data));
|
|
908
976
|
const handle = { end: finish };
|
|
@@ -1263,7 +1331,7 @@ const plugin = definePlugin({
|
|
|
1263
1331
|
}));
|
|
1264
1332
|
add('docker_ps', defineTool({
|
|
1265
1333
|
name: 'docker_ps',
|
|
1266
|
-
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` =
|
|
1334
|
+
description: '列出容器(默认只列运行中的;all:true 含已停止)。**target 传 `*` = 一次列出所有目标**(跨主机,按目标分组返回,单个目标不可达不影响其他目标)。排障第一步。注意:`ports` 为空**不等于**「没暴露端口」——host 网络容器的端口就是宿主机端口、ps 里没有映射,这种情况会给 `net` 字段(如 `net:"host"`),别为此再逐个 docker_inspect。',
|
|
1267
1335
|
parameters: {
|
|
1268
1336
|
target: { type: 'string', description: '目标名;传 `*` 或省略(仅一个目标时)表示当前目标/全部目标(docker_targets 列出)' },
|
|
1269
1337
|
all: { type: 'boolean', description: 'true 时包含已停止容器(默认 false)' },
|
|
@@ -1287,6 +1355,7 @@ const plugin = definePlugin({
|
|
|
1287
1355
|
status: { type: 'string', required: true },
|
|
1288
1356
|
health: { type: 'string' },
|
|
1289
1357
|
ports: { type: 'string' },
|
|
1358
|
+
net: { type: 'string' },
|
|
1290
1359
|
composeProject: { type: 'string' },
|
|
1291
1360
|
composeService: { type: 'string' },
|
|
1292
1361
|
},
|
|
@@ -1315,6 +1384,7 @@ const plugin = definePlugin({
|
|
|
1315
1384
|
status: { type: 'string', required: true },
|
|
1316
1385
|
health: { type: 'string' },
|
|
1317
1386
|
ports: { type: 'string' },
|
|
1387
|
+
net: { type: 'string' },
|
|
1318
1388
|
composeProject: { type: 'string' },
|
|
1319
1389
|
composeService: { type: 'string' },
|
|
1320
1390
|
},
|
|
@@ -1345,6 +1415,9 @@ const plugin = definePlugin({
|
|
|
1345
1415
|
status: row.status,
|
|
1346
1416
|
...(row.health === null ? {} : { health: row.health }),
|
|
1347
1417
|
ports: row.ports.map((p) => (p.hostPort === undefined ? `${String(p.containerPort)}/${p.protocol}` : `${String(p.hostPort)}→${String(p.containerPort)}/${p.protocol}`)).join(','),
|
|
1418
|
+
// 端口为空才补 net(D131):host 网络容器的端口即宿主机端口、ps 没有映射,
|
|
1419
|
+
// 空 ports 与「没暴露端口」否则无法区分。有映射时不加,避免每行都变长。
|
|
1420
|
+
...(row.ports.length === 0 && row.networks.length > 0 ? { net: row.networks.join(',') } : {}),
|
|
1348
1421
|
...(row.composeProject === null ? {} : { composeProject: row.composeProject }),
|
|
1349
1422
|
...(row.composeService === null ? {} : { composeService: row.composeService }),
|
|
1350
1423
|
});
|
|
@@ -1541,7 +1614,26 @@ const plugin = definePlugin({
|
|
|
1541
1614
|
const { api } = apiFor(picked.name);
|
|
1542
1615
|
if (api === undefined)
|
|
1543
1616
|
throw new Error(resolveByName(picked.name).error ?? '无法构造执行通道');
|
|
1544
|
-
|
|
1617
|
+
let details;
|
|
1618
|
+
try {
|
|
1619
|
+
details = await api.inspect([input.id]);
|
|
1620
|
+
}
|
|
1621
|
+
catch (error) {
|
|
1622
|
+
// 未命中时给候选(D132):`No such object: rmqnamesrv` 只说明这个名字不存在,
|
|
1623
|
+
// 而 docker_ps 里可能就躺着 `607023340cbb_rmqnamesrv`(compose 建过的容器名)。
|
|
1624
|
+
// 只在失败路径多花一次 ps;候选查不到就静默——提示是锦上添花,不能变成新错误。
|
|
1625
|
+
let hint = '';
|
|
1626
|
+
try {
|
|
1627
|
+
const rows = await api.listContainers(true);
|
|
1628
|
+
const hits = suggestContainerNames(input.id, rows.map((row) => row.name));
|
|
1629
|
+
if (hits.length > 0)
|
|
1630
|
+
hint = `(是否想找:${hits.join('、')}?)`;
|
|
1631
|
+
}
|
|
1632
|
+
catch {
|
|
1633
|
+
/* 候选获取失败不影响原错误 */
|
|
1634
|
+
}
|
|
1635
|
+
throw new Error((error instanceof Error ? error.message : String(error)) + hint);
|
|
1636
|
+
}
|
|
1545
1637
|
const detail = details[0];
|
|
1546
1638
|
if (detail === undefined)
|
|
1547
1639
|
throw new Error(`容器不存在:${input.id}`);
|
|
@@ -2214,9 +2306,13 @@ const plugin = definePlugin({
|
|
|
2214
2306
|
/**
|
|
2215
2307
|
* GET /logs/stream — 容器日志实时流(`docker logs --follow` → SSE)。
|
|
2216
2308
|
*
|
|
2309
|
+
* 参数:`tail`(0~5000,缺省取配置;**0 = 不补历史只跟随**,客户端断线重连用,
|
|
2310
|
+
* 见 D133)、`timestamps`、`since`。
|
|
2311
|
+
*
|
|
2217
2312
|
* 事件协议(每帧 `event:` + 单行 JSON `data:`):
|
|
2218
2313
|
* - `line` `{"d":"..."}` stdout 分片 / `{"e":"..."}` stderr 分片
|
|
2219
2314
|
* - `end` `{"reason":"container-exit","code":N}` 容器停止、docker logs -f 自然退出
|
|
2315
|
+
* - `end` `{"reason":"output-limit"}` 宿主侧背压队列溢出(客户端消费跟不上,D133)
|
|
2220
2316
|
* - `error` `{"message":"..."}` 后关闭(参数 / 执行失败)
|
|
2221
2317
|
* 心跳:每 15s 一帧 `: ping` 注释;客户端断开则静默中止执行器(SIGTERM 阶梯 /
|
|
2222
2318
|
* channel KILL),不写任何帧。安全语义与快照 /logs 一致:只读能力,不走
|
|
@@ -2247,8 +2343,13 @@ const plugin = definePlugin({
|
|
|
2247
2343
|
writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
|
|
2248
2344
|
return;
|
|
2249
2345
|
}
|
|
2346
|
+
/*
|
|
2347
|
+
* tail=0 合法(D133):客户端断线重连时不补历史、只要新行——否则服务端会把
|
|
2348
|
+
* 最后 tail 行当新行重推,客户端重复回填。空值/纯空白按「未传」处理(取默认),
|
|
2349
|
+
* 不要落进 Number('') === 0 的坑。
|
|
2350
|
+
*/
|
|
2250
2351
|
const tailParam = params.get('tail');
|
|
2251
|
-
const tail = tailParam === null ? live.logTailDefault : Number(tailParam);
|
|
2352
|
+
const tail = tailParam === null || tailParam.trim() === '' ? live.logTailDefault : Number(tailParam);
|
|
2252
2353
|
const timestampsParam = params.get('timestamps');
|
|
2253
2354
|
const sinceParam = params.get('since');
|
|
2254
2355
|
// SSE 与快照同一套 since 校验(D45);非法直接 400(此处不在 POST 的 try 内)
|
|
@@ -2639,7 +2740,7 @@ const plugin = definePlugin({
|
|
|
2639
2740
|
writeJson(res, 400, { error: error instanceof Error ? error.message : String(error) });
|
|
2640
2741
|
return;
|
|
2641
2742
|
}
|
|
2642
|
-
//
|
|
2743
|
+
// volatile 更新事件会触发 applySection;无事件时也应用一次(幂等)
|
|
2643
2744
|
}
|
|
2644
2745
|
// applySection 也可能抛(tools.register / systemPrompt.section,D36):
|
|
2645
2746
|
// 此时配置已落盘,必须把原因带回给用户,而不是一个空 400/500。
|
|
@@ -2746,7 +2847,8 @@ const plugin = definePlugin({
|
|
|
2746
2847
|
writeJson(res, 400, { error: built.error ?? '无法构造执行通道' });
|
|
2747
2848
|
return;
|
|
2748
2849
|
}
|
|
2749
|
-
|
|
2850
|
+
// 目标级抛错打标记,由外层 catch 分流成 200 + ok:false(见 guardTargetFailures)
|
|
2851
|
+
const api = guardTargetFailures(built.api);
|
|
2750
2852
|
// 引用白名单在路由层先跑一次(D37):非法引用是**客户端**错误,直接 400
|
|
2751
2853
|
// 带原因——落到 DockerApi 里才抛的话会被外层 catch 统一写成 500。
|
|
2752
2854
|
// D97:`/action` 的 id、`/stats` 的 ids[] 与 `/exec` 的空 command 原先漏在外,
|
|
@@ -2957,21 +3059,35 @@ const plugin = definePlugin({
|
|
|
2957
3059
|
}
|
|
2958
3060
|
}
|
|
2959
3061
|
catch (error) {
|
|
2960
|
-
|
|
3062
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3063
|
+
/*
|
|
3064
|
+
* 目标级失败(SSH 不可达 / 密钥读不到 / docker 不在)不是服务端 bug:
|
|
3065
|
+
* 按多目标聚合的同一口径回 200 + ok:false,客户端两条路都取 payload.error
|
|
3066
|
+
* (client-src 的 request():!response.ok 与 ok === false 都抛 error),
|
|
3067
|
+
* 面板显示的文案不变,但语义对了——500 该留给真正的服务端故障。
|
|
3068
|
+
*/
|
|
3069
|
+
if (isTargetFailure(error)) {
|
|
3070
|
+
writeJson(res, 200, { ok: false, target: picked.name, error: message });
|
|
3071
|
+
return;
|
|
3072
|
+
}
|
|
3073
|
+
writeJson(res, 500, { error: message });
|
|
2961
3074
|
}
|
|
2962
3075
|
},
|
|
2963
3076
|
});
|
|
2964
3077
|
return () => dispose();
|
|
2965
3078
|
}, 'dsh-docker: web routes');
|
|
2966
3079
|
});
|
|
2967
|
-
/* ---------- settings
|
|
3080
|
+
/* ---------- settings ---------- */
|
|
2968
3081
|
ctx.inject(['settings'], (settingsCtx) => {
|
|
2969
3082
|
settingsCtx.effect(() => {
|
|
2970
|
-
const
|
|
2971
|
-
|
|
2972
|
-
|
|
3083
|
+
const scope = settingsEntryScope(settingsCtx, 'docker');
|
|
3084
|
+
if (scope === undefined)
|
|
3085
|
+
return () => { };
|
|
3086
|
+
// 卡片是自定义页(plugins.row.config),别再让 DSH 为本 entry 自动生成一份。
|
|
3087
|
+
const offAutoPage = suppressAutoSettingsPage(settingsCtx, ctx);
|
|
2973
3088
|
settingsScope = scope;
|
|
2974
|
-
|
|
3089
|
+
settingsApi = { get: (ns) => readSettingsEntry(settingsCtx, ns) };
|
|
3090
|
+
// 立刻读一次 resolved 值(schema 默认值 ← composition base ← 用户层)
|
|
2975
3091
|
const resolved = scope.get();
|
|
2976
3092
|
if (typeof resolved === 'object' && resolved !== null)
|
|
2977
3093
|
live = normalizeConfig(resolved);
|
|
@@ -2979,14 +3095,10 @@ const plugin = definePlugin({
|
|
|
2979
3095
|
console.log(`[dsh-docker] settings resolved (keys=${Object.keys(diag).join('|')}, targets=${Array.isArray(diag.targets) ? String(diag.targets.length) : 'not-array'}, hostKeys=${Array.isArray(diag.hostKeys) ? String(diag.hostKeys.length) : 'not-array'})`);
|
|
2980
3096
|
refreshTools();
|
|
2981
3097
|
refreshAnnouncement();
|
|
2982
|
-
const
|
|
2983
|
-
const off = events.events.on('settings/updated', (ns, next) => {
|
|
2984
|
-
if (ns !== 'docker' || typeof next !== 'object' || next === null)
|
|
2985
|
-
return;
|
|
2986
|
-
applySection(next);
|
|
2987
|
-
});
|
|
3098
|
+
const off = scope.onChanged(() => applySection(scope.get()));
|
|
2988
3099
|
return () => {
|
|
2989
3100
|
off();
|
|
3101
|
+
offAutoPage();
|
|
2990
3102
|
settingsScope = undefined;
|
|
2991
3103
|
settingsApi = undefined;
|
|
2992
3104
|
};
|