@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hyzyn/dsh-docker",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "DSH Web GUI 的 Docker 容器面板插件:本机或 SSH 主机上的容器列表 / 启停删 / 日志 / 统计 / 镜像 / 一次性 exec,默认只读,配套只读优先的 agent 工具。",
5
5
  "keywords": [
6
6
  "dsh",
@@ -31,26 +31,28 @@
31
31
  "./package.json": "./package.json"
32
32
  },
33
33
  "dsh": {
34
+ "manifestVersion": 1,
34
35
  "bundle": {
35
36
  "patch": "./cordis.patch.yml"
36
37
  },
37
38
  "engines": {
38
- "dsh": ">=0.1.2-rc.1"
39
+ "dsh": ">=0.1.7-rc.2"
39
40
  },
40
41
  "client": {
41
42
  "platform": "web"
42
43
  }
43
44
  },
44
45
  "dependencies": {
45
- "@deepseek-ai/schemastery": "^3.18.2",
46
- "@hyzyn/dsh-kit": "0.4.1",
46
+ "@deepseek-ai/schemastery": "^3.18.4",
47
+ "@hyzyn/dsh-kit": "0.4.2",
47
48
  "ssh2": "^1.17.0"
48
49
  },
49
50
  "peerDependencies": {
50
- "@deepseek-ai/dsh-tools": "^0.1.2-rc.1"
51
+ "@deepseek-ai/dsh": "^0.1.7-rc.2",
52
+ "@deepseek-ai/dsh-tools": "^0.1.7-rc.2"
51
53
  },
52
54
  "devDependencies": {
53
- "@deepseek-ai/cordis": "^4.0.2",
55
+ "@deepseek-ai/cordis": "^4.0.4",
54
56
  "@deepseek-ai/dsh-host-webserver": "0.1.2-rc.1",
55
57
  "@deepseek-ai/dsh-tools": "0.1.2-rc.1",
56
58
  "@types/node": "^22.20.0",
@@ -79,10 +81,16 @@
79
81
  "publishConfig": {
80
82
  "access": "public"
81
83
  },
84
+ "peerDependenciesMeta": {
85
+ "@deepseek-ai/dsh": {
86
+ "optional": true
87
+ }
88
+ },
82
89
  "scripts": {
83
90
  "build": "tsc -p tsconfig.json && node scripts/build-client.mjs",
84
91
  "typecheck": "tsc --noEmit && node ../../scripts/client-lint.mjs",
85
92
  "smoke": "node scripts/smoke.mjs && node scripts/route-smoke.mjs && node scripts/client-smoke.mjs",
93
+ "perf:logs": "node scripts/log-perf.mjs",
86
94
  "lint:client": "node ../../scripts/client-lint.mjs"
87
95
  }
88
96
  }
@@ -752,6 +752,39 @@ await test('日志级别档位:共用一份定义,含 INFO+(真实日志
752
752
  }
753
753
  })
754
754
 
755
+ await test('日志级别过滤:`%5p` 右填充的 `[INFO ]` / `[WARN ]` 必须认出来(否则门槛整个失效)', () => {
756
+ const agg = aggLogsApi()
757
+ /*
758
+ * 回归现场(用户截图):Logback / Spring 的 `%5p` 把级别右填充到 5 字符,
759
+ * 于是 INFO/WARN/DEBUG 写成 `[INFO ]`,而 ERROR/FATAL 恰好 5 字符不带空格。
760
+ * 旧正则只认 `[INFO]`,这些行被判成「无级别」——looks 无害,实则致命:
761
+ * filterByLevelCore 对未知级别走「不误杀」分支(rank=null 一律保留),
762
+ * 所以选了 WARN+ 仍会显示一屏 INFO,过滤像没生效。
763
+ */
764
+ assert.equal(agg.levelName('[INFO ] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'INFO')
765
+ assert.equal(agg.levelName('[WARN ] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'WARN')
766
+ assert.equal(agg.levelName('[DEBUG] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'DEBUG')
767
+ assert.equal(agg.levelName('[ERROR] [2026-09-25 11:31:00] [scheduling-1] ==> x'), 'ERROR')
768
+ // 既有形态不能被这次放宽改坏:未填充 / 前导空格 / Spring 竖线 / 无级别
769
+ assert.equal(agg.levelName('[INFO] [2026-09-09 18:05:52] unpadded'), 'INFO')
770
+ assert.equal(agg.levelName('[ INFO] leading pad'), 'INFO')
771
+ assert.equal(agg.levelName('16:11:34,150 |INFO in Spring'), 'INFO')
772
+ assert.equal(agg.levelName('没有待处理的考试,定时任务结束'), null)
773
+
774
+ // 端到端:WARN+ 滤掉填充过的 INFO 行,且紧邻的续行继承语义不受影响
775
+ const lines = [
776
+ '[INFO ] [2026-09-25 11:31:00] [scheduling-1] ==> 没有待处理的考试,定时任务结束',
777
+ '[WARN ] [2026-09-25 11:31:05] [scheduling-1] ==> 磁盘将满',
778
+ ' 续行继承上一行的级别',
779
+ ]
780
+ assert.deepEqual(agg.filterLinesByLevel(lines, 3), [lines[1], lines[2]], 'WARN+ 必须滤掉填充过的 INFO')
781
+ assert.deepEqual(agg.filterLinesByLevel(lines, 2), lines, 'INFO+ 三行都该留下')
782
+ assert.deepEqual(agg.filterLinesByLevel(lines, 4), [], 'ERROR+ 该清空(续行继承 WARN)')
783
+ // 行对象适配器与纯文本适配器必须给出同一结论
784
+ const rows = lines.map((text) => ({ text }))
785
+ assert.deepEqual(agg.filterByLevel(rows, 3).map((row) => row.text), agg.filterLinesByLevel(lines, 3))
786
+ })
787
+
755
788
  await test('日志导出对齐:两种格式共用构建器,只有标题与作用域不同', () => {
756
789
  const pick = aggLogsApi()
757
790
  const rows = [{ service: 'web', ts: 1755000000000, text: 'ERROR boom' }]
@@ -769,6 +802,34 @@ await test('日志导出对齐:两种格式共用构建器,只有标题与
769
802
  assert.match(many, /容器(2):web、db/)
770
803
  })
771
804
 
805
+ await test('日志导出:两个格式合并成一个菜单按钮(D137 —— 窄面板下不再各占一格)', () => {
806
+ const exports_ = registration.factory((spec) => SEED[spec])
807
+ const agg = exports_.__aggLogs
808
+ const decoded = decodeBundle(code)
809
+
810
+ // 「一份定义」不是口号:按钮文案在产物里出现两次 = 两个视图又各写了一套
811
+ // (级别档位、过滤内核当初就是这么漂的)
812
+ assert.equal(typeof agg.EXPORT_LABEL, 'string', '缺少 __aggLogs.EXPORT_LABEL 测试缝')
813
+ assert.ok(decoded.includes(agg.EXPORT_LABEL), '产物里没有导出按钮文案')
814
+ assert.equal(
815
+ decoded.split(agg.EXPORT_LABEL).length - 1,
816
+ 1,
817
+ agg.EXPORT_LABEL + ' 在产物里出现了多次(视图各写了一套按钮?)',
818
+ )
819
+ // 两个视图的按钮都必须是「菜单触发器」——否则「这是个菜单」对读屏用户不可知
820
+ assert.equal(decoded.split('aria-haspopup').length - 1, 2, '两个视图的导出按钮都要标 aria-haspopup')
821
+ // 旧的「一个格式一个按钮」不许回来(正是它把 .md 挤到第二行)
822
+ assert.ok(!decoded.includes('导出当前显示内容为 .log'), '旧的单格式按钮文案残留(又拆回两个按钮了?)')
823
+
824
+ // 菜单里两个格式都在,且各自把正确的 format 交回导出函数
825
+ const picked = []
826
+ const items = agg.exportMenuItems((format) => picked.push(format))
827
+ assert.equal(items.length, 2, '导出菜单必须恰好两个格式')
828
+ assert.deepEqual(items.map((item) => item.label), ['⬇ .log', '⬇ .md'])
829
+ for (const item of items) item.onPick()
830
+ assert.deepEqual(picked, ['log', 'md'], '菜单项必须把对应的 format 传回导出函数')
831
+ })
832
+
772
833
  await test('渲染期守卫:面板组件体直接跑一遍不能抛(TDZ 那类错误曾让面板整个空白)', () => {
773
834
  const exports_ = registration.factory((spec) => SEED[spec])
774
835
  assert.ok(exports_.__render !== undefined, '缺少 __render 测试缝')
@@ -776,6 +837,9 @@ await test('渲染期守卫:面板组件体直接跑一遍不能抛(TDZ 那
776
837
  ['DockerTabBody', {}],
777
838
  ['ContainerPanel', { carrier: 'tab', onClose: () => {}, initialTarget: '' }],
778
839
  ['ContainerPanel', { onClose: () => {}, initialTarget: '' }], // 模态 / docked 形态(carrier 缺省)
840
+ // 聚合日志视图(D137 也动了它的导出按钮):它的工具条与单容器那条共用同一份定义,
841
+ // 但只在「多选聚合 / compose 详情」下才挂载,上面两个入口都盖不到它
842
+ ['ComposeLogs', { target: '目标1', targetLabel: '目标1', items: [{ id: 'a', name: 'web' }, { id: 'b', name: 'db' }] }],
779
843
  ]
780
844
  for (const [name, props] of cases) {
781
845
  const component = exports_.__render[name]
@@ -1831,6 +1895,203 @@ await test('总览:degraded 目标出提示(inspect 降级 / 候选超预算
1831
1895
  assert.match(value.attentionNotice, /已降级/)
1832
1896
  })
1833
1897
 
1898
+ /* ------------------------------------------------------------------ *
1899
+ * 日志缓冲(D63 根治):环形上限 / 残行分片 / 单调 id——突发灌入的内存护栏。
1900
+ * 真实 EventSource 时序进不了 Node 桩,但「缓冲吃多少、渲染信号发多少次」
1901
+ * 是纯逻辑,在这里钉死。
1902
+ * ------------------------------------------------------------------ */
1903
+
1904
+ await test('日志缓冲:burst 灌入上行数/字节双限与单调 id(D63 根治)', () => {
1905
+ const exports_ = registration.factory((spec) => SEED[spec])
1906
+ const seam = exports_.__logBuffer
1907
+ assert.ok(seam !== undefined && typeof seam.create === 'function', '缺少 __logBuffer 测试缝')
1908
+ const { create, MAX_LINES, BYTE_LIMIT, PENDING_MAX, FLUSH_MS } = seam
1909
+ // 常量在场:缓冲行数/字节上限与合帧节奏(显示层不再另设截断)
1910
+ assert.equal(MAX_LINES, 5000)
1911
+ assert.equal(BYTE_LIMIT, 4 * 1024 * 1024)
1912
+ assert.equal(PENDING_MAX, 1024 * 1024)
1913
+ assert.equal(FLUSH_MS, 150)
1914
+
1915
+ // burst:3 万行一次灌入,行数上限收口;id 严格递增(渲染 key 稳定性的前提)
1916
+ const buffer = create({ maxLines: MAX_LINES, maxBytes: BYTE_LIMIT, maxPendingBytes: PENDING_MAX })
1917
+ const lines = Array.from({ length: 30_000 }, (_, index) => 'line-' + String(index))
1918
+ assert.equal(buffer.pushChunk(lines.join('\n') + '\n').appended, lines.length)
1919
+ assert.equal(buffer.count(), MAX_LINES)
1920
+ const snap = buffer.snapshot()
1921
+ assert.equal(snap[0].text, 'line-' + String(lines.length - MAX_LINES))
1922
+ for (let i = 1; i < snap.length; i++) assert.ok(snap[i].id > snap[i - 1].id, '行 id 必须单调递增')
1923
+ assert.equal(buffer.takeDropped(), true)
1924
+ assert.equal(buffer.takeDropped(), false)
1925
+
1926
+ // 大行:字节上限收口(旧行为只限行数,大行场景能吃掉数百 MB)
1927
+ const bigLine = 'x'.repeat(64 * 1024)
1928
+ const buffer2 = create({ maxLines: MAX_LINES, maxBytes: 1024 * 1024, maxPendingBytes: PENDING_MAX })
1929
+ for (let i = 0; i < 64; i++) buffer2.pushChunk(bigLine + '\n')
1930
+ const live = buffer2.snapshot().reduce((sum, entry) => sum + entry.bytes, 0)
1931
+ assert.ok(live <= 1024 * 1024, '存活字节必须 ≤ maxBytes,实际 ' + String(live))
1932
+ })
1933
+
1934
+ await test('日志流重连:首连带 tail、重连一律 tail=0(D133 不重放历史)', () => {
1935
+ const exports_ = registration.factory((spec) => SEED[spec])
1936
+ const seam = exports_.__logStream
1937
+ assert.ok(seam !== undefined && typeof seam.subscribe === 'function', '缺少 __logStream 测试缝')
1938
+ assert.equal(seam.RECONNECT_BASE_MS, 1000)
1939
+ assert.equal(seam.RECONNECT_MAX_MS, 15000)
1940
+ // 规则本身:首连保持调用方给的 tail,重连恒为 0
1941
+ assert.equal(seam.reconnectTail(200, false), 200)
1942
+ assert.equal(seam.reconnectTail(200, true), 0)
1943
+ assert.equal(seam.reconnectTail(5000, true), 0)
1944
+
1945
+ // 用可注入的假 EventSource 真跑一遍订阅:首连 URL 带 200,断线重连后带 0
1946
+ const urls = []
1947
+ const instances = []
1948
+ class FakeEventSource {
1949
+ constructor(url) {
1950
+ this.url = url
1951
+ this.readyState = 0
1952
+ this.listeners = new Map()
1953
+ urls.push(url)
1954
+ instances.push(this)
1955
+ }
1956
+ addEventListener(name, fn) {
1957
+ const list = this.listeners.get(name) ?? []
1958
+ list.push(fn)
1959
+ this.listeners.set(name, list)
1960
+ }
1961
+ /** data 省略 = 连接层错误那种「没有 data 的普通 Event」。 */
1962
+ emit(name, data) {
1963
+ for (const fn of this.listeners.get(name) ?? []) fn(data === undefined ? {} : { data: JSON.stringify(data) })
1964
+ }
1965
+ open() {
1966
+ this.readyState = 1
1967
+ if (this.onopen) this.onopen()
1968
+ }
1969
+ close() {
1970
+ this.readyState = 2
1971
+ }
1972
+ }
1973
+ const previous = globalThis.EventSource
1974
+ const previousSetTimeout = globalThis.setTimeout
1975
+ globalThis.EventSource = FakeEventSource
1976
+ // 退避定时器直接同步执行,免得用例等着真实 1s
1977
+ globalThis.setTimeout = (fn) => {
1978
+ fn()
1979
+ return 0
1980
+ }
1981
+ try {
1982
+ const lines = []
1983
+ const handle = seam.subscribe({
1984
+ buildUrl: (tail) => '/api/dsh-docker/logs/stream?tail=' + String(tail),
1985
+ tail: 200,
1986
+ onLine: (text) => lines.push(text),
1987
+ })
1988
+ assert.deepEqual(urls, ['/api/dsh-docker/logs/stream?tail=200'])
1989
+ instances[0].open()
1990
+ instances[0].emit('line', { d: 'first\n' })
1991
+ // 连接层错误(无 data 的 Event):触发手动重连,URL 必须换成 tail=0
1992
+ instances[0].emit('error')
1993
+ assert.deepEqual(urls, [
1994
+ '/api/dsh-docker/logs/stream?tail=200',
1995
+ '/api/dsh-docker/logs/stream?tail=0',
1996
+ ])
1997
+ instances[1].open()
1998
+ instances[1].emit('line', { d: 'second\n' })
1999
+ assert.deepEqual(lines, ['first\n', 'second\n'])
2000
+ handle.close()
2001
+ } finally {
2002
+ globalThis.EventSource = previous
2003
+ globalThis.setTimeout = previousSetTimeout
2004
+ }
2005
+ })
2006
+
2007
+ await test('日志流重连:服务端 end{output-limit} 交给调用方决定重连,不静默当成正常结束', () => {
2008
+ const exports_ = registration.factory((spec) => SEED[spec])
2009
+ const seam = exports_.__logStream
2010
+ const urls = []
2011
+ const instances = []
2012
+ class FakeEventSource {
2013
+ constructor(url) {
2014
+ this.url = url
2015
+ this.readyState = 0
2016
+ this.listeners = new Map()
2017
+ urls.push(url)
2018
+ instances.push(this)
2019
+ }
2020
+ addEventListener(name, fn) {
2021
+ const list = this.listeners.get(name) ?? []
2022
+ list.push(fn)
2023
+ this.listeners.set(name, list)
2024
+ }
2025
+ /** data 省略 = 连接层错误那种「没有 data 的普通 Event」。 */
2026
+ emit(name, data) {
2027
+ for (const fn of this.listeners.get(name) ?? []) fn(data === undefined ? {} : { data: JSON.stringify(data) })
2028
+ }
2029
+ open() {
2030
+ this.readyState = 1
2031
+ if (this.onopen) this.onopen()
2032
+ }
2033
+ close() {
2034
+ this.readyState = 2
2035
+ }
2036
+ }
2037
+ const previous = globalThis.EventSource
2038
+ const previousSetTimeout = globalThis.setTimeout
2039
+ globalThis.EventSource = FakeEventSource
2040
+ globalThis.setTimeout = (fn) => {
2041
+ fn()
2042
+ return 0
2043
+ }
2044
+ try {
2045
+ const seen = []
2046
+ seam.subscribe({
2047
+ buildUrl: (tail) => '/logs/stream?tail=' + String(tail),
2048
+ tail: 50,
2049
+ onEnd: (payload, controls) => {
2050
+ seen.push(payload)
2051
+ if (payload && payload.reason === 'output-limit') controls.reconnect()
2052
+ },
2053
+ })
2054
+ instances[0].open()
2055
+ instances[0].emit('end', { reason: 'output-limit' })
2056
+ assert.equal(seen.length, 1)
2057
+ assert.equal(seen[0].reason, 'output-limit')
2058
+ // 溢出后重连:同样不许带历史(否则重复回填)
2059
+ assert.deepEqual(urls, ['/logs/stream?tail=50', '/logs/stream?tail=0'])
2060
+ } finally {
2061
+ globalThis.EventSource = previous
2062
+ globalThis.setTimeout = previousSetTimeout
2063
+ }
2064
+ })
2065
+
2066
+ await test('日志缓冲:无换行的超长输出被残行分片钉在有界内存内', () => {
2067
+ const exports_ = registration.factory((spec) => SEED[spec])
2068
+ const { create, PENDING_MAX } = exports_.__logBuffer
2069
+ const buffer = create({ maxLines: 100, maxBytes: 1024 * 1024, maxPendingBytes: PENDING_MAX })
2070
+ // 模拟「永远不换行的输出」:32MB 连续灌入,残行每次到 1MB 强制落行
2071
+ for (let i = 0; i < 32; i++) buffer.pushChunk('y'.repeat(PENDING_MAX))
2072
+ const live = buffer.snapshot().reduce((sum, entry) => sum + entry.bytes, 0)
2073
+ assert.ok(live <= 1024 * 1024 + PENDING_MAX, '存活字节必须有界,实际 ' + String(live))
2074
+ assert.ok(buffer.pendingLength() <= PENDING_MAX)
2075
+ assert.ok(buffer.takeDropped(), '持续超限必须置 dropped')
2076
+ })
2077
+
2078
+ await test('日志缓冲:快照切分不把结尾换行算成一行(D136 —— --tail 201 必须显示 201 行)', () => {
2079
+ const exports_ = registration.factory((spec) => SEED[spec])
2080
+ const { splitLines, create } = exports_.__logBuffer
2081
+ assert.equal(typeof splitLines, 'function', '缺少 __logBuffer.splitLines 测试缝')
2082
+ // 现场:docker logs --tail 201 → 201 行、每行以 \n 结尾。旧的 text.split('\n') 会切出 202 段。
2083
+ const text = Array.from({ length: 201 }, (_, i) => 'line ' + String(i)).join('\n') + '\n'
2084
+ assert.equal(splitLines(text).length, 201, '计数必须等于 --tail 的行数')
2085
+ assert.equal(splitLines(text).at(-1), 'line 200', '末尾不该多出一条空行')
2086
+ // 与 FOLLOW 缓冲的结论一致:同一份文本,两个视图的行数不许不同
2087
+ const buffer = create({ maxLines: 5000, maxBytes: 4 * 1024 * 1024 })
2088
+ buffer.pushChunk(text)
2089
+ assert.equal(buffer.snapshot().length, splitLines(text).length, '快照视图与 FOLLOW 视图必须同行数')
2090
+ // 真正以空行结尾的日志要留住那个空行(只剥终止符)
2091
+ assert.deepEqual(splitLines('a\n\n'), ['a', ''])
2092
+ assert.deepEqual(splitLines(''), [])
2093
+ })
2094
+
1834
2095
  /* ------------------------------------------------------------------ *
1835
2096
  * 结果
1836
2097
  * ------------------------------------------------------------------ */
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * 日志页性能门禁(D128 的浏览器侧护栏)。
4
+ *
5
+ * 为什么要有它:日志页当初「网页直接崩溃」是个**性能**缺陷——单测能钉住缓冲上限与
6
+ * 合帧常量,但钉不住「5000 行首屏要多久 / 突发时事件循环卡多久」。这里用真 Chrome
7
+ * 量这两个数并断言预算,回归就再也回不来了。
8
+ *
9
+ * 依赖 tty 的预览夹具(vendor React + mock-host + 面板外壳):
10
+ * node ../tty/scripts/preview.mjs # 首次/夹具缺失时先生成
11
+ * 夹具或 Chrome 缺失时**跳过**(exit 0)——它是 npm script,不是 vitest 必跑项。
12
+ *
13
+ * node scripts/log-perf.mjs # 默认
14
+ * CHROME_PATH=/path/to/chrome node scripts/log-perf.mjs
15
+ */
16
+ import http from 'node:http'
17
+ import { readFileSync, existsSync, copyFileSync, mkdirSync, statSync } from 'node:fs'
18
+ import { join, extname, resolve, dirname } from 'node:path'
19
+ import { fileURLToPath } from 'node:url'
20
+ import { Chrome } from '../../../scripts/chrome-cdp.mjs'
21
+
22
+ const here = dirname(fileURLToPath(import.meta.url))
23
+ const pkg = resolve(here, '..')
24
+ const repo = resolve(pkg, '../..')
25
+ const fixtureDir = join(repo, 'packages/tty/.preview')
26
+
27
+ /** 预算:宽松到不会因机器抖动误报,紧到能抓住「每 chunk 全量重渲染」那类回归。 */
28
+ const BUDGET = {
29
+ snapshotMs: 2500,
30
+ snapshotRows: 5000,
31
+ burstMs: 8000,
32
+ burstMaxLagMs: 400,
33
+ maxNodes: 60_000,
34
+ }
35
+
36
+ function skip(reason) {
37
+ console.log('[log-perf] 跳过:' + reason)
38
+ process.exit(0)
39
+ }
40
+
41
+ const chromePath = process.env.CHROME_PATH ?? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
42
+ if (!existsSync(chromePath)) skip('没找到 Chrome(可用 CHROME_PATH 指定)')
43
+ for (const file of ['harness.html', 'mock-host.js', 'harness.js', 'vendor/react.js', 'vendor/react-dom.js']) {
44
+ if (!existsSync(join(fixtureDir, file))) skip('预览夹具缺 ' + file + '(先跑 node packages/tty/scripts/preview.mjs)')
45
+ }
46
+ if (!existsSync(join(pkg, 'client.js'))) skip('client.js 还没构建(先跑 pnpm --filter @hyzyn/dsh-docker build)')
47
+
48
+ // 把当前构建的 docker client 换进夹具(夹具读 .preview/docker-client.js)
49
+ mkdirSync(fixtureDir, { recursive: true })
50
+ copyFileSync(join(pkg, 'client.js'), join(fixtureDir, 'docker-client.js'))
51
+
52
+ const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' }
53
+ const server = http.createServer((req, res) => {
54
+ const path = decodeURIComponent(String(req.url).split('?')[0])
55
+ const file = join(fixtureDir, path === '/' ? 'harness.html' : path)
56
+ if (!file.startsWith(fixtureDir) || !existsSync(file) || statSync(file).isDirectory()) {
57
+ res.writeHead(404).end('not found')
58
+ return
59
+ }
60
+ res.writeHead(200, { 'content-type': MIME[extname(file)] ?? 'application/octet-stream' })
61
+ res.end(readFileSync(file))
62
+ })
63
+ await new Promise((r) => server.listen(0, '127.0.0.1', r))
64
+ const port = server.address().port
65
+
66
+ /*
67
+ * 页面里的量测:5000 行快照的首屏耗时 / DOM 规模,然后灌 400×50 行的 FOLLOW 突发,
68
+ * 记录事件循环最大延迟与收敛后的行数。整段与 .preview/log-perf.mjs 一致,只多出
69
+ * 「返回结构化数字」以便这里断言。
70
+ */
71
+ const MEASURE = `(async () => {
72
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
73
+ const q = (s) => document.querySelector(s)
74
+ const qa = (s) => [...document.querySelectorAll(s)]
75
+ const waitFor = async (fn, timeout = 15000) => {
76
+ const t0 = performance.now()
77
+ for (;;) {
78
+ let v = null
79
+ try { v = fn() } catch { v = null }
80
+ if (v) return performance.now() - t0
81
+ if (performance.now() - t0 > timeout) throw new Error('waitFor 超时')
82
+ await sleep(25)
83
+ }
84
+ }
85
+ const out = { steps: [] }
86
+ try {
87
+ await waitFor(() => q('.dk_panel'))
88
+ await waitFor(() => qa('.dk_card').length > 0)
89
+ const LINE = '2026-09-24T12:00:00.000Z [INFO] request handled in 12ms path=/api/v1/orders/{id} trace=abc'
90
+ const BIG = Array.from({ length: 5000 }, (_, i) => LINE + ' seq=' + String(i)).join('\\n')
91
+ const realFetch = window.fetch.bind(window)
92
+ window.fetch = (url, init) => {
93
+ const target = typeof url === 'string' ? url : String(url && url.url ? url.url : url)
94
+ if (target.indexOf('/logs/stream') === -1 && target.indexOf('/logs') !== -1) {
95
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ ok: true, logs: { id: 'app-web-1', text: BIG, truncated: false } }) })
96
+ }
97
+ return realFetch(url, init)
98
+ }
99
+ const card = qa('.dk_card')[0]
100
+ if (!card) throw new Error('没有容器卡片')
101
+ card.click()
102
+ await waitFor(() => q('.dk_tab'))
103
+ const logsTab = qa('.dk_tab').find((el) => el.textContent.trim() === '日志')
104
+ if (!logsTab) throw new Error('没有日志页签')
105
+ const t0 = performance.now()
106
+ logsTab.click()
107
+ await waitFor(() => qa('.dk_logLine').length >= 4000)
108
+ await sleep(150)
109
+ out.snapshotMs = Math.round(performance.now() - t0)
110
+ out.snapshotRows = qa('.dk_logLine').length
111
+ out.snapshotNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
112
+
113
+ let maxLag = 0
114
+ let last = performance.now()
115
+ const probe = setInterval(() => {
116
+ const now = performance.now()
117
+ maxLag = Math.max(maxLag, now - last - 16)
118
+ last = now
119
+ }, 16)
120
+
121
+ let emit = null
122
+ window.EventSource = class {
123
+ constructor() {
124
+ this.readyState = 0
125
+ this.listeners = new Map()
126
+ setTimeout(() => { this.readyState = 1; if (this.onopen) this.onopen() }, 0)
127
+ emit = (event, data) => {
128
+ for (const h of this.listeners.get(event) ?? []) h({ data: JSON.stringify(data) })
129
+ }
130
+ }
131
+ addEventListener(name, fn) {
132
+ const list = this.listeners.get(name) ?? []
133
+ list.push(fn)
134
+ this.listeners.set(name, list)
135
+ }
136
+ close() { this.readyState = 2 }
137
+ }
138
+ const follow = qa('.dk_pill').find((el) => el.className.indexOf('dk_pillFollow') >= 0)
139
+ if (!follow) throw new Error('没有 FOLLOW 开关')
140
+ const t1 = performance.now()
141
+ follow.click()
142
+ await waitFor(() => emit !== null, 5000)
143
+ const CHUNK = Array.from({ length: 50 }, (_, i) => LINE + ' burst=' + String(i)).join('\\n') + '\\n'
144
+ for (let i = 0; i < 400; i += 1) {
145
+ emit('line', { d: CHUNK })
146
+ if (i % 20 === 0) await sleep(0)
147
+ }
148
+ await sleep(800)
149
+ clearInterval(probe)
150
+ out.burstMs = Math.round(performance.now() - t1)
151
+ out.burstRows = qa('.dk_logLine').length
152
+ out.burstMaxLagMs = Math.round(maxLag)
153
+ out.burstNodes = q('.dk_logBody') ? q('.dk_logBody').querySelectorAll('*').length : -1
154
+ out.droppedBanner = qa('.dk_banner').some((el) => el.textContent.indexOf('丢弃最早') >= 0)
155
+ out.stillInteractive = qa('.dk_tab').length >= 3 && q('.dk_logBody') !== null
156
+ return JSON.stringify(out)
157
+ } catch (error) {
158
+ return JSON.stringify({ error: error instanceof Error ? error.message : String(error), steps: out.steps })
159
+ }
160
+ })()`
161
+
162
+ const chrome = await Chrome.launch({ path: chromePath, extraArgs: ['--no-sandbox'] })
163
+ let raw = '{}'
164
+ try {
165
+ await chrome.attachToPage()
166
+ await chrome.send('Page.enable')
167
+ await chrome.send('Runtime.enable')
168
+ await chrome.send('Page.navigate', { url: `http://127.0.0.1:${String(port)}/harness.html?scenario=docker-panel&theme=light` })
169
+ raw = await chrome.evaluate(MEASURE)
170
+ } finally {
171
+ chrome.close()
172
+ server.close()
173
+ }
174
+
175
+ let result
176
+ try {
177
+ result = JSON.parse(raw)
178
+ } catch {
179
+ console.error('[log-perf] 量测返回不可解析:' + raw.slice(0, 400))
180
+ process.exit(1)
181
+ }
182
+ if (result.error !== undefined) {
183
+ console.error('[log-perf] 量测失败:' + result.error)
184
+ process.exit(1)
185
+ }
186
+
187
+ console.log('[log-perf] 5000 行快照:' + String(result.snapshotMs) + 'ms / ' + String(result.snapshotRows) + ' 行 / ' + String(result.snapshotNodes) + ' 个 DOM 节点')
188
+ console.log('[log-perf] FOLLOW 突发 20000 行:' + String(result.burstMs) + 'ms / 事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms / 收敛后 ' + String(result.burstRows) + ' 行 / ' + String(result.burstNodes) + ' 节点')
189
+ console.log('[log-perf] 丢弃横幅 ' + String(result.droppedBanner) + ' / 仍可交互 ' + String(result.stillInteractive))
190
+
191
+ const failures = []
192
+ if (result.snapshotMs > BUDGET.snapshotMs) failures.push('快照渲染 ' + String(result.snapshotMs) + 'ms 超出预算 ' + String(BUDGET.snapshotMs) + 'ms')
193
+ if (result.snapshotRows < BUDGET.snapshotRows) failures.push('快照只渲染了 ' + String(result.snapshotRows) + ' 行(应 ≥ ' + String(BUDGET.snapshotRows) + ')')
194
+ if (result.burstMs > BUDGET.burstMs) failures.push('突发处理 ' + String(result.burstMs) + 'ms 超出预算 ' + String(BUDGET.burstMs) + 'ms')
195
+ if (result.burstMaxLagMs > BUDGET.burstMaxLagMs) failures.push('事件循环最大延迟 ' + String(result.burstMaxLagMs) + 'ms 超出预算 ' + String(BUDGET.burstMaxLagMs) + 'ms')
196
+ if (result.burstRows > BUDGET.snapshotRows) failures.push('突发后行数 ' + String(result.burstRows) + ' 超过缓冲上限 ' + String(BUDGET.snapshotRows))
197
+ if (result.snapshotNodes > BUDGET.maxNodes || result.burstNodes > BUDGET.maxNodes) failures.push('DOM 节点数超出预算 ' + String(BUDGET.maxNodes))
198
+ if (result.droppedBanner !== true) failures.push('突发撑爆缓冲后没有「已丢弃最早内容」横幅')
199
+ if (result.stillInteractive !== true) failures.push('突发后界面失去可交互性')
200
+
201
+ if (failures.length > 0) {
202
+ console.error('[log-perf] 性能预算未通过:')
203
+ for (const item of failures) console.error(' - ' + item)
204
+ process.exit(1)
205
+ }
206
+ console.log('[log-perf] 性能预算通过')
@@ -173,19 +173,26 @@ function makeCtx(config, options = {}) {
173
173
  settingsStored: {},
174
174
  ttyConfig: options.ttyConfig,
175
175
  disposers: [],
176
+ /**
177
+ * 本插件 entry 的安装级配置(= apply 拿到的 config)。DSH ≥0.1.7 的
178
+ * `settings.describe()` 返回的是「默认值 ← 组合 base ← 用户层」的 resolved 值,
179
+ * 这里用 base ∪ settingsStored 模拟同一件事。
180
+ */
181
+ baseConfig: config,
176
182
  }
177
183
 
178
- const scopeFor = (base) => ({
179
- get: () => ({ ...base, ...state.settingsStored }),
180
- update: async (patch) => {
181
- Object.assign(state.settingsStored, patch)
182
- // 真实实现会 emit settings/updated;这里同步触发,验证热更新路径
183
- const listeners = state.listeners.get('settings/updated') ?? []
184
- for (const listener of listeners) listener('docker', { ...base, ...state.settingsStored }, undefined, 'update')
185
- },
186
- })
184
+ /** loader 检出 volatile-only 变更后发的事件(测试里同步发,验证热更新路径)。 */
185
+ const emitVolatile = () => {
186
+ for (const listener of state.listeners.get('loader/volatile-update') ?? []) listener()
187
+ }
187
188
 
188
189
  const makeChild = (names) => {
190
+ const on = (name, listener) => {
191
+ const list = state.listeners.get(name) ?? []
192
+ list.push(listener)
193
+ state.listeners.set(name, list)
194
+ return () => {}
195
+ }
189
196
  const child = {
190
197
  logger: { info: () => {}, warn: () => {} },
191
198
  effect: (callback, _name) => {
@@ -198,14 +205,8 @@ function makeCtx(config, options = {}) {
198
205
  cb(grand)
199
206
  return () => {}
200
207
  },
201
- events: {
202
- on: (name, listener) => {
203
- const list = state.listeners.get(name) ?? []
204
- list.push(listener)
205
- state.listeners.set(name, list)
206
- return () => {}
207
- },
208
- },
208
+ on,
209
+ events: { on },
209
210
  }
210
211
  if (names.includes('tools')) {
211
212
  child.tools = {
@@ -227,9 +228,19 @@ function makeCtx(config, options = {}) {
227
228
  }
228
229
  }
229
230
  if (names.includes('settings')) {
231
+ // DSH ≥0.1.7 的 settings 服务(SettingsForms):describe / update / configure。
232
+ // describe 里额外放一个 tty entry,模拟「tty 已安装、连接簿可读」。
230
233
  child.settings = {
231
- register: (ns, schema, options_) => scopeFor(options_?.base ?? {}),
232
- get: (ns) => (ns === 'tty' ? state.ttyConfig : undefined),
234
+ describe: () => [
235
+ { ns: 'docker', value: { ...state.baseConfig, ...state.settingsStored } },
236
+ ...(state.ttyConfig === undefined ? [] : [{ ns: 'tty', value: state.ttyConfig }]),
237
+ ],
238
+ update: async (ns, patch) => {
239
+ if (ns !== 'docker') throw new Error('未知的 entry: ' + ns)
240
+ Object.assign(state.settingsStored, patch)
241
+ emitVolatile()
242
+ },
243
+ configure: () => () => {},
233
244
  }
234
245
  }
235
246
  if (names.includes('systemPrompt')) {
@@ -700,6 +711,27 @@ await test('POST /inspect:容器名注入尝试被白名单拒绝(D37 起回
700
711
  assert.match(res.body.error, /container 含非法字符/)
701
712
  })
702
713
 
714
+ await test('单目标连不上:回 200 + ok:false(不是不透明 500)——与 target=* 同一口径', async () => {
715
+ /*
716
+ * Windows 真机实测挖出来的不对称:同一句目标侧错误,`target:'*'` 走聚合路径回
717
+ * 200 + groups[].ok:false,单目标却一路冒到外层 catch 变成 500。
718
+ * 「直连」是 127.0.0.1:1 —— 连不上是**运维状况**,不是服务端 bug。
719
+ */
720
+ const one = await call('POST', '/containers', { target: '直连', all: true })
721
+ assert.equal(one.status, 200, '单目标失败不该是 5xx:' + JSON.stringify(one.body))
722
+ assert.equal(one.body.ok, false)
723
+ assert.equal(one.body.target, '直连')
724
+ assert.equal(typeof one.body.error, 'string')
725
+ assert.ok(one.body.error.length > 0, '必须带可读原因')
726
+ // 与聚合口径对照:同一次失败在 target='*' 下也是 ok:false
727
+ const all = await call('POST', '/containers', { target: '*', all: true })
728
+ assert.equal(all.status, 200)
729
+ const group = (all.body.groups ?? []).find((row) => row.target === '直连')
730
+ assert.equal(group?.ok, false, '聚合路径里这条目标也该是 ok:false')
731
+ // 载荷形状不能串:单目标失败时不该顺手给出一个空的 containers 列表
732
+ assert.equal(one.body.containers, undefined)
733
+ })
734
+
703
735
  await test('非 loopback 请求 403', async () => {
704
736
  const res = await call('GET', '/config', undefined, '10.0.0.9')
705
737
  assert.equal(res.status, 403)