@hyzyn/dsh-docker 0.7.3 → 0.8.1

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.
@@ -19,6 +19,8 @@
19
19
  import dockerCss from './docker.css'
20
20
  import { bookSessionHost, pickTargetByHost, sessionHostPort, staleBookRef } from './session-target.js'
21
21
  import { currentSessionIdOf } from './current-session.js'
22
+ import { createLogBuffer, splitLogLines } from './log-buffer.js'
23
+ import { subscribeLogStream, reconnectTail, LOG_RECONNECT_BASE_MS, LOG_RECONNECT_MAX_MS } from './log-stream.js'
22
24
 
23
25
  const API = '/api/dsh-docker'
24
26
  const PANEL_STYLE_ID = 'dsh-docker-style'
@@ -1312,7 +1314,11 @@ window.__ModuleLoader__.load({
1312
1314
  jsxs('div', { className: 'dk_cardRows', children: [
1313
1315
  jsx(CardRow, { label: '镜像', value: item.image }, 'image'),
1314
1316
  jsx(CardRow, { label: 'ID', value: item.shortId }, 'id'),
1315
- jsx(CardRow, { label: '端口', value: portsText(item.ports) }, 'ports'),
1317
+ // host 网络容器在 ps 里没有端口映射:空值加说明,免得被读成「没暴露端口」(D131)
1318
+ jsx(CardRow, {
1319
+ label: '端口',
1320
+ value: portsText(item.ports) + (item.ports.length === 0 && Array.isArray(item.networks) && item.networks.includes('host') ? '(host 网络:端口即宿主机端口)' : ''),
1321
+ }, 'ports'),
1316
1322
  jsx(CardRow, { label: '创建', value: created }, 'created'),
1317
1323
  item.composeProject === null ? null : jsx(CardRow, { label: 'compose', value: item.composeProject + (item.composeService === null ? '' : '/' + item.composeService) }, 'compose'),
1318
1324
  ] }, 'rows'),
@@ -1342,17 +1348,43 @@ window.__ModuleLoader__.load({
1342
1348
  * ------------------------------------------------------------------ */
1343
1349
 
1344
1350
  /**
1345
- * 日志行前缀解析:时间戳与级别各自成 span,便于分级着色。两种常见格式都吃:
1351
+ * 日志行前缀解析:时间戳与级别各自成 span,便于分级着色。三种常见格式都吃:
1346
1352
  * `[INFO] [2026-09-09 18:05:52] ...`(级别在前)
1353
+ * `[INFO ] [2026-09-25 11:31:00] ...`(同上,但级别被 `%5p` 右填充到 5 字符)
1347
1354
  * `16:11:34,150 |INFO in ...`(Spring Boot,时间戳在前、级别用竖线)
1355
+ *
1356
+ * 方括号里的 `\s*` 不是装饰:Logback / Spring 的 `%5p` 会把 INFO / WARN / DEBUG 右填充成
1357
+ * `[INFO ]` / `[WARN ]`,而 ERROR / FATAL 恰好 5 字符不带空格。旧正则只认 `[INFO]`,
1358
+ * 于是填充过的行全部判成「无级别」——分级着色丢掉,**级别门槛也整个失效**:
1359
+ * `filterByLevelCore` 对未知级别走「不误杀」分支(rank=null 一律保留),
1360
+ * 结果就是选了 WARN+ 仍能看到一屏 INFO。
1348
1361
  */
1349
1362
  const LOG_TS_RE = /^\s*(\[\d{4}-\d{2}-\d{2}[ T][0-9:.,]+\]|\d{2}:\d{2}:\d{2}[,.]\d{3})/
1350
- const LOG_LEVEL_RE = /^\s*(\[(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\]|\|\s*(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL))/
1363
+ const LOG_LEVEL_RE = /^\s*(\[\s*(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL)\s*\]|\|\s*(?:TRACE|DEBUG|INFO|WARN|ERROR|FATAL))/
1351
1364
  const LOG_LEVEL_NAME_RE = /(TRACE|DEBUG|INFO|WARN|ERROR|FATAL)/
1352
- /** 着色行数上限:超大日志整篇着色会拖慢渲染,超出只对尾部着色。 */
1353
- const LOG_COLOR_LIMIT = 2000
1354
- /** FOLLOW 流式日志的环形缓冲上限:超出丢最旧并提示一次(防止长时间跟随吃内存)。 */
1365
+ /**
1366
+ * FOLLOW 流式日志的环形缓冲行数上限:超出丢最旧并提示一次(防止长时间跟随吃内存)。
1367
+ *
1368
+ * 显示**不再另设截断**:LINES 选多少就渲染/导出多少——真正的闸是这里的缓冲
1369
+ * (行数 + 字节)与宿主的「输出上限(KB)」,再多加一层「只显示最近 N 行」只会
1370
+ * 让 LINES 说了不算。行 key 用单调 id(见 log-buffer.js),窗口滑动只挂载/卸载
1371
+ * 边界行,行数本身不再触发全量重绘。
1372
+ */
1355
1373
  const FOLLOW_LINE_LIMIT = 5000
1374
+ /**
1375
+ * FOLLOW 缓冲的字节上限(UTF-16 unit 数):旧实现只限行数,容器吐大行(整段
1376
+ * JSON / base64 / 压平的堆栈)时 5000 行能吃掉数百 MB——这是「日志多 → 崩溃」
1377
+ * 的内存根因之一。超限丢最旧,与行数上限共用同一套 dropped 提示。
1378
+ */
1379
+ const FOLLOW_BYTE_LIMIT = 4 * 1024 * 1024
1380
+ /** 单条残行上限:无换行输出超此长度强制落为分片行(否则 pending 无界增长)。 */
1381
+ const LOG_PENDING_MAX = 1024 * 1024
1382
+ /**
1383
+ * FOLLOW 合帧间隔:SSE chunk 到达速率与渲染频率解耦——缓冲照单全收,React
1384
+ * 每 150ms 至多消费一次快照。打开 FOLLOW 时的 tail 突发(docker logs -f 先把
1385
+ * 历史一口气吐完)从「几百次全量重渲染」变成「几次」。
1386
+ */
1387
+ const FOLLOW_FLUSH_MS = 150
1356
1388
 
1357
1389
  /*
1358
1390
  * 聚合视图的**每容器**初始行数。
@@ -1370,7 +1402,7 @@ window.__ModuleLoader__.load({
1370
1402
  * Compose 聚合日志共用同一套着色:聚合日志只需要在这几个片段前再插一个
1371
1403
  * `[service]` 前缀即可,不必复制一份正则与渲染逻辑。
1372
1404
  */
1373
- function renderLogParts(line, index, query) {
1405
+ function renderLogParts(line, keyBase, query) {
1374
1406
  const nodes = []
1375
1407
  let rest = line
1376
1408
  // 最多吃掉两个前缀(时间戳 + 级别,顺序不限)
@@ -1390,16 +1422,20 @@ window.__ModuleLoader__.load({
1390
1422
  }
1391
1423
  break
1392
1424
  }
1393
- nodes.push(jsx('span', { className: 'dk_logText', children: highlight(rest, query, 'x' + String(index)) }, 'tx'))
1425
+ nodes.push(jsx('span', { className: 'dk_logText', children: highlight(rest, query, 'x' + String(keyBase)) }, 'tx'))
1394
1426
  return nodes
1395
1427
  }
1396
1428
 
1397
- function renderLogLine(line, index, query) {
1398
- return jsxs('div', { className: 'dk_logLine', children: renderLogParts(line, index, query) }, String(index))
1429
+ /**
1430
+ * 行 key = 行的单调 id(D63 根治):旧实现用渲染下标当 key,环形缓冲一滑动
1431
+ * 每行内容全变 → 每帧全量 reconcile;id 稳定后窗口滑动只挂载/卸载边界行。
1432
+ */
1433
+ function renderLogLine(entry, query) {
1434
+ return jsxs('div', { className: 'dk_logLine', children: renderLogParts(entry.text, entry.id, query) }, String(entry.id))
1399
1435
  }
1400
1436
 
1401
1437
  /** Compose 聚合日志行:在标准日志行前加一个 `[service]` 前缀。 */
1402
- function renderAggLine(entry, index, query, showTs) {
1438
+ function renderAggLine(entry, query, showTs) {
1403
1439
  const ts = showTs === true && typeof entry.ts === 'number' && Number.isFinite(entry.ts)
1404
1440
  ? jsx('span', { className: 'dk_logTs', children: new Date(entry.ts).toLocaleTimeString() }, 'ts')
1405
1441
  : null
@@ -1411,9 +1447,9 @@ window.__ModuleLoader__.load({
1411
1447
  children: [
1412
1448
  jsx('span', { className: 'dk_logSvc', children: '[' + entry.service + ']' }, 'svc'),
1413
1449
  ts,
1414
- ...renderLogParts(entry.text, index, query),
1450
+ ...renderLogParts(entry.text, entry.id, query),
1415
1451
  ],
1416
- }, String(index))
1452
+ }, String(entry.id))
1417
1453
  }
1418
1454
 
1419
1455
  /* ------------------------------------------------------------------ *
@@ -1711,14 +1747,19 @@ window.__ModuleLoader__.load({
1711
1747
  menu.className = 'dk_menu'
1712
1748
  menu.setAttribute('role', 'menu')
1713
1749
 
1714
- const head = document.createElement('div')
1715
- head.className = 'dk_menuHead'
1716
- head.textContent = options.head
1717
- menu.appendChild(head)
1718
- const sub = document.createElement('div')
1719
- sub.className = 'dk_menuSub'
1720
- sub.textContent = options.sub
1721
- menu.appendChild(sub)
1750
+ // head / sub / note 都可省:导出菜单只有两行选项,套一层标题+注解反而比菜单本身高
1751
+ if (options.head !== undefined) {
1752
+ const head = document.createElement('div')
1753
+ head.className = 'dk_menuHead'
1754
+ head.textContent = options.head
1755
+ menu.appendChild(head)
1756
+ }
1757
+ if (options.sub !== undefined) {
1758
+ const sub = document.createElement('div')
1759
+ sub.className = 'dk_menuSub'
1760
+ sub.textContent = options.sub
1761
+ menu.appendChild(sub)
1762
+ }
1722
1763
 
1723
1764
  for (const item of options.items) {
1724
1765
  const btn = document.createElement('button')
@@ -1741,10 +1782,12 @@ window.__ModuleLoader__.load({
1741
1782
  menu.appendChild(btn)
1742
1783
  }
1743
1784
 
1744
- const note = document.createElement('div')
1745
- note.className = 'dk_menuNote'
1746
- note.textContent = options.note
1747
- menu.appendChild(note)
1785
+ if (options.note !== undefined) {
1786
+ const note = document.createElement('div')
1787
+ note.className = 'dk_menuNote'
1788
+ note.textContent = options.note
1789
+ menu.appendChild(note)
1790
+ }
1748
1791
 
1749
1792
  document.body.appendChild(menu)
1750
1793
  placeFloating(menu, options.x, options.y)
@@ -1767,8 +1810,47 @@ window.__ModuleLoader__.load({
1767
1810
  }
1768
1811
  }
1769
1812
 
1770
- /* ---------------------- 投递 ---------------------- */
1813
+ /**
1814
+ * 日志导出:两个格式(`.log` / `.md`)与触发按钮文案的**唯一定义**,单容器日志与
1815
+ * 聚合日志两个视图共用。
1816
+ *
1817
+ * 为什么收成一份:这两个视图已经因为「各写一套」栽过好几次(级别档位、过滤内核都
1818
+ * 是这么对齐的)。格式集合、hint、按钮文案一旦分家,改一处忘一处,症状还很轻——
1819
+ * 只是两个视图长得不一样,没人报 bug。
1820
+ */
1821
+ const LOG_EXPORT_LABEL = '⬇ 导出'
1822
+ function exportMenuItems(onPick) {
1823
+ return [
1824
+ { label: '⬇ .log', hint: '纯文本,逐行原样', onPick: () => onPick('log') },
1825
+ { label: '⬇ .md', hint: '带来源与行数表头,适合当工单附件', onPick: () => onPick('md') },
1826
+ ]
1827
+ }
1828
+
1829
+ /**
1830
+ * 工具条上那个「导出」按钮的浮层菜单(D137)。
1831
+ *
1832
+ * 为什么合并:两个格式各占一个 chip,窄面板(或 docked / tab 承载)下 `.dk_filterBar`
1833
+ * 一换行,`.md` 就被甩到第二行、把行数计数也带下去——工具条长成两行,视觉上像是
1834
+ * 「布局坏了」。合成一个按钮后工具条少一格,两种格式仍在菜单里各有一项。
1835
+ *
1836
+ * 复用 openLogMenu:Esc / 点外部 / 滚轮 / 触摸 / resize 的关闭语义与右键菜单完全一致,
1837
+ * 不另造一套。锚点取按钮自己的矩形(贴着下沿展开),越界由 placeFloating 夹回。
1838
+ */
1839
+ function openExportMenu(event, options) {
1840
+ const target = event === null || event === undefined ? null : event.currentTarget
1841
+ const rect = target !== null && target !== undefined && typeof target.getBoundingClientRect === 'function'
1842
+ ? target.getBoundingClientRect()
1843
+ : null
1844
+ openLogMenu({
1845
+ x: rect === null ? 0 : rect.left,
1846
+ y: rect === null ? 0 : rect.bottom + 4,
1847
+ head: '导出日志',
1848
+ ...(options.sub === undefined ? {} : { sub: options.sub }),
1849
+ items: exportMenuItems(options.onPick),
1850
+ })
1851
+ }
1771
1852
 
1853
+ /* ---------------------- 投递 ---------------------- */
1772
1854
  /** 非安全上下文(远端 GUI 用 IP 访问)没有 navigator.clipboard,退回 textarea。 */
1773
1855
  function copyToClipboard(text) {
1774
1856
  if (navigator.clipboard !== undefined && navigator.clipboard !== null) {
@@ -1908,6 +1990,8 @@ window.__ModuleLoader__.load({
1908
1990
  function ContainerView(props) {
1909
1991
  const item = props.item
1910
1992
  const config = props.config
1993
+ /** 宿主的单次输出字节上限(KB):快照日志的第二个闸,文案里要说清是它截断的。 */
1994
+ const outputCapKb = Number(config.maxOutputKb) || 0
1911
1995
  const [tab, setTab] = useState(props.initialTab ?? 'overview')
1912
1996
  /** 面板是否可见(S3):折叠的 tab 不建流。模态 / dock 形态下恒为 true。 */
1913
1997
  const active = usePanelActive()
@@ -1926,10 +2010,13 @@ window.__ModuleLoader__.load({
1926
2010
  const [logIntervalSec, setLogIntervalSec] = useState(3)
1927
2011
  /*
1928
2012
  * 实时跟随(FOLLOW,docker logs -f → SSE):
1929
- * followLines 是环形缓冲(≤ FOLLOW_LINE_LIMIT 行),followPending 存
1930
- * 最后一段没等到 \n 的残行;followStatus 只表达连接状态(connecting /
1931
- * open / reconnecting / closed),**连接错误不弹横幅**(EventSource 会
1932
- * 自动重连,弹一次就会刷屏),只有服务端 event:error 才进 followError。
2013
+ * followBuf 是环形缓冲(≤ FOLLOW_LINE_LIMIT 行且 ≤ FOLLOW_BYTE_LIMIT,
2014
+ * 残行超 LOG_PENDING_MAX 强制分片,见 log-buffer.js),followLines 是它
2015
+ * 按 FOLLOW_FLUSH_MS **合帧**后的快照——渲染频率与 chunk 速率解耦(D63
2016
+ * 根治:旧实现逐 chunk setState,tail 突发即几百次全量重渲染)。
2017
+ * followStatus 只表达连接状态(connecting / open / reconnecting / closed),
2018
+ * **连接错误不弹横幅**(EventSource 会自动重连,弹一次就会刷屏),只有
2019
+ * 服务端 event:error 才进 followError。
1933
2020
  */
1934
2021
  const [follow, setFollow] = useState(false)
1935
2022
  const [followLines, setFollowLines] = useState([])
@@ -1938,8 +2025,37 @@ window.__ModuleLoader__.load({
1938
2025
  const [followNotice, setFollowNotice] = useState('')
1939
2026
  const [followDropped, setFollowDropped] = useState(false)
1940
2027
  const [followAtBottom, setFollowAtBottom] = useState(true)
1941
- const followLinesRef = useRef([])
1942
- const followPendingRef = useRef('')
2028
+ const followBufRef = useRef(null)
2029
+ const followDirtyRef = useRef(false)
2030
+ const followTimerRef = useRef(null)
2031
+
2032
+ /** 合帧落地:把缓冲快照交给 React;期间的淘汰只提示一次(dropped 读后清零)。 */
2033
+ const flushFollow = () => {
2034
+ followTimerRef.current = null
2035
+ if (!followDirtyRef.current) return
2036
+ followDirtyRef.current = false
2037
+ const buffer = followBufRef.current
2038
+ if (buffer === null) return
2039
+ setFollowLines(buffer.snapshot())
2040
+ if (buffer.takeDropped()) setFollowDropped(true)
2041
+ }
2042
+
2043
+ /** 有完整新行才排渲染;残行增长不打扰 React(旧版逐 chunk setState 的病根)。 */
2044
+ const scheduleFollowFlush = () => {
2045
+ followDirtyRef.current = true
2046
+ if (followTimerRef.current === null) {
2047
+ followTimerRef.current = setTimeout(flushFollow, FOLLOW_FLUSH_MS)
2048
+ }
2049
+ }
2050
+
2051
+ /** 关流 / 切快照前收掉合帧:别让迟到的一次 flush 在切换后再动 state。 */
2052
+ const cancelFollowFlush = () => {
2053
+ followDirtyRef.current = false
2054
+ if (followTimerRef.current !== null) {
2055
+ clearTimeout(followTimerRef.current)
2056
+ followTimerRef.current = null
2057
+ }
2058
+ }
1943
2059
  const logBodyRef = useRef(null)
1944
2060
  const [stats, setStats] = useState(null)
1945
2061
  const [statsError, setStatsError] = useState('')
@@ -2022,8 +2138,8 @@ window.__ModuleLoader__.load({
2022
2138
  return undefined
2023
2139
  }
2024
2140
  // 每次重开流都从空缓冲开始,避免把上一次的行混进来
2025
- followLinesRef.current = []
2026
- followPendingRef.current = ''
2141
+ followBufRef.current = createLogBuffer({ maxLines: FOLLOW_LINE_LIMIT, maxBytes: FOLLOW_BYTE_LIMIT, maxPendingBytes: LOG_PENDING_MAX })
2142
+ cancelFollowFlush()
2027
2143
  setFollowLines([])
2028
2144
  setFollowDropped(false)
2029
2145
  setFollowError('')
@@ -2031,86 +2147,70 @@ window.__ModuleLoader__.load({
2031
2147
  setFollowAtBottom(true)
2032
2148
  setFollowStatus('connecting')
2033
2149
 
2034
- const params = new URLSearchParams({
2035
- target: props.target,
2036
- id: item.id,
2037
- tail: String(logOptions.tail),
2038
- ...(logOptions.timestamps ? { timestamps: '1' } : {}),
2039
- })
2040
- const es = new EventSource(API + '/logs/stream?' + params.toString())
2041
- let closed = false
2042
- const close = () => {
2043
- if (closed) return
2044
- closed = true
2045
- try { es.close() } catch { /* 已关闭 */ }
2046
- }
2047
-
2048
- /** 分片 → 完整行:docker 的 chunk 不按行切,末段残行留给下一片。 */
2150
+ /**
2151
+ * 分片 → 完整行:docker 的 chunk 不按行切,末段残行留给下一片。
2152
+ * 切分/淘汰都在缓冲里做,这里只负责「有新行 → 排一次合帧」。
2153
+ */
2049
2154
  const pushChunk = (text) => {
2050
2155
  if (text === '') return
2051
- const parts = (followPendingRef.current + text).split('\n')
2052
- followPendingRef.current = parts.pop() ?? ''
2053
- if (parts.length === 0) return
2054
- const next = followLinesRef.current.concat(parts)
2055
- const trimmed = next.length > FOLLOW_LINE_LIMIT ? next.slice(next.length - FOLLOW_LINE_LIMIT) : next
2056
- followLinesRef.current = trimmed
2057
- if (trimmed.length !== next.length) setFollowDropped(true)
2058
- setFollowLines(trimmed)
2059
- }
2060
-
2061
- const onLine = (event) => {
2062
- let payload = null
2063
- try { payload = JSON.parse(event.data) } catch { return }
2064
- if (payload === null || typeof payload !== 'object') return
2065
- if (typeof payload.d === 'string') pushChunk(payload.d)
2066
- else if (typeof payload.e === 'string') pushChunk(payload.e)
2067
- }
2068
-
2069
- const onEnd = (event) => {
2070
- let payload = null
2071
- try { payload = JSON.parse(event.data) } catch { /* 畸形载荷按容器退出处理 */ }
2072
- const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
2073
- const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
2074
- if (reason === 'container-exit') {
2075
- // 容器停止 → docker logs -f 自然退出:关流、切回快照并立即补一次刷新
2076
- setFollowNotice('容器已退出' + (code === null ? '' : '(退出码 ' + String(code) + ')') + ',日志流结束,已切回快照')
2077
- close()
2078
- setFollow(false)
2079
- loadLogs()
2080
- return
2081
- }
2082
- // 服务端主动停流(插件禁用 / 配置热更新):交给 EventSource 自动重连
2083
- setFollowStatus('reconnecting')
2084
- setFollowNotice('服务端已停止日志流,正在重连…')
2156
+ const result = followBufRef.current.pushChunk(text)
2157
+ if (result.appended > 0) scheduleFollowFlush()
2085
2158
  }
2086
2159
 
2087
- const onError = (event) => {
2088
- // 服务端 event:error 是带 data 的 MessageEvent;连接层错误是普通 Event
2089
- if (typeof event.data === 'string' && event.data !== '') {
2090
- let message = '日志流异常'
2091
- try {
2092
- const payload = JSON.parse(event.data)
2093
- if (payload !== null && typeof payload.message === 'string') message = payload.message
2094
- } catch { /* 用默认文案 */ }
2160
+ /** 订阅句柄:onEnd 里要能关掉自己,而它在 subscribeLogStream 返回之前就已定义。 */
2161
+ let subscription = null
2162
+ /*
2163
+ * 断线重连交给 subscribeLogStream(D133):首连带 tail 补历史,**重连带
2164
+ * tail=0** 只补新行。EventSource 自带的自动重连会复用带 tail 的 URL —— 服务端
2165
+ * 把最后 tail 行当新行再推一遍,客户端就会重复回填(还会挤掉真正的历史)。
2166
+ */
2167
+ subscription = subscribeLogStream({
2168
+ buildUrl: (tailNow) => streamUrl('/logs/stream', {
2169
+ target: props.target,
2170
+ id: item.id,
2171
+ tail: String(tailNow),
2172
+ ...(logOptions.timestamps ? { timestamps: '1' } : {}),
2173
+ }),
2174
+ tail: logOptions.tail,
2175
+ onStatus: (status) => {
2176
+ if (status === 'open') setFollowNotice('')
2177
+ setFollowStatus(status)
2178
+ },
2179
+ onLine: pushChunk,
2180
+ onEnd: (payload, controls) => {
2181
+ const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
2182
+ const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
2183
+ if (reason === 'container-exit') {
2184
+ // 容器停止 → docker logs -f 自然退出:关流、切回快照并立即补一次刷新
2185
+ setFollowNotice('容器已退出' + (code === null ? '' : '(退出码 ' + String(code) + ')') + ',日志流结束,已切回快照')
2186
+ subscription?.close()
2187
+ cancelFollowFlush()
2188
+ setFollow(false)
2189
+ loadLogs()
2190
+ return
2191
+ }
2192
+ if (reason === 'output-limit') {
2193
+ // 宿主侧背压队列溢出(推送快过浏览器消费):不是容器的问题,重连即可
2194
+ setFollowNotice('主机侧日志积压超出上限(推送速度超过浏览器消费速度),已断开并重连;重连只补新行,不重复历史')
2195
+ controls.reconnect()
2196
+ return
2197
+ }
2198
+ // 服务端主动停流(插件禁用 / 配置热更新):重连也会补新行
2199
+ setFollowNotice('服务端已停止日志流,正在重连…')
2200
+ controls.reconnect()
2201
+ },
2202
+ onError: (message) => {
2095
2203
  setFollowError(message)
2096
- close()
2204
+ subscription?.close()
2205
+ cancelFollowFlush()
2097
2206
  setFollow(false)
2098
2207
  loadLogs()
2099
- return
2100
- }
2101
- // 连接层错误:浏览器按 EventSource 语义自动重连,这里只更新状态,
2102
- // 不弹错误横幅(否则每次重试都会刷一条)
2103
- setFollowStatus(es.readyState === 2 ? 'closed' : 'reconnecting')
2104
- }
2105
-
2106
- es.addEventListener('line', onLine)
2107
- es.addEventListener('end', onEnd)
2108
- es.addEventListener('error', onError)
2109
- es.onopen = () => {
2110
- setFollowStatus('open')
2111
- setFollowNotice('')
2208
+ },
2209
+ })
2210
+ return () => {
2211
+ subscription?.close()
2212
+ cancelFollowFlush()
2112
2213
  }
2113
- return close
2114
2214
  // active 必须在 deps 里(D17):折叠 tab / 切走会话时收掉 SSE,否则
2115
2215
  // `docker logs -f` 会一直占着共享的 SSH 通道额度——与 S3 的设计意图相反
2116
2216
  }, [active, tab, follow, props.target, item.id, logOptions.tail, logOptions.timestamps, loadLogs])
@@ -2126,6 +2226,7 @@ window.__ModuleLoader__.load({
2126
2226
  /** FOLLOW 与 AUTO REFRESH 互斥:开流停轮询;关流立即回快照。 */
2127
2227
  const toggleFollow = () => {
2128
2228
  if (follow) {
2229
+ cancelFollowFlush()
2129
2230
  setFollow(false)
2130
2231
  setFollowStatus('')
2131
2232
  loadLogs()
@@ -2352,25 +2453,34 @@ window.__ModuleLoader__.load({
2352
2453
  }
2353
2454
 
2354
2455
  /**
2355
- * 日志统计(工具条与正文共用):FOLLOW 时数据源是流缓冲,否则是快照。
2356
- * useMemo 到 [数据源, 过滤条件](D63):它在渲染期被 logFilterBar / logsView /
2357
- * logShown 调 2~3 次,每次都 join/split 最多 5000 行——渲染期重复重算是
2358
- * 消息密集时主线程占满的主因之一。
2456
+ * 快照日志 → 行对象:每次 logs 载入只 split 一次(D63 根治——旧实现每次
2457
+ * 渲染都 join/split 最多 5000 行的大字符串)。id 用 `s+下标`:同一次快照内
2458
+ * 稳定,新快照整体换血(贴底/导出语义不受影响)。
2459
+ *
2460
+ * 切分走 splitLogLines(D136):它不是 `text.split('\n')`——结尾那个 `\n`
2461
+ * 是终止符,不该多切出一条空行,否则 `--tail N` 的计数恒为 N+1。
2359
2462
  */
2360
- const logStatsValue = useMemo(() => {
2463
+ const snapshotEntriesValue = useMemo(() => {
2361
2464
  // 只认 string:宿主 /logs 的形状是 { id, text, truncated },但客户端不该
2362
2465
  // 因为一个畸形/旧版响应就在渲染期抛错——那会连整块面板和 exec 终端一起被
2363
2466
  // React 卸载掉(一次日志请求赔进去一个正在跑的容器会话)。
2364
- const raw = follow
2365
- ? followLines.join('\n')
2366
- : (logs !== null && typeof logs === 'object' && typeof logs.text === 'string' ? logs.text : '')
2467
+ const text = logs !== null && typeof logs === 'object' && typeof logs.text === 'string' ? logs.text : ''
2468
+ return splitLogLines(text).map((line, index) => ({ id: 's' + String(index), text: line }))
2469
+ }, [logs])
2470
+
2471
+ /**
2472
+ * 级别/文本过滤结果(工具条与正文共用):FOLLOW 时数据源是流缓冲的合帧快照,
2473
+ * 否则是快照行对象。入口已是行数组,这里只做 O(行数) 的过滤;渲染频率由
2474
+ * FOLLOW 合帧压到 ≤~7 次/秒,主线程不再被逐 chunk 重算占满。
2475
+ */
2476
+ const logStatsValue = useMemo(() => {
2477
+ const allEntries = follow ? followLines : snapshotEntriesValue
2367
2478
  const needle = logFilter.trim().toLowerCase()
2368
- const allLines = raw === '' ? [] : raw.split('\n')
2369
2479
  // 先级别、再文本——与聚合日志同一顺序、同一个内核(含"续行继承上一条级别")
2370
- const leveled = filterLinesByLevel(allLines, levelMin)
2371
- const matchedLines = needle === '' ? leveled : leveled.filter((line) => line.toLowerCase().includes(needle))
2372
- return { raw, needle, allLines, leveled, matchedLines }
2373
- }, [follow, followLines, logs, logFilter, levelMin])
2480
+ const leveled = filterRowsByLevel(allEntries, levelMin)
2481
+ const matched = needle === '' ? leveled : leveled.filter((entry) => entry.text.toLowerCase().includes(needle))
2482
+ return { needle, total: allEntries.length, matched }
2483
+ }, [follow, followLines, snapshotEntriesValue, logFilter, levelMin])
2374
2484
  const logStats = () => logStatsValue
2375
2485
 
2376
2486
  const logPill = (on, label, onClick, options) => jsx('button', {
@@ -2393,6 +2503,8 @@ window.__ModuleLoader__.load({
2393
2503
  jsx('select', {
2394
2504
  className: 'dk_select dk_selectSm',
2395
2505
  value: String(logOptions.tail),
2506
+ // 行数与字节是两个闸:快照还会被设置卡片的「输出上限(KB)」按字节截断
2507
+ title: '拉取的尾部行数。快照另受设置卡片「输出上限(KB)」限制(当前 ' + String(outputCapKb) + 'KB)——行数够但字节超了仍会截断,实际行数可能更少;FOLLOW 流不受该字节上限约束(由本面板的缓冲上限收口)。',
2396
2508
  onChange: (event) => setLogOptions({ ...logOptions, tail: Number(event.target.value) }),
2397
2509
  children: tailOptions.map((value) => jsx('option', { value: String(value), children: value === 5000 ? 'Last 5000' : 'Last ' + String(value) }, String(value))),
2398
2510
  }),
@@ -2438,7 +2550,7 @@ window.__ModuleLoader__.load({
2438
2550
  * 绝对定位,不占布局,所以出现 / 消失都不会让任何东西位移。
2439
2551
  */
2440
2552
  const logFilterBar = () => {
2441
- const { needle, allLines, matchedLines } = logStats()
2553
+ const { needle, total, matched } = logStats()
2442
2554
  return jsxs('div', { className: 'dk_filterBar', children: [
2443
2555
  jsxs('div', { className: 'dk_filterWrap', children: [
2444
2556
  jsx('input', {
@@ -2473,38 +2585,29 @@ window.__ModuleLoader__.load({
2473
2585
  jsx('button', {
2474
2586
  type: 'button',
2475
2587
  className: 'dk_chip',
2476
- disabled: logShown().length === 0,
2477
- title: '导出当前显示内容为 .log(纯文本)',
2478
- onClick: () => doLogExport('log'),
2479
- children: '⬇ .log',
2480
- }, 'exportLog'),
2481
- jsx('button', {
2482
- type: 'button',
2483
- className: 'dk_chip',
2484
- disabled: logShown().length === 0,
2485
- title: '导出当前显示内容为 .md(带来源与行数表头,适合当工单附件)',
2486
- onClick: () => doLogExport('md'),
2487
- children: '⬇ .md',
2488
- }, 'exportMd'),
2588
+ disabled: matched.length === 0,
2589
+ 'aria-haspopup': 'menu',
2590
+ title: '导出当前显示内容:.log(纯文本)/ .md(带来源与行数表头,适合当工单附件)',
2591
+ onClick: (event) => openExportMenu(event, { sub: item.name + ' · ' + String(matched.length) + ' 行', onPick: doLogExport }),
2592
+ children: LOG_EXPORT_LABEL,
2593
+ }, 'export'),
2489
2594
  jsx('span', {
2490
2595
  className: 'dk_filterCount',
2491
2596
  children: needle === '' && levelMin === 0
2492
- ? String(allLines.length) + ' 行'
2493
- : String(matchedLines.length) + ' / ' + String(allLines.length) + ' 行',
2597
+ ? String(total) + ' 行'
2598
+ : String(matched.length) + ' / ' + String(total) + ' 行',
2494
2599
  }, 'count'),
2495
2600
  ] })
2496
2601
  }
2497
2602
 
2498
- /** 当前**显示**的行:受级别 / 文本过滤与显示上限影响——渲染与「导出当前显示内容」共用它。 */
2499
- const logShown = () => {
2500
- const { matchedLines } = logStats()
2501
- return matchedLines.length > LOG_COLOR_LIMIT ? matchedLines.slice(-LOG_COLOR_LIMIT) : matchedLines
2502
- }
2503
-
2504
- /** 导出当前显示内容:与聚合日志同一个构建器、同样两种格式。 */
2603
+ /**
2604
+ * 导出当前显示内容:与聚合日志同一个构建器、同样两种格式。
2605
+ * 显示与导出取**同一个集合**(LINES 选多少就渲染/导出多少)——上限由缓冲
2606
+ * (行数 + 字节)与宿主「输出上限(KB)」决定,不在这里再截一刀。
2607
+ */
2505
2608
  const doLogExport = (format) => {
2506
- const rows = logShown().map((line) => {
2507
- const split = splitLogTimestamp(line)
2609
+ const rows = logStats().matched.map((entry) => {
2610
+ const split = splitLogTimestamp(entry.text)
2508
2611
  return { service: item.name, ts: split.ts, text: split.text }
2509
2612
  })
2510
2613
  const text = buildLogExport(rows, {
@@ -2519,8 +2622,7 @@ window.__ModuleLoader__.load({
2519
2622
  }
2520
2623
 
2521
2624
  const logsView = () => {
2522
- const { needle, matchedLines } = logStats()
2523
- const shown = logShown()
2625
+ const { needle, matched } = logStats()
2524
2626
  return jsxs('div', { className: 'dk_logs', children: [
2525
2627
  logsError === '' ? null : jsx(Banner, {
2526
2628
  title: '读取日志失败',
@@ -2536,10 +2638,14 @@ window.__ModuleLoader__.load({
2536
2638
  followNotice === '' ? null : jsx(Banner, { kind: 'info', title: followNotice }),
2537
2639
  followDropped ? jsx(Banner, {
2538
2640
  kind: 'warn',
2539
- title: '日志超过 ' + String(FOLLOW_LINE_LIMIT) + ' 行,已丢弃最早内容',
2540
- hint: '流式日志只保留最近的行;需要完整历史请关掉 FOLLOW 用快照,或调大「LINES」。',
2641
+ title: '日志超出缓冲上限(' + String(FOLLOW_LINE_LIMIT) + ' 行 / ' + String(Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024)) + 'MB),已丢弃最早内容',
2642
+ hint: '流式日志只保留最近的行;需要完整历史请关掉 FOLLOW 用快照,或调小「LINES」。',
2643
+ }) : null,
2644
+ !follow && logs !== null && logs.truncated === true ? jsx(Banner, {
2645
+ kind: 'warn',
2646
+ title: '日志输出超过「输出上限(' + String(outputCapKb) + 'KB)」,已截断',
2647
+ hint: '这是**字节**上限,不是行数上限——所以 LINES 选了 5000 也可能只回来一部分。想多留日志请到设置卡片调大「输出上限(KB)」,或打开 FOLLOW(流式不受它约束)。',
2541
2648
  }) : null,
2542
- !follow && logs !== null && logs.truncated === true ? jsx(Banner, { kind: 'warn', title: '日志输出超过上限,已截断', hint: '调小「LINES」或到设置卡片调大「单次命令输出上限」。' }) : null,
2543
2649
  follow ? jsx('div', { className: 'dk_followState', 'data-state': followStatus, children: followStatusText() }) : null,
2544
2650
  jsxs('div', {
2545
2651
  className: 'dk_logBody',
@@ -2556,16 +2662,13 @@ window.__ModuleLoader__.load({
2556
2662
  filtered: needle !== '',
2557
2663
  }),
2558
2664
  children: [
2559
- matchedLines.length > shown.length
2560
- ? jsx('div', { className: 'dk_logLine dk_logMore', children: '(只显示最近 ' + String(LOG_COLOR_LIMIT) + ' 行,共 ' + String(matchedLines.length) + ' 行匹配)' }, 'more')
2561
- : null,
2562
2665
  logsError !== ''
2563
2666
  ? null
2564
2667
  : (!follow && logs === null)
2565
2668
  ? jsx('div', { className: 'dk_logLine', children: '读取中…' }, 'loading')
2566
- : (shown.length === 0
2669
+ : (matched.length === 0
2567
2670
  ? jsx('div', { className: 'dk_logLine', children: follow ? '等待日志…' : (needle === '' ? '(无日志)' : '(无匹配日志)') }, 'empty')
2568
- : shown.map((line, index) => renderLogLine(line, index, needle))),
2671
+ : matched.map((entry) => renderLogLine(entry, needle))),
2569
2672
  ],
2570
2673
  }),
2571
2674
  follow && !followAtBottom
@@ -3051,6 +3154,30 @@ window.__ModuleLoader__.load({
3051
3154
  const linesRef = useRef([])
3052
3155
  const pendingRef = useRef('')
3053
3156
  const bodyRef = useRef(null)
3157
+ /** 进度落地合帧定时器(D134):拉到跑满带宽时这个流能到几百行/秒。 */
3158
+ const flushTimerRef = useRef(null)
3159
+
3160
+ /**
3161
+ * 合帧落地进度行(D134):docker pull 逐层刷进度时这个流能有几百行/秒,而
3162
+ * 每行都会走一次 mergeProgress(要 slice + 重建 key 索引表,O(行数))再 setLines
3163
+ * ——逐行落地就是「每行一次 2000 行重渲染」,与日志页原来的崩溃同一类写法,
3164
+ * 只是量级小。这里把窗口内的多批并成一次:真相源仍是 linesRef.current(同步
3165
+ * 更新,结束时不会丢行),DOM 按 150ms 节奏刷。
3166
+ */
3167
+ const flushLines = () => {
3168
+ if (flushTimerRef.current !== null) return
3169
+ flushTimerRef.current = setTimeout(() => {
3170
+ flushTimerRef.current = null
3171
+ setLines(linesRef.current)
3172
+ }, FOLLOW_FLUSH_MS)
3173
+ }
3174
+ const flushNow = () => {
3175
+ if (flushTimerRef.current !== null) {
3176
+ clearTimeout(flushTimerRef.current)
3177
+ flushTimerRef.current = null
3178
+ }
3179
+ setLines(linesRef.current)
3180
+ }
3054
3181
 
3055
3182
  useEffect(() => {
3056
3183
  if (!running) return undefined
@@ -3077,12 +3204,14 @@ window.__ModuleLoader__.load({
3077
3204
  linesRef.current = merged.lines
3078
3205
  pendingRef.current = merged.pending
3079
3206
  if (merged.dropped) setDropped(true)
3080
- setLines(merged.lines)
3207
+ flushLines()
3081
3208
  }
3082
3209
  const onEnd = (event) => {
3083
3210
  let payload = null
3084
3211
  try { payload = JSON.parse(event.data) } catch { /* 畸形载荷按失败处理 */ }
3085
3212
  const code = payload !== null && typeof payload.code === 'number' ? payload.code : null
3213
+ // 结束前把还没落地的进度刷出去,别让最后几行卡在合帧窗口里
3214
+ flushNow()
3086
3215
  setExitCode(code)
3087
3216
  setRunning(false)
3088
3217
  setStatus(code === 0 ? '拉取完成' : '拉取结束(退出码 ' + String(code === null ? '?' : code) + ')')
@@ -3106,7 +3235,14 @@ window.__ModuleLoader__.load({
3106
3235
  es.addEventListener('end', onEnd)
3107
3236
  es.addEventListener('error', onError)
3108
3237
  es.onopen = () => setStatus('open')
3109
- return () => { close(); pendingRef.current = '' }
3238
+ return () => {
3239
+ close()
3240
+ pendingRef.current = ''
3241
+ if (flushTimerRef.current !== null) {
3242
+ clearTimeout(flushTimerRef.current)
3243
+ flushTimerRef.current = null
3244
+ }
3245
+ }
3110
3246
  }, [running, props.target])
3111
3247
 
3112
3248
  useEffect(() => {
@@ -3477,7 +3613,14 @@ window.__ModuleLoader__.load({
3477
3613
  const [levelMin, setLevelMin] = useState(0)
3478
3614
  /** 每容器初始行数:改它触发重连(见 AGG_TAIL_OPTIONS 的注释)。 */
3479
3615
  const [tail, setTail] = useState(AGG_TAIL_DEFAULT)
3480
- const entriesRef = useRef([])
3616
+ /**
3617
+ * 聚合日志缓冲(D63 根治):行对象带单调 id,行数 + 字节双限都在 log-buffer
3618
+ * 里;entriesRef 退役——真相源是 aggBuf,snapshot() 即列表。到达序的合帧
3619
+ * 暂存区也在这里(与单容器 FOLLOW 同节奏)。
3620
+ */
3621
+ const aggBufRef = useRef(null)
3622
+ const arrivalRowsRef = useRef([])
3623
+ const arrivalTimerRef = useRef(null)
3481
3624
  const pendingRef = useRef(new Map())
3482
3625
  /** SSE 回调里读「最新暂停态」:闭包捕获的是连接建立那一刻的 state。 */
3483
3626
  const pausedRef = useRef(false)
@@ -3502,6 +3645,63 @@ window.__ModuleLoader__.load({
3502
3645
  setAtBottom(true)
3503
3646
  }
3504
3647
 
3648
+ /** 暂停期间的去处:攒进暂停缓冲,DOM 不动(与旧版 push 里的分支同一语义)。 */
3649
+ const stashRows = (rows) => {
3650
+ const buffered = bufferRef.current.concat(rows)
3651
+ bufferRef.current = buffered.length > FOLLOW_LINE_LIMIT ? buffered.slice(buffered.length - FOLLOW_LINE_LIMIT) : buffered
3652
+ setBufferedCount((current) => (bufferRef.current.length - current >= 5 || current === 0 ? bufferRef.current.length : current))
3653
+ }
3654
+
3655
+ /**
3656
+ * 落地一批行:真相源是 aggBuf(行数 + 字节双限);到达序 appendRows、「按时间」
3657
+ * 整表重排后 replaceAll。N 条流 × 逐 chunk setState 的渲染风暴在这里收敛
3658
+ * (D63 根治:到达序按 FOLLOW_FLUSH_MS 合帧,时间序本就有 350ms 窗口)。
3659
+ */
3660
+ const commit = (rows) => {
3661
+ if (rows.length === 0) return
3662
+ if (pausedRef.current) {
3663
+ stashRows(rows)
3664
+ return
3665
+ }
3666
+ const buffer = aggBufRef.current
3667
+ if (buffer === null) return
3668
+ const result = orderRef.current === 'time'
3669
+ ? buffer.replaceAll(reorderTailByTimestamp(buffer.snapshot(), rows, LOG_REORDER_TAIL))
3670
+ : buffer.appendRows(rows)
3671
+ if (result.dropped) setDropped(true)
3672
+ setEntries(buffer.snapshot())
3673
+ }
3674
+
3675
+ /** 到达序合帧:150ms 内的多批并成一次 commit(与单容器 FOLLOW 同节奏)。 */
3676
+ const scheduleArrivalCommit = (rows) => {
3677
+ arrivalRowsRef.current = arrivalRowsRef.current.concat(rows)
3678
+ if (arrivalTimerRef.current === null) {
3679
+ arrivalTimerRef.current = setTimeout(() => {
3680
+ arrivalTimerRef.current = null
3681
+ const pendingRows = arrivalRowsRef.current
3682
+ arrivalRowsRef.current = []
3683
+ commit(pendingRows)
3684
+ }, FOLLOW_FLUSH_MS)
3685
+ }
3686
+ }
3687
+
3688
+ /** 时间序:攒进窗口,到点整体按时间戳排序后落地(到达序走合帧)。 */
3689
+ const enqueue = (rows) => {
3690
+ if (orderRef.current !== 'time') {
3691
+ scheduleArrivalCommit(rows)
3692
+ return
3693
+ }
3694
+ timeBufRef.current = timeBufRef.current.concat(rows)
3695
+ if (flushTimerRef.current !== null) return
3696
+ flushTimerRef.current = setTimeout(() => {
3697
+ flushTimerRef.current = null
3698
+ const pendingRows = timeBufRef.current
3699
+ timeBufRef.current = []
3700
+ // 时序种子取自主列表(D56):flush 窗口的首行可能是无前缀续行
3701
+ commit(orderRowsByTimestamp(pendingRows, carriedTsBefore(aggBufRef.current.snapshot())))
3702
+ }, LOG_MERGE_WINDOW_MS)
3703
+ }
3704
+
3505
3705
  useEffect(() => {
3506
3706
  // 折叠的 tab 不建流(S3)。
3507
3707
  // 刻意**不动 status**:隐藏时状态行停在原样(比如「已连接 N 条」),展开后
@@ -3516,7 +3716,12 @@ window.__ModuleLoader__.load({
3516
3716
  return undefined
3517
3717
  }
3518
3718
  setStatus('connecting')
3519
- entriesRef.current = []
3719
+ aggBufRef.current = createLogBuffer({ maxLines: FOLLOW_LINE_LIMIT, maxBytes: FOLLOW_BYTE_LIMIT })
3720
+ arrivalRowsRef.current = []
3721
+ if (arrivalTimerRef.current !== null) {
3722
+ clearTimeout(arrivalTimerRef.current)
3723
+ arrivalTimerRef.current = null
3724
+ }
3520
3725
  pendingRef.current = new Map()
3521
3726
  bufferRef.current = []
3522
3727
  setEntries([])
@@ -3534,37 +3739,18 @@ window.__ModuleLoader__.load({
3534
3739
  }
3535
3740
  let open = 0
3536
3741
  let closed = 0
3742
+ /*
3743
+ * 每条容器流各一个订阅句柄(D133):首连带 tail 补历史,**重连带 tail=0** 只补
3744
+ * 新行。聚合视图一条流断了就用 EventSource 自动重连的话,那条流的 tail 会被
3745
+ * 整段重放,混进同一条列表里就是「凭空多出一段重复日志」。
3746
+ */
3537
3747
  const sources = items.map((item) => {
3538
3748
  const service = item.composeService === null ? item.name : item.composeService
3539
- // timestamps=1:聚合视图为「按时间合并」与可选显示时间戳固定带上的参数
3540
- const es = new EventSource(streamUrl('/logs/stream', { target: props.target, id: item.id, tail, timestamps: 1 }))
3541
- const commit = (rows) => {
3542
- if (rows.length === 0) return
3543
- // 时间序:把最近 N 行连同新行一起重排(跨批次的历史错序也能被纠正)
3544
- const next = orderRef.current === 'time'
3545
- ? reorderTailByTimestamp(entriesRef.current, rows, LOG_REORDER_TAIL)
3546
- : entriesRef.current.concat(rows)
3547
- const trimmed = next.length > FOLLOW_LINE_LIMIT ? next.slice(next.length - FOLLOW_LINE_LIMIT) : next
3548
- entriesRef.current = trimmed
3549
- if (trimmed.length !== next.length) setDropped(true)
3550
- setEntries(trimmed)
3551
- }
3552
- /** 时间序:攒进窗口,到点整体按时间戳排序后落地(到达序则直接落地)。 */
3553
- const enqueue = (rows) => {
3554
- if (orderRef.current !== 'time') {
3555
- commit(rows)
3556
- return
3557
- }
3558
- timeBufRef.current = timeBufRef.current.concat(rows)
3559
- if (flushTimerRef.current !== null) return
3560
- flushTimerRef.current = setTimeout(() => {
3561
- flushTimerRef.current = null
3562
- const pendingRows = timeBufRef.current
3563
- timeBufRef.current = []
3564
- // 时序种子取自主列表(D56):flush 窗口的首行可能是无前缀续行
3565
- commit(orderRowsByTimestamp(pendingRows, carriedTsBefore(entriesRef.current)))
3566
- }, LOG_MERGE_WINDOW_MS)
3567
- }
3749
+ /**
3750
+ * 分片 → 完整行:残行按 item 存(pendingRef);行对象组出来时即取单调 id
3751
+ * (之后重排/合并只是挪位置,key 不抖动),再交给 enqueue——commit/合帧
3752
+ * 在组件作用域(见上),N 条流共享同一份。
3753
+ */
3568
3754
  const push = (text) => {
3569
3755
  const pending = pendingRef.current.get(item.id) ?? ''
3570
3756
  const parts = (pending + text).split('\n')
@@ -3572,42 +3758,56 @@ window.__ModuleLoader__.load({
3572
3758
  if (parts.length === 0) return
3573
3759
  const rows = parts.map((line) => {
3574
3760
  const parsed = splitLogTimestamp(line)
3575
- return { service, text: parsed.text, ts: parsed.ts }
3761
+ return { id: aggBufRef.current.nextId(), service, text: parsed.text, ts: parsed.ts, bytes: parsed.text.length }
3576
3762
  })
3577
3763
  if (pausedRef.current) {
3578
3764
  // 暂停:攒进缓冲,DOM 不动(恢复时并入并回到底部)
3579
- const buffered = bufferRef.current.concat(rows)
3580
- bufferRef.current = buffered.length > FOLLOW_LINE_LIMIT ? buffered.slice(buffered.length - FOLLOW_LINE_LIMIT) : buffered
3581
- setBufferedCount((current) => (bufferRef.current.length - current >= 5 || current === 0 ? bufferRef.current.length : current))
3765
+ stashRows(rows)
3582
3766
  return
3583
3767
  }
3584
3768
  enqueue(rows)
3585
3769
  }
3586
- es.addEventListener('line', (event) => {
3587
- let payload = null
3588
- try { payload = JSON.parse(event.data) } catch { return }
3589
- if (payload === null || typeof payload !== 'object') return
3590
- if (typeof payload.d === 'string') push(payload.d)
3591
- else if (typeof payload.e === 'string') push(payload.e)
3592
- })
3593
- es.addEventListener('end', () => {
3594
- try { es.close() } catch { /* 已关闭 */ }
3595
- closed += 1
3596
- if (closed >= items.length) setStatus('closed')
3597
- })
3598
- es.addEventListener('error', (event) => {
3599
- // 服务端 event:error 会带 data;连接层错误交给 EventSource 自动重连
3600
- if (typeof event.data === 'string' && event.data !== '') setStatus('partial')
3601
- else setStatus('reconnecting')
3770
+ // timestamps=1:聚合视图为「按时间合并」与可选显示时间戳固定带上的参数
3771
+ const handle = subscribeLogStream({
3772
+ buildUrl: (tailNow) => streamUrl('/logs/stream', { target: props.target, id: item.id, tail: String(tailNow), timestamps: '1' }),
3773
+ tail,
3774
+ onStatus: (next) => {
3775
+ if (next === 'connecting') return
3776
+ if (next === 'open') {
3777
+ open += 1
3778
+ setStatus('open')
3779
+ return
3780
+ }
3781
+ if (next === 'reconnecting') setStatus('reconnecting')
3782
+ },
3783
+ onLine: push,
3784
+ onEnd: (payload, controls) => {
3785
+ const reason = payload !== null && typeof payload.reason === 'string' ? payload.reason : 'container-exit'
3786
+ if (reason === 'container-exit') {
3787
+ // 这个容器停了:这条流不会再有内容,计入「已结束」的条数
3788
+ controls.close()
3789
+ closed += 1
3790
+ if (closed >= items.length) setStatus('closed')
3791
+ return
3792
+ }
3793
+ // 服务端停流 / 宿主队列溢出:重连(只补新行)
3794
+ controls.reconnect()
3795
+ },
3796
+ onError: () => {
3797
+ // 服务端 event:error:只标记「部分出错」,其余流继续
3798
+ setStatus('partial')
3799
+ },
3602
3800
  })
3603
- es.onopen = () => {
3604
- open += 1
3605
- setStatus('open')
3606
- }
3607
- return () => { try { es.close() } catch { /* 已关闭 */ } }
3801
+ return () => handle.close()
3608
3802
  })
3609
3803
  void open
3610
- return () => { for (const close of sources) close() }
3804
+ return () => {
3805
+ for (const close of sources) close()
3806
+ if (arrivalTimerRef.current !== null) {
3807
+ clearTimeout(arrivalTimerRef.current)
3808
+ arrivalTimerRef.current = null
3809
+ }
3810
+ }
3611
3811
  }, [active, props.target, itemIds, tail])
3612
3812
 
3613
3813
  useEffect(() => {
@@ -3627,10 +3827,11 @@ window.__ModuleLoader__.load({
3627
3827
  bufferRef.current = []
3628
3828
  setBufferedCount(0)
3629
3829
  if (buffered.length > 0) {
3630
- const merged = mergeBufferedEntries(entriesRef.current, buffered, FOLLOW_LINE_LIMIT)
3631
- entriesRef.current = merged.entries
3632
- setEntries(merged.entries)
3633
- if (merged.dropped) setDropped(true)
3830
+ const merged = mergeBufferedEntries(aggBufRef.current.snapshot(), buffered, FOLLOW_LINE_LIMIT)
3831
+ // 行数上限由 mergeBufferedEntries 把关,字节上限由 replaceAll 的裁剪把关
3832
+ const result = aggBufRef.current.replaceAll(merged.entries)
3833
+ if (result.dropped) setDropped(true)
3834
+ setEntries(aggBufRef.current.snapshot())
3634
3835
  }
3635
3836
  // 等这一帧的 DOM 落地再贴底(否则滚到的是合并前的高度)
3636
3837
  requestAnimationFrame(() => {
@@ -3640,12 +3841,13 @@ window.__ModuleLoader__.load({
3640
3841
  }
3641
3842
 
3642
3843
  const needle = filter.trim().toLowerCase()
3643
- // 先按级别(WARN+ / ERROR+),再按文本/服务名,最后套显示上限
3844
+ // 先按级别(WARN+ / ERROR+),再按文本/服务名,最后套渲染窗口
3644
3845
  const leveled = filterRowsByLevel(entries, levelMin)
3645
3846
  const matched = needle === ''
3646
3847
  ? leveled
3647
3848
  : leveled.filter((entry) => entry.text.toLowerCase().indexOf(needle) >= 0 || entry.service.toLowerCase().indexOf(needle) >= 0)
3648
- const shown = matched.length > LOG_COLOR_LIMIT ? matched.slice(-LOG_COLOR_LIMIT) : matched
3849
+ // 与单容器视图一致:LINES 选多少就渲染/导出多少,显示层不再截断(缓冲与宿主
3850
+ // 输出上限已是闸;再多一道「只显示最近 N 行」只会让选择说了不算)
3649
3851
 
3650
3852
  /** 切换排序:先把待合并窗口落地,避免切模式时短暂的顺序错乱。 */
3651
3853
  const toggleOrderMode = () => {
@@ -3659,22 +3861,20 @@ window.__ModuleLoader__.load({
3659
3861
  const pendingRows = timeBufRef.current
3660
3862
  timeBufRef.current = []
3661
3863
  if (pendingRows.length > 0) {
3662
- const merged = reorderTailByTimestamp(entriesRef.current, pendingRows, LOG_REORDER_TAIL)
3663
- const trimmed = merged.length > FOLLOW_LINE_LIMIT ? merged.slice(merged.length - FOLLOW_LINE_LIMIT) : merged
3664
- entriesRef.current = trimmed
3665
- setEntries(trimmed)
3864
+ commit(pendingRows)
3666
3865
  }
3667
3866
  // 切到「按时间」时,把现有尾部也整体重排一次(历史批次之间的错序一次纠正)
3668
3867
  if (next === 'time') {
3669
- const reordered = reorderTailByTimestamp([], entriesRef.current, entriesRef.current.length)
3670
- entriesRef.current = reordered
3671
- setEntries(reordered)
3868
+ const snapshot = aggBufRef.current.snapshot()
3869
+ const result = aggBufRef.current.replaceAll(reorderTailByTimestamp([], snapshot, snapshot.length))
3870
+ if (result.dropped) setDropped(true)
3871
+ setEntries(aggBufRef.current.snapshot())
3672
3872
  }
3673
3873
  }
3674
3874
 
3675
- /** 导出当前显示内容(受级别 / 文本过滤影响)。 */
3875
+ /** 导出当前显示内容(受级别 / 文本过滤影响;与渲染取同一集合)。 */
3676
3876
  const doExport = (format) => {
3677
- const text = buildLogExport(shown, {
3877
+ const text = buildLogExport(matched, {
3678
3878
  format,
3679
3879
  target: props.target,
3680
3880
  targetLabel: props.targetLabel,
@@ -3696,7 +3896,7 @@ window.__ModuleLoader__.load({
3696
3896
  }
3697
3897
 
3698
3898
  return jsxs('div', { className: 'dk_logs', children: [
3699
- dropped ? jsx(Banner, { kind: 'warn', title: '聚合日志超过 ' + String(FOLLOW_LINE_LIMIT) + ' 行,已丢弃最早内容' }) : null,
3899
+ dropped ? jsx(Banner, { kind: 'warn', title: '聚合日志超出缓冲上限(' + String(FOLLOW_LINE_LIMIT) + ' 行 / ' + String(Math.round(FOLLOW_BYTE_LIMIT / 1024 / 1024)) + 'MB),已丢弃最早内容' }) : null,
3700
3900
  jsxs('div', { className: 'dk_filterBar', children: [
3701
3901
  jsxs('div', { className: 'dk_filterWrap', children: [
3702
3902
  jsx('input', {
@@ -3769,19 +3969,12 @@ window.__ModuleLoader__.load({
3769
3969
  jsx('button', {
3770
3970
  type: 'button',
3771
3971
  className: 'dk_chip',
3772
- disabled: shown.length === 0,
3773
- title: '导出当前显示内容为 .log(纯文本)',
3774
- onClick: () => doExport('log'),
3775
- children: '⬇ .log',
3776
- }),
3777
- jsx('button', {
3778
- type: 'button',
3779
- className: 'dk_chip',
3780
- disabled: shown.length === 0,
3781
- title: '导出当前显示内容为 .md(带来源与行数表头,适合当工单附件)',
3782
- onClick: () => doExport('md'),
3783
- children: '⬇ .md',
3784
- }),
3972
+ disabled: matched.length === 0,
3973
+ 'aria-haspopup': 'menu',
3974
+ title: '导出当前显示内容:.log(纯文本)/ .md(带来源与行数表头,适合当工单附件)',
3975
+ onClick: (event) => openExportMenu(event, { sub: String(items.length) + ' 个容器 · ' + String(matched.length) + ' 行', onPick: doExport }),
3976
+ children: LOG_EXPORT_LABEL,
3977
+ }, 'export'),
3785
3978
  jsx('span', { className: 'dk_filterCount', children: needle === '' && levelMin === 0 ? String(entries.length) + ' 行' : String(matched.length) + ' / ' + String(entries.length) + ' 行' }),
3786
3979
  ] }),
3787
3980
  jsx('div', { className: 'dk_followState', 'data-state': status === 'open' ? 'open' : (status === 'closed' ? 'closed' : 'connecting'), children: statusText() }),
@@ -3801,9 +3994,9 @@ window.__ModuleLoader__.load({
3801
3994
  filtered: needle !== '',
3802
3995
  }),
3803
3996
  children: [
3804
- shown.length === 0
3997
+ matched.length === 0
3805
3998
  ? jsx('div', { className: 'dk_logLine', children: status === 'open' ? '等待日志…' : statusText() }, 'empty')
3806
- : shown.map((entry, index) => renderAggLine(entry, index, needle, showTs)),
3999
+ : matched.map((entry) => renderAggLine(entry, needle, showTs)),
3807
4000
  ],
3808
4001
  }),
3809
4002
  !paused && !atBottom ? jsx('button', { type: 'button', className: 'dk_backToBottom', onClick: backToBottom, children: '回到底部' }) : null,
@@ -5945,8 +6138,8 @@ window.__ModuleLoader__.load({
5945
6138
  jsxs('div', { className: 'dk_fieldGrid', children: [
5946
6139
  field('docker CLI', jsx('input', { className: 'dk_input', value: form.dockerBin, onChange: (event) => patch({ dockerBin: event.target.value }) }), '默认 docker;podman 可填 podman'),
5947
6140
  field('统计刷新间隔(秒)', numberInput('pollIntervalSec', 1, 60)),
5948
- field('日志默认行数', numberInput('logTailDefault', 1, 5000)),
5949
- field('输出上限(KB)', numberInput('maxOutputKb', 1, 8192)),
6141
+ field('日志默认行数', numberInput('logTailDefault', 1, 5000), '面板日志页 LINES 的初始值(面板内可临时改);它只是**行数**上限——快照还要过下面那道字节闸,所以不保证一定拿得到这么多行'),
6142
+ field('输出上限(KB)', numberInput('maxOutputKb', 1, 8192), '单次输出的**字节**上限:日志快照 / inspect / exec 共用;日志行数够但字节超了会被截断(面板会给出截断横幅)。FOLLOW 流式日志不受它约束'),
5950
6143
  field('exec 超时(秒)', numberInput('execTimeoutSec', 1, 120)),
5951
6144
  ] }),
5952
6145
 
@@ -6382,6 +6575,7 @@ window.__ModuleLoader__.load({
6382
6575
  exports.__render = {
6383
6576
  ContainerPanel,
6384
6577
  DockerTabBody,
6578
+ ComposeLogs,
6385
6579
  }
6386
6580
  exports.__pick = {
6387
6581
  MAX: PICK_MAX,
@@ -6444,6 +6638,29 @@ window.__ModuleLoader__.load({
6444
6638
  */
6445
6639
  matchTargetForSession,
6446
6640
  }
6641
+ /*
6642
+ * 日志缓冲的测试缝:环形上限 / 残行分片 / 单调 id / 快照切分都是纯逻辑,离线冒烟
6643
+ * 直接驱动(真实 EventSource 时序进不了 Node 桩)。常量挂出来供用例对齐,避免两边漂移。
6644
+ */
6645
+ exports.__logBuffer = {
6646
+ create: createLogBuffer,
6647
+ splitLines: splitLogLines,
6648
+ MAX_LINES: FOLLOW_LINE_LIMIT,
6649
+ BYTE_LIMIT: FOLLOW_BYTE_LIMIT,
6650
+ PENDING_MAX: LOG_PENDING_MAX,
6651
+ FLUSH_MS: FOLLOW_FLUSH_MS,
6652
+ }
6653
+ /*
6654
+ * 断线重连策略的测试缝(D133):真实 EventSource 时序进不了 Node 桩,但「首连带
6655
+ * tail、重连带 0」这条规则本身是纯的——用可注入的假 EventSource 驱动一遍,
6656
+ * 把「重连不许重放历史」钉在用例里(否则退化成浏览器自动重连就没人发现)。
6657
+ */
6658
+ exports.__logStream = {
6659
+ subscribe: subscribeLogStream,
6660
+ RECONNECT_BASE_MS: LOG_RECONNECT_BASE_MS,
6661
+ RECONNECT_MAX_MS: LOG_RECONNECT_MAX_MS,
6662
+ reconnectTail,
6663
+ }
6447
6664
  exports.__aggLogs = {
6448
6665
  mergeBuffered: mergeBufferedEntries,
6449
6666
  WINDOW_MS: LOG_MERGE_WINDOW_MS,
@@ -6455,6 +6672,9 @@ window.__ModuleLoader__.load({
6455
6672
  filterByLevel: filterRowsByLevel,
6456
6673
  filterLinesByLevel,
6457
6674
  buildLogExport,
6675
+ /* 导出按钮文案与两个格式的菜单项:两个视图共用一份,产物里只该出现一次(D137)。 */
6676
+ EXPORT_LABEL: LOG_EXPORT_LABEL,
6677
+ exportMenuItems,
6458
6678
  LEVEL_OPTIONS: LOG_LEVEL_OPTIONS,
6459
6679
  TAIL_OPTIONS: AGG_TAIL_OPTIONS,
6460
6680
  TAIL_DEFAULT: AGG_TAIL_DEFAULT,