@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.
- package/README.en.md +31 -5
- package/README.md +41 -13
- package/client-src/docker.css +13 -13
- package/client-src/index.js +466 -246
- package/client-src/log-buffer.js +205 -0
- package/client-src/log-stream.js +143 -0
- package/client.js +37 -36
- package/lib/docker.d.ts +18 -3
- package/lib/docker.js +54 -7
- package/lib/docker.js.map +1 -1
- package/lib/index.js +154 -19
- package/lib/index.js.map +1 -1
- package/package.json +5 -4
- package/scripts/client-smoke.mjs +261 -0
- package/scripts/log-perf.mjs +206 -0
- package/scripts/route-smoke.mjs +21 -0
- package/scripts/smoke.mjs +59 -1
package/client-src/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1354
|
-
|
|
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,
|
|
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(
|
|
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
|
-
|
|
1398
|
-
|
|
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,
|
|
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,
|
|
1450
|
+
...renderLogParts(entry.text, entry.id, query),
|
|
1415
1451
|
],
|
|
1416
|
-
}, String(
|
|
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
|
-
|
|
1715
|
-
head
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
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
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
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
|
-
*
|
|
1930
|
-
*
|
|
1931
|
-
*
|
|
1932
|
-
*
|
|
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
|
|
1942
|
-
const
|
|
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
|
-
|
|
2026
|
-
|
|
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
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
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
|
|
2052
|
-
|
|
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
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
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
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
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
|
-
*
|
|
2356
|
-
*
|
|
2357
|
-
*
|
|
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
|
|
2463
|
+
const snapshotEntriesValue = useMemo(() => {
|
|
2361
2464
|
// 只认 string:宿主 /logs 的形状是 { id, text, truncated },但客户端不该
|
|
2362
2465
|
// 因为一个畸形/旧版响应就在渲染期抛错——那会连整块面板和 exec 终端一起被
|
|
2363
2466
|
// React 卸载掉(一次日志请求赔进去一个正在跑的容器会话)。
|
|
2364
|
-
const
|
|
2365
|
-
|
|
2366
|
-
|
|
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 =
|
|
2371
|
-
const
|
|
2372
|
-
return {
|
|
2373
|
-
}, [follow, followLines,
|
|
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,
|
|
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:
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
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(
|
|
2493
|
-
: String(
|
|
2597
|
+
? String(total) + ' 行'
|
|
2598
|
+
: String(matched.length) + ' / ' + String(total) + ' 行',
|
|
2494
2599
|
}, 'count'),
|
|
2495
2600
|
] })
|
|
2496
2601
|
}
|
|
2497
2602
|
|
|
2498
|
-
/**
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
/** 导出当前显示内容:与聚合日志同一个构建器、同样两种格式。 */
|
|
2603
|
+
/**
|
|
2604
|
+
* 导出当前显示内容:与聚合日志同一个构建器、同样两种格式。
|
|
2605
|
+
* 显示与导出取**同一个集合**(LINES 选多少就渲染/导出多少)——上限由缓冲
|
|
2606
|
+
* (行数 + 字节)与宿主「输出上限(KB)」决定,不在这里再截一刀。
|
|
2607
|
+
*/
|
|
2505
2608
|
const doLogExport = (format) => {
|
|
2506
|
-
const rows =
|
|
2507
|
-
const split = splitLogTimestamp(
|
|
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,
|
|
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: '
|
|
2540
|
-
hint: '流式日志只保留最近的行;需要完整历史请关掉 FOLLOW
|
|
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
|
-
: (
|
|
2669
|
+
: (matched.length === 0
|
|
2567
2670
|
? jsx('div', { className: 'dk_logLine', children: follow ? '等待日志…' : (needle === '' ? '(无日志)' : '(无匹配日志)') }, 'empty')
|
|
2568
|
-
:
|
|
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
|
-
|
|
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 () => {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
|
|
3600
|
-
|
|
3601
|
-
|
|
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
|
-
|
|
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 () => {
|
|
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(
|
|
3631
|
-
|
|
3632
|
-
|
|
3633
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3670
|
-
|
|
3671
|
-
|
|
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(
|
|
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: '
|
|
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:
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
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
|
-
|
|
3997
|
+
matched.length === 0
|
|
3805
3998
|
? jsx('div', { className: 'dk_logLine', children: status === 'open' ? '等待日志…' : statusText() }, 'empty')
|
|
3806
|
-
:
|
|
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,
|