@hyzyn/dsh-docker 0.13.3 → 0.13.6

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-docker",
3
- "version": "0.13.3",
3
+ "version": "0.13.6",
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.6.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 || ^0.2.1-rc.4",
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 || ^0.2.1-rc.4"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@deepseek-ai/cordis": "^4.0.4",
@@ -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):不写清楚,3 看起来就只是个随手定的数字
1461
- assert.ok(decoded.includes('浏览器每个站点最多同时保持 4 条实时流'), '缺少「预算从哪来」的说明')
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('聚合选择:上限绑在浏览器同源连接预算上(4 条流 − 事件流 = 3)', () => {
1473
+ await test('聚合选择:上限是交互 / 宿主通道上限,不再随浏览器同源连接增长(D168)', () => {
1474
1474
  const pick = pickApi()
1475
1475
  /*
1476
- * D165(2026-10-08 真机实测):宿主是 HTTP/1.1,每条 EventSource 独占一条同源
1477
- * 连接且不释放。实测常驻流 4 条 → 页内请求 6/6 成功(中位 29ms);5 条 → 0/6 全部超时
1478
- * (同时刻进程外请求 41ms 正常,堵在浏览器不在宿主)。容器页常开着事件流,所以聚合
1479
- * 只能用掉剩下的 3 条。这条用例把「数字从哪来」钉住,免得后人凭手感调大。
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 与本地共用同一个浏览器预算(卡页面的是浏览器,不是 MaxSessions)')
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
- // 两个目标类型走同一套上限:真正卡住页面的是浏览器同源预算,与目标是 SSH 还是本机无关
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
- assert.ok(/\}, \[active, view, target, eventsReconnect\]\)/.test(source), '事件流 effect 的依赖数组里必须含 eventsReconnect(否则点了不重开)')
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
 
@@ -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 帧协议版本:客户端据此决定「怎样才算已就绪」(2 = 管道起来先发 ready)——
426
+ // SSE 帧协议版本:客户端据此决定「怎样才算已就绪」(≥2 = 管道起来先发 ready),
427
+ // 以及「日志流怎么连」(3 = /logs/stream 支持 ids= 多容器单连接 + 分源帧)——
427
428
  // 旧宿主没有这个字段,开发档错位时新客户端靠它退回旧判据
428
- assert.equal(config.streamProtocol, 2)
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)