@hyzyn/dsh-docker 0.13.3 → 0.13.5
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 +98 -345
- package/README.md +98 -299
- package/client-src/index.js +240 -63
- package/client-src/log-stream.js +48 -3
- package/client.js +24 -24
- package/lib/docker.d.ts +24 -5
- package/lib/docker.js +33 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +450 -88
- package/lib/index.js.map +1 -1
- package/package.json +4 -4
- package/scripts/client-smoke.mjs +62 -17
- package/scripts/route-smoke.mjs +36 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hyzyn/dsh-docker",
|
|
3
|
-
"version": "0.13.
|
|
3
|
+
"version": "0.13.5",
|
|
4
4
|
"description": "DSH Web GUI 的 Docker 容器面板插件:本机或 SSH 主机上的容器列表 / 启停删 / 日志 / 统计 / 镜像 / 一次性 exec,默认只读,配套只读优先的 agent 工具。",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -44,12 +44,12 @@
|
|
|
44
44
|
},
|
|
45
45
|
"dependencies": {
|
|
46
46
|
"@deepseek-ai/schemastery": "^3.18.4",
|
|
47
|
-
"@hyzyn/dsh-kit": "0.
|
|
47
|
+
"@hyzyn/dsh-kit": "0.7.3",
|
|
48
48
|
"ssh2": "^1.17.0"
|
|
49
49
|
},
|
|
50
50
|
"peerDependencies": {
|
|
51
|
-
"@deepseek-ai/dsh": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-alpha.5 || ^0.2.1-rc.1 || ^0.2.1-rc.2",
|
|
52
|
-
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-alpha.5 || ^0.2.1-rc.1 || ^0.2.1-rc.2"
|
|
51
|
+
"@deepseek-ai/dsh": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-alpha.5 || ^0.2.1-rc.1 || ^0.2.1-rc.2 || ^0.2.1-rc.3",
|
|
52
|
+
"@deepseek-ai/dsh-tools": "^0.1.7-rc.2 || ^0.2.0-rc.1 || ^0.2.0-rc.2 || ^0.2.1-alpha.1 || ^0.2.1-alpha.2 || ^0.2.1-alpha.5 || ^0.2.1-rc.1 || ^0.2.1-rc.2 || ^0.2.1-rc.3"
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@deepseek-ai/cordis": "^4.0.4",
|
package/scripts/client-smoke.mjs
CHANGED
|
@@ -1451,14 +1451,14 @@ await test('聚合选择:入口 / 勾选框 / 操作条装配进 bundle', () =
|
|
|
1451
1451
|
assert.ok(decoded.includes('已选 '), '缺少「已选 N 个容器」计数')
|
|
1452
1452
|
assert.ok(decoded.includes('聚合日志 · '), '缺少聚合视图标题')
|
|
1453
1453
|
assert.ok(decoded.includes('至少选择 2 个容器'), '缺少 N<2 的提示')
|
|
1454
|
-
assert.ok(decoded.includes('
|
|
1454
|
+
assert.ok(decoded.includes('已接近上限,再多就看不清了'), '缺少软上限提示')
|
|
1455
1455
|
// 硬上限文案由「数字 + 后缀」拼出来(数字不写死),所以这里只锁静态部分;
|
|
1456
1456
|
// 具体数字由下面 pickDecide 的用例断言(它同时钉住「数字从哪来」)。
|
|
1457
1457
|
assert.ok(decoded.includes('最多 '), '缺少硬上限的「最多」前缀')
|
|
1458
1458
|
assert.ok(decoded.includes('个容器'), '缺少硬上限的「个容器」文案')
|
|
1459
|
-
assert.ok(decoded.includes('
|
|
1460
|
-
// 上限的来由要写在选择条上(D165
|
|
1461
|
-
assert.ok(decoded.includes('
|
|
1459
|
+
assert.ok(decoded.includes('一次读不清'), '缺少硬上限的成因后缀')
|
|
1460
|
+
// 上限的来由要写在选择条上(D165 → D168 改写):现在一句话说清「一条流装全部容器」
|
|
1461
|
+
assert.ok(decoded.includes('聚合日志只占一条 SSE'), '缺少「预算从哪来」的说明')
|
|
1462
1462
|
assert.ok(code.includes('dk_pickBudget'), '缺少预算说明的样式钩子')
|
|
1463
1463
|
// 接线判据走源码级:局部标识符会被压缩改名,产物里 `budget: t(` 认不出来
|
|
1464
1464
|
const pickSource = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
@@ -1470,34 +1470,76 @@ await test('聚合选择:入口 / 勾选框 / 操作条装配进 bundle', () =
|
|
|
1470
1470
|
assert.ok(code.includes('dk_pillPick'), '缺少「聚合选择」激活态样式钩子')
|
|
1471
1471
|
})
|
|
1472
1472
|
|
|
1473
|
-
await test('
|
|
1473
|
+
await test('聚合选择:上限是交互 / 宿主通道上限,不再随浏览器同源连接增长(D168)', () => {
|
|
1474
1474
|
const pick = pickApi()
|
|
1475
1475
|
/*
|
|
1476
|
-
* D165(2026-10-08 真机实测):宿主是 HTTP/1.1,每条 EventSource
|
|
1477
|
-
*
|
|
1478
|
-
*
|
|
1479
|
-
*
|
|
1476
|
+
* D165(2026-10-08 真机实测):宿主是 HTTP/1.1,每条 EventSource 独占一条同源连接且
|
|
1477
|
+
* 不释放;实测常驻流 4 条 → 页内请求 6/6 成功(中位 29ms),5 条 → 0/6 全部超时。
|
|
1478
|
+
*
|
|
1479
|
+
* D168 之后聚合日志是**一条** `/logs/stream?ids=…`,选 2 个还是 3 个都只占一条连接;
|
|
1480
|
+
* 所以 PICK_MAX 不再是「预算 − 事件流」推出来的,而是可读性 + 宿主侧每个容器一条
|
|
1481
|
+
* `docker logs -f`(SSH 上是 `MAX_STREAMS_PER_TARGET = 8` 的通道额度)取小。这里的
|
|
1482
|
+
* BUDGET / RESERVED 常量仍钉住**浏览器预算这份事实**(事件流 + 聚合流 = 2 条),只是
|
|
1483
|
+
* 它不再参与 PICK_MAX 的除法——后人别把两个数重新绑回去。
|
|
1480
1484
|
*/
|
|
1481
1485
|
assert.equal(pick.MAX, 3)
|
|
1482
|
-
assert.equal(pick.SSH_MAX, 3, 'SSH
|
|
1486
|
+
assert.equal(pick.SSH_MAX, 3, 'SSH 与本地同值:两边都不再受浏览器连接数驱动(D168)')
|
|
1483
1487
|
assert.equal(pick.SOFT_MAX, 2)
|
|
1484
|
-
assert.equal(pick.BUDGET, 4, '同源预算:4
|
|
1485
|
-
assert.equal(pick.RESERVED, 1, '其中 1
|
|
1488
|
+
assert.equal(pick.BUDGET, 4, '同源预算:4 条常驻流(D165 实测事实,仍要钉住)')
|
|
1489
|
+
assert.equal(pick.RESERVED, 1, '其中 1 条是活动条的事件流')
|
|
1486
1490
|
assert.deepEqual(pick.decide(0), { canRun: false, hint: '' })
|
|
1487
1491
|
assert.deepEqual(pick.decide(1), { canRun: false, hint: '至少选择 2 个容器' })
|
|
1488
1492
|
assert.deepEqual(pick.decide(2), { canRun: true, hint: '' })
|
|
1489
|
-
// 第 3
|
|
1493
|
+
// 第 3 个是可读性的最后一格:可点,但给一句软提示
|
|
1490
1494
|
assert.equal(pick.decide(3).canRun, true)
|
|
1491
|
-
assert.match(pick.decide(3).hint,
|
|
1495
|
+
assert.match(pick.decide(3).hint, /看不清/)
|
|
1492
1496
|
const over = pick.decide(4)
|
|
1493
1497
|
assert.equal(over.canRun, false, '超过硬上限必须置灰')
|
|
1494
1498
|
assert.match(over.hint, /最多 3 个容器/)
|
|
1495
1499
|
|
|
1496
|
-
//
|
|
1500
|
+
// 两个目标类型走同一套上限:一次读不清与宿主通道额度是同一个量级
|
|
1497
1501
|
assert.deepEqual(pick.decide(4, true).canRun, false, 'SSH 目标同样在 4 个时置灰')
|
|
1498
1502
|
assert.match(pick.decide(4, true).hint, /最多 3 个容器/)
|
|
1499
1503
|
})
|
|
1500
1504
|
|
|
1505
|
+
await test('D168:多容器日志走一条流 + 事件流只跟列表可见性', () => {
|
|
1506
|
+
const exports_ = registration.factory((spec) => SEED[spec])
|
|
1507
|
+
const streams = exports_.__streams
|
|
1508
|
+
assert.ok(streams !== undefined, '缺少 __streams 测试缝')
|
|
1509
|
+
assert.equal(typeof streams.multiLog, 'function')
|
|
1510
|
+
assert.equal(typeof streams.shouldStreamEvents, 'function')
|
|
1511
|
+
|
|
1512
|
+
/*
|
|
1513
|
+
* 宿主协议协商:>=3 才支持 `ids=` 单连接。config 未到 / 字段缺失都按老路径处理——
|
|
1514
|
+
* 先按 N 条流建、config 到了多源布尔值变化会让 effect 重跑并切到单连接(不闪空白)。
|
|
1515
|
+
*/
|
|
1516
|
+
assert.equal(streams.multiLog({ streamProtocol: 3 }), true)
|
|
1517
|
+
assert.equal(streams.multiLog({ streamProtocol: 4 }), true)
|
|
1518
|
+
assert.equal(streams.multiLog({ streamProtocol: 2 }), false, '老宿主只认 id=,必须退回每容器一条')
|
|
1519
|
+
assert.equal(streams.multiLog({}), false, '字段缺失 = 更老的宿主')
|
|
1520
|
+
assert.equal(streams.multiLog(null), false)
|
|
1521
|
+
assert.equal(streams.multiLog('nope'), false)
|
|
1522
|
+
|
|
1523
|
+
// 事件流建流判据:列表不在场(详情 / 聚合 / 拉取……)就不该再占一条同源连接
|
|
1524
|
+
const base = { active: true, view: 'containers', target: '本机', listVisible: true }
|
|
1525
|
+
assert.equal(streams.shouldStreamEvents(base), true)
|
|
1526
|
+
assert.equal(streams.shouldStreamEvents({ ...base, listVisible: false }), false, '详情 / 聚合视图替掉列表时必须断流')
|
|
1527
|
+
assert.equal(streams.shouldStreamEvents({ ...base, active: false }), false, '折叠的 tab 不建流')
|
|
1528
|
+
assert.equal(streams.shouldStreamEvents({ ...base, view: 'images' }), false)
|
|
1529
|
+
assert.equal(streams.shouldStreamEvents({ ...base, target: '' }), false)
|
|
1530
|
+
|
|
1531
|
+
/*
|
|
1532
|
+
* 源码级接线判据(产物里局部标识符会被压缩改名,只剩属性名 / 字符串):
|
|
1533
|
+
* 聚合视图必须真的用 `ids=` 一条流、多源分支必须由协议协商控制、老退路不能删、
|
|
1534
|
+
* 事件流 effect 必须走上面那条判据。
|
|
1535
|
+
*/
|
|
1536
|
+
const source = readFileSync(new URL('../client-src/index.js', import.meta.url), 'utf8')
|
|
1537
|
+
assert.ok(source.includes('ids: itemIds'), '聚合日志要多源单连接(ids=)')
|
|
1538
|
+
assert.ok(source.includes('if (props.multiLog === true)'), '多源分支必须看宿主协议协商结果')
|
|
1539
|
+
assert.ok(source.includes('id: item.id'), '老宿主退路(每容器一条流)不能删')
|
|
1540
|
+
assert.ok(source.includes('shouldStreamEvents({ active, view, target, listVisible })'), '事件流建流必须走统一判据')
|
|
1541
|
+
})
|
|
1542
|
+
|
|
1501
1543
|
await test('聚合选择:勾选增删保序 + 列表刷新按 id 对账', () => {
|
|
1502
1544
|
const pick = pickApi()
|
|
1503
1545
|
let ids = []
|
|
@@ -1638,8 +1680,11 @@ await test('事件流断开:活动条接线(兄弟按钮、不是嵌套按
|
|
|
1638
1680
|
assert.ok(headClose !== -1, '折叠头应有结束标记')
|
|
1639
1681
|
assert.ok(!source.slice(headOpen, headClose).includes('dk_activityRetry'), '重连按钮不能嵌在折叠按钮里(非法 HTML + 读屏会听成一个动作)')
|
|
1640
1682
|
|
|
1641
|
-
// 事件流 effect
|
|
1642
|
-
|
|
1683
|
+
// 事件流 effect 的依赖里必须有重连令牌,否则点了不重开;
|
|
1684
|
+
// 列表可见性同样要在依赖里(D168):进详情 / 聚合视图时断流、回列表时重开,靠的就是它变化
|
|
1685
|
+
const eventsDeps = source.match(/\}, \[([^\]]*eventsReconnect[^\]]*)\]\)/)
|
|
1686
|
+
assert.ok(eventsDeps !== null, '事件流 effect 的依赖数组里必须含 eventsReconnect(否则点了不重开)')
|
|
1687
|
+
assert.ok(eventsDeps[1].includes('listVisible'), '列表可见性要进事件流 effect 依赖(否则进出详情不会断流 / 回来不会重开)')
|
|
1643
1688
|
// 令牌必须自增:布尔翻回 false 时 effect 看不出「又要求了一次」
|
|
1644
1689
|
assert.ok(/setEventsReconnect\(\(value\) => value \+ 1\)/.test(source), '重连令牌必须自增')
|
|
1645
1690
|
|
package/scripts/route-smoke.mjs
CHANGED
|
@@ -423,9 +423,10 @@ await test('GET /config:凭证脱敏 + 只读默认 + 复用 tty 连接簿名'
|
|
|
423
423
|
assert.equal(config.dockerBin, fakeBin)
|
|
424
424
|
assert.deepEqual(config.ttyBooks, ['prod-a'])
|
|
425
425
|
assert.equal(config.ttyAvailable, true)
|
|
426
|
-
// SSE
|
|
426
|
+
// SSE 帧协议版本:客户端据此决定「怎样才算已就绪」(≥2 = 管道起来先发 ready),
|
|
427
|
+
// 以及「日志流怎么连」(3 = /logs/stream 支持 ids= 多容器单连接 + 分源帧)——
|
|
427
428
|
// 旧宿主没有这个字段,开发档错位时新客户端靠它退回旧判据
|
|
428
|
-
assert.equal(config.streamProtocol,
|
|
429
|
+
assert.equal(config.streamProtocol, 3)
|
|
429
430
|
assert.equal(JSON.stringify(config).includes('FAKE_PW'), false)
|
|
430
431
|
for (const target of config.targets) {
|
|
431
432
|
assert.equal(Object.hasOwn(target, 'password'), false)
|
|
@@ -511,6 +512,39 @@ await test('GET /logs/stream:SSE 事件序列(line stdout/stderr → end)'
|
|
|
511
512
|
assert.equal(res.ended, true)
|
|
512
513
|
})
|
|
513
514
|
|
|
515
|
+
await test('GET /logs/stream?ids=…:一条连接装多个容器(D168)', async () => {
|
|
516
|
+
const res = await call('GET', '/logs/stream?target=本机&ids=shop-web-1,shop-db-1&tail=20')
|
|
517
|
+
assert.equal(res.status, 200)
|
|
518
|
+
assert.equal(res.flushed, true)
|
|
519
|
+
const text = res.frames.join('')
|
|
520
|
+
// ready 只发一帧:两个源(两个真的 docker logs 子进程)也只算一次握手
|
|
521
|
+
assert.equal(text.split('event: ready').length - 1, 1)
|
|
522
|
+
// 每帧带 id,两个容器各自的 line 都在(到达序不保证,按集合断言)
|
|
523
|
+
assert.match(text, /event: line\ndata: \{"id":"shop-web-1","d":"stdout line 1\\n"\}\n\n/)
|
|
524
|
+
assert.match(text, /event: line\ndata: \{"id":"shop-db-1","d":"stdout line 1\\n"\}\n\n/)
|
|
525
|
+
assert.match(text, /event: line\ndata: \{"id":"shop-web-1","e":"stderr line 1\\n"\}\n\n/)
|
|
526
|
+
assert.match(text, /event: line\ndata: \{"id":"shop-db-1","e":"stderr line 1\\n"\}\n\n/)
|
|
527
|
+
// 每个源各一条 source-end,全部结束之后才是整条连接的 end
|
|
528
|
+
assert.match(text, /event: source-end\ndata: \{"id":"shop-web-1","code":0\}\n\n/)
|
|
529
|
+
assert.match(text, /event: source-end\ndata: \{"id":"shop-db-1","code":0\}\n\n/)
|
|
530
|
+
assert.match(text, /event: end\ndata: \{"reason":"container-exit","code":0\}\n\n/)
|
|
531
|
+
assert.equal(res.ended, true)
|
|
532
|
+
})
|
|
533
|
+
|
|
534
|
+
await test('GET /logs/stream?ids=…:校验矩阵(空 / 超上限 / 非法条目都不建流)', async () => {
|
|
535
|
+
const empty = await call('GET', '/logs/stream?target=本机&ids=,')
|
|
536
|
+
assert.equal(empty.status, 400)
|
|
537
|
+
assert.match(empty.body.error, /id 必填/)
|
|
538
|
+
|
|
539
|
+
const tooMany = await call('GET', '/logs/stream?target=本机&ids=' + Array.from({ length: 33 }, (_, index) => 'c' + String(index)).join(','))
|
|
540
|
+
assert.equal(tooMany.status, 400)
|
|
541
|
+
assert.match(tooMany.body.error, /32/)
|
|
542
|
+
|
|
543
|
+
const badId = await call('GET', '/logs/stream?target=本机&ids=' + encodeURIComponent('shop-web-1; rm -rf /'))
|
|
544
|
+
assert.equal(badId.status, 400)
|
|
545
|
+
assert.match(badId.body.error, /container 含非法字符/)
|
|
546
|
+
})
|
|
547
|
+
|
|
514
548
|
await test('GET /logs/stream:缺 id 400 / 非法 id 400 / 非 GET 405 / 非 loopback 403', async () => {
|
|
515
549
|
const missing = await call('GET', '/logs/stream?target=本机')
|
|
516
550
|
assert.equal(missing.status, 400)
|