@inventec/dsh-onetree-log 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 iasiv5
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,100 @@
1
+ # dsh-onetree-log
2
+
3
+ AMI MegaRAC OneTree 一键日志(OpenBMC dreport dump)上传与分析框架 —— DSH 插件。
4
+
5
+ 在 DSH「设置 → OneTree 日志」面板里上传 OneTree 一键日志压缩包(或登记服务器本地
6
+ 路径),插件自动**安全解包 → 探测 dump 根 → 文件清单与分类导航 → 文件预览**。
7
+ 本仓库是第一阶段(框架);第二阶段在同一内核上挂逐域解析器
8
+ (SEL / 传感器 / journal / 服务 / POST code …),见下文「第二阶段扩展点」。
9
+
10
+ > 设计蓝本:[openUBMC bmcstudio_log_reviewer](https://gitcode.com/openUBMC/bmcstudio_log_reviewer)
11
+ > 的"内核 + 薄入口"架构(解析内核独立于 UI,路由化契约,机型知识外置)。
12
+ > OneTree 背景:MegaRAC OneTree 是 AMI 的 OpenBMC 商业发行版,一键日志由
13
+ > phosphor-debug-collector(dreport)产出(`selinfo.log`、`journal-pretty.log`、
14
+ > `dmesg.log`、`fru-info.log` …一组命令输出文件,tar 打包,常为 zstd 压缩)。
15
+
16
+ ## 功能(第一阶段)
17
+
18
+ - **上传**:浏览器直传压缩包(原始字节流 POST,流式落盘,≤512MB;>20MB 受入口
19
+ 网关限制时建议改走服务器路径)
20
+ - **登记服务器路径**:压缩包或已解压 dump 目录原地登记(无大小限制,适合超大日志)
21
+ - **自动解包探测**:`.tar / .tar.gz / .tgz / .tar.zst / .tzst / .zip`;嵌套包装
22
+ (zip 套 tar 等)自动展开 ≤2 层;标记评分定位 dump 根;SP-X FFDC 血统给出专门提示
23
+ - **文件清单**:按 14 个域分类(SEL/传感器/系统日志/FRU/服务/启动/网络…),
24
+ 统计 chips 过滤 + 目录树 + 单文件文本预览(256KB,二进制探测)
25
+ - **生命周期**:dump 登记持久化(重启不丢);删除时只清插件自建目录,
26
+ path 登记的用户原文件/目录绝不动
27
+
28
+ ## 安全
29
+
30
+ - 解包三防:zip-slip(绝对路径/`..`/NUL)、符号链接与硬链接条目直接拒绝、
31
+ 单文件 1GiB / 总量 2GiB / 条目 20k 上限
32
+ - 文件预览路径二次校验(resolveFile 拒绝逃逸)
33
+ - 信任围栏与 dsh-obmc-web 同款:Host 须 loopback 或部署信任域、
34
+ Sec-Fetch-Site 不得 cross-site、Origin 须同 Host 主机名
35
+ - 上传/请求体限额:上传 512MB、JSON 8KB、预览 256KB
36
+
37
+ ## 架构
38
+
39
+ ```
40
+ lib/
41
+ ├── index.js 宿主半身:/onetree-log* 路由(信任围栏 + JSON 契约)
42
+ ├── client.js 客户端半身:settings.section「OneTree 日志」面板(React)
43
+ ├── extract.js 解包内核:纯 JS 流式 tar(ustar/pax/GNU longname)+ zlib gz/zstd + zip 兜底
44
+ ├── detect.js dump 探测:标记评分(强/中/SP-X)、嵌套包装展开
45
+ ├── inventory.js 文件清单:KNOWN_FILES 分类表 + 统计 + 目录树
46
+ └── store.js 登记持久化:~/.local/share/dsh-onetree-log/dumps/<id>/
47
+ test/ node:test 单测(解包安全用例 / 探测 / 清单 / store 全链路)
48
+ scripts/e2e.mjs 宿主半身端到端自测(mock ctx + 临时 HTTP 服务)
49
+ ```
50
+
51
+ ### HTTP 契约(宿主半身)
52
+
53
+ | 方法 | 路径 | 说明 |
54
+ |---|---|---|
55
+ | POST | `/onetree-log/upload?name=<原文件名>` | 原始字节流上传 → 解包探测登记 |
56
+ | POST | `/onetree-log/open-path` `{path,label?}` | 登记服务器本地压缩包/目录 |
57
+ | GET | `/onetree-log/dumps` | 登记列表(时间倒序) |
58
+ | GET | `/onetree-log/dumps/<id>` | 详情(meta + inventory) |
59
+ | GET | `/onetree-log/dumps/<id>/file?path=<rel>` | 文件预览 |
60
+ | DELETE | `/onetree-log/dumps/<id>` | 删除登记 |
61
+
62
+ ## 安装
63
+
64
+ ```bash
65
+ # 市场(发布后)
66
+ dsh plugin --profile web add @inventec/dsh-onetree-log
67
+ # 源码本地(开发模式)
68
+ dsh plugin --profile web add link:/path/to/dsh-onetree-log
69
+ ```
70
+
71
+ 安装后重启 dsh web 生效。数据目录可用 `ONETREE_LOG_DIR` 环境变量覆盖。
72
+
73
+ ## 开发
74
+
75
+ ```bash
76
+ node --test 'test/*.test.js' # 17 个单测
77
+ node scripts/e2e.mjs # 宿主半身端到端
78
+ ```
79
+
80
+ 无构建步骤:`lib/` 即发布产物(与 dsh-surf / dsh-obmc-web 一致的薄插件风格)。
81
+ 宿主半身需要 Node ≥22.15(zlib 原生 zstd;旧版本自动回退系统 `zstd -dc`)。
82
+
83
+ ## 第二阶段扩展点(逐域解析)
84
+
85
+ 内核已按 section 预留挂钩,第二阶段只需:
86
+
87
+ 1. `lib/sections/<name>.js`:实现 `parse(files, ctx) → 结构化结果`
88
+ (如 `sel.js` 解析 `ipmitool sel list` 行为事件表、`sensors.js` 解析传感器阈值矩阵)
89
+ 2. `lib/index.js`:新增路由 `GET /onetree-log/dumps/<id>/section/<name>`
90
+ (按 `inventory` 的 section 找到文件集 → 调解析器 → JSON 返回)
91
+ 3. `lib/client.js`:详情页为每个 section 加一个渲染卡片(表格/图表)
92
+ 4. 规则诊断(对齐 bmcstudio_log_reviewer 的 detection-report 形态):
93
+ `diagnose.js` 消费各 section 结果产出 `{Severity, Items:[{severity,msg,fix,evidence}]}`
94
+ 5. 真实样本回归:拿到脱敏真机 dump 后建 `test/golden/` 冻结快照
95
+
96
+ 分类表在 `lib/inventory.js` 的 `KNOWN_FILES`(文件名 → section),新文件名只需加一行。
97
+
98
+ ## License
99
+
100
+ MIT
@@ -0,0 +1 @@
1
+ - insert: [{id: onetree-log, name: "@inventec/dsh-onetree-log"}]
package/lib/client.js ADDED
@@ -0,0 +1,393 @@
1
+ // dsh-onetree-log client half.
2
+ //
3
+ // 设置一级菜单「OneTree 日志」面板:上传 OneTree 一键日志压缩包 / 登记服务器
4
+ // 本地路径 → 解包探测 → dump 列表 → 详情(分类统计 + 文件树 + 文件预览)。
5
+ // 与宿主半身经同源 /onetree-log* 路由通信(fetch 不受 X-Frame-Options 影响)。
6
+ //
7
+ // The package registration id MUST equal package.json `name`.
8
+ window.__ModuleLoader__.load({
9
+ id: '@inventec/dsh-onetree-log',
10
+ factory: (require) => {
11
+ var module = { exports: {} }
12
+ var exports = module.exports
13
+ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
14
+ const React = require('react')
15
+ const h = React.createElement
16
+
17
+ const SECTION_ID = 'dsh-onetree-log'
18
+ const NAV_LABEL = 'OneTree 日志'
19
+ const BASE = '/onetree-log'
20
+
21
+ const SECTION_META = {
22
+ overview: { label: '概览', color: '#8b9cf7' },
23
+ sel: { label: 'SEL 事件', color: '#f87171' },
24
+ sensors: { label: '传感器', color: '#fbbf24' },
25
+ elog: { label: 'phosphor 事件', color: '#fb923c' },
26
+ journal: { label: '系统日志', color: '#60a5fa' },
27
+ inventory: { label: 'FRU/库存', color: '#34d399' },
28
+ services: { label: '服务/进程', color: '#a78bfa' },
29
+ boot: { label: '启动', color: '#f472b6' },
30
+ thermal: { label: '温度/风扇', color: '#fdba74' },
31
+ power: { label: '电源', color: '#facc15' },
32
+ network: { label: '网络', color: '#22d3ee' },
33
+ versions: { label: '版本', color: '#94a3b8' },
34
+ console: { label: '串口控制台', color: '#e879f9' },
35
+ other: { label: '其他', color: '#9ca3af' },
36
+ }
37
+
38
+ // ── 工具 ────────────────────────────────────────────────────────────
39
+
40
+ function humanBytes(n) {
41
+ if (n === null || n === undefined) return ''
42
+ if (n < 1024) return `${n} B`
43
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
44
+ if (n < 1024 * 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
45
+ return `${(n / 1024 / 1024 / 1024).toFixed(2)} GB`
46
+ }
47
+
48
+ function formatTime(iso) {
49
+ if (!iso) return ''
50
+ try { return new Date(iso).toLocaleString() } catch { return iso }
51
+ }
52
+
53
+ async function api(path, options) {
54
+ const response = await fetch(`${BASE}${path}`, { cache: 'no-store', ...options })
55
+ let data = null
56
+ try { data = await response.json() } catch { /* non-JSON */ }
57
+ if (!response.ok) throw new Error(data?.error || `HTTP ${response.status}`)
58
+ return data
59
+ }
60
+
61
+ // ── 上传卡片 ────────────────────────────────────────────────────────
62
+
63
+ function UploadCard({ onDone }) {
64
+ const [progress, setProgress] = React.useState(null) // {loaded,total}
65
+ const [error, setError] = React.useState(null)
66
+ const inputRef = React.useRef(null)
67
+
68
+ const upload = file => {
69
+ if (!file) return
70
+ setError(null)
71
+ setProgress({ loaded: 0, total: file.size })
72
+ const xhr = new XMLHttpRequest()
73
+ xhr.open('POST', `${BASE}/upload?name=${encodeURIComponent(file.name)}`)
74
+ xhr.upload.onprogress = e => { if (e.lengthComputable) setProgress({ loaded: e.loaded, total: e.total }) }
75
+ xhr.onerror = () => { setProgress(null); setError('上传失败(网络错误;超过 20MB 的日志可能被入口限制,建议放到服务器本地后用「登记服务器路径」)') }
76
+ xhr.onload = () => {
77
+ setProgress(null)
78
+ try {
79
+ const data = JSON.parse(xhr.responseText)
80
+ if (xhr.status >= 200 && xhr.status < 300 && data.ok) onDone(data.dump)
81
+ else setError(data.error || `上传失败(HTTP ${xhr.status})`)
82
+ } catch { setError(`上传失败(HTTP ${xhr.status})`) }
83
+ }
84
+ xhr.send(file)
85
+ }
86
+
87
+ const pct = progress && progress.total > 0 ? Math.round((progress.loaded / progress.total) * 100) : 0
88
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
89
+ h('input', {
90
+ ref: inputRef,
91
+ type: 'file',
92
+ accept: '.tar,.tar.gz,.tgz,.tar.zst,.tzst,.zip',
93
+ style: { display: 'none' },
94
+ onChange: e => { upload(e.target.files?.[0]); e.target.value = '' },
95
+ }),
96
+ h('div', { style: { display: 'flex', gap: '8px', alignItems: 'center' } },
97
+ h('button', {
98
+ type: 'button',
99
+ disabled: progress !== null,
100
+ onClick: () => inputRef.current?.click(),
101
+ }, progress !== null ? '上传中…' : '📤 上传一键日志压缩包'),
102
+ h('span', { style: { fontSize: '12px', opacity: 0.7 } },
103
+ '.tar / .tar.gz / .tgz / .tar.zst / .zip,≤512MB'),
104
+ ),
105
+ progress !== null && h('div', { style: { fontSize: '12px' } },
106
+ `${pct}% · ${humanBytes(progress.loaded)} / ${humanBytes(progress.total)}(上传后自动解包探测)`),
107
+ error && h('div', { style: { color: '#f87171', fontSize: '12px', whiteSpace: 'pre-wrap' } }, error),
108
+ )
109
+ }
110
+
111
+ // ── 服务器路径卡片 ──────────────────────────────────────────────────
112
+
113
+ function PathCard({ onDone }) {
114
+ const [value, setValue] = React.useState('')
115
+ const [busy, setBusy] = React.useState(false)
116
+ const [error, setError] = React.useState(null)
117
+
118
+ const submit = async () => {
119
+ const p = value.trim()
120
+ if (p === '' || busy) return
121
+ setBusy(true); setError(null)
122
+ try {
123
+ const data = await api('/open-path', {
124
+ method: 'POST',
125
+ headers: { 'content-type': 'application/json' },
126
+ body: JSON.stringify({ path: p }),
127
+ })
128
+ setValue('')
129
+ onDone(data.dump)
130
+ } catch (e) { setError(e.message) } finally { setBusy(false) }
131
+ }
132
+
133
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
134
+ h('div', { style: { display: 'flex', gap: '8px' } },
135
+ h('input', {
136
+ type: 'text',
137
+ value,
138
+ placeholder: '服务器本地路径:压缩包或已解压的 dump 目录',
139
+ onChange: e => setValue(e.target.value),
140
+ onKeyDown: e => { if (e.key === 'Enter') submit() },
141
+ style: { flex: 1, minWidth: 0 },
142
+ }),
143
+ h('button', { type: 'button', disabled: busy || value.trim() === '', onClick: submit },
144
+ busy ? '导入中…' : '🗂 登记服务器路径'),
145
+ ),
146
+ h('div', { style: { fontSize: '12px', opacity: 0.7 } },
147
+ '超大日志推荐:先 scp 到服务器,再登记路径(不受上传大小限制)'),
148
+ error && h('div', { style: { color: '#f87171', fontSize: '12px', whiteSpace: 'pre-wrap' } }, error),
149
+ )
150
+ }
151
+
152
+ // ── dump 列表 ───────────────────────────────────────────────────────
153
+
154
+ function DumpItem({ dump, onOpen, onDelete }) {
155
+ const sourceLabel = { upload: '上传', 'path-archive': '本地包', 'path-dir': '本地目录' }[dump.sourceKind] ?? dump.sourceKind
156
+ return h('div', {
157
+ style: {
158
+ display: 'flex', alignItems: 'center', gap: '10px', padding: '10px 12px',
159
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
160
+ borderRadius: '10px',
161
+ },
162
+ },
163
+ h('div', { style: { flex: 1, minWidth: 0 } },
164
+ h('div', { style: { fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
165
+ dump.label),
166
+ h('div', { style: { fontSize: '12px', opacity: 0.7, marginTop: '2px' } },
167
+ `${formatTime(dump.createdAt)} · ${sourceLabel}`
168
+ + (dump.archiveSize ? ` · ${humanBytes(dump.archiveSize)}` : '')
169
+ + ` · 识别分 ${dump.detected.score}`),
170
+ ),
171
+ h('button', { type: 'button', onClick: () => onOpen(dump.id) }, '查看'),
172
+ h('button', { type: 'button', onClick: () => onDelete(dump.id) }, '删除'),
173
+ )
174
+ }
175
+
176
+ // ── 文件树 ──────────────────────────────────────────────────────────
177
+
178
+ function TreeNode({ node, depth, onPick, pickedPath }) {
179
+ const [open, setOpen] = React.useState(depth < 1)
180
+ const meta = node.section ? SECTION_META[node.section] : null
181
+ const row = (children) => h('div', {
182
+ style: {
183
+ display: 'flex', alignItems: 'center', gap: '6px', padding: '2px 6px',
184
+ cursor: 'pointer', borderRadius: '4px', paddingLeft: `${6 + depth * 14}px`,
185
+ background: pickedPath === node.path ? 'rgba(96,165,250,0.15)' : undefined,
186
+ fontSize: '13px',
187
+ },
188
+ onClick: e => { e.stopPropagation(); if (node.dir) setOpen(!open); else onPick(node) },
189
+ },
190
+ node.dir ? h('span', { style: { opacity: 0.7, width: '12px' } }, open ? '▾' : '▸') : h('span', { style: { width: '12px' } }),
191
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, node.name || '/'),
192
+ !node.dir && meta && h('span', {
193
+ style: { fontSize: '10px', color: meta.color, border: `1px solid ${meta.color}55`, borderRadius: '999px', padding: '0 6px', flexShrink: 0 },
194
+ }, meta.label),
195
+ h('span', { style: { marginLeft: 'auto', opacity: 0.6, fontSize: '11px', flexShrink: 0 } },
196
+ node.dir ? `${node.fileCount} 项 · ${humanBytes(node.size)}` : humanBytes(node.size)),
197
+ children,
198
+ )
199
+ if (node.dir && open && node.children) {
200
+ return h('div', null,
201
+ row(),
202
+ ...node.children.map(child =>
203
+ h(TreeNode, { key: child.name, node: { ...child, path: (node.path ? node.path + '/' : '') + child.name }, depth: depth + 1, onPick, pickedPath })))
204
+ }
205
+ return row()
206
+ }
207
+
208
+ // ── 文件预览 ────────────────────────────────────────────────────────
209
+
210
+ function Preview({ dumpId, file }) {
211
+ const [data, setData] = React.useState(null)
212
+ const [error, setError] = React.useState(null)
213
+ React.useEffect(() => {
214
+ let alive = true
215
+ setData(null); setError(null)
216
+ api(`/dumps/${encodeURIComponent(dumpId)}/file?path=${encodeURIComponent(file.path)}`)
217
+ .then(d => { if (alive) setData(d) })
218
+ .catch(e => { if (alive) setError(e.message) })
219
+ return () => { alive = false }
220
+ }, [dumpId, file.path])
221
+ return h('div', { style: { borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))', paddingTop: '10px' } },
222
+ h('div', { style: { fontSize: '12px', marginBottom: '6px' } },
223
+ h('strong', null, file.path), ` · ${humanBytes(file.size)}`),
224
+ error && h('div', { style: { color: '#f87171', fontSize: '12px' } }, error),
225
+ data && data.binary && h('div', { style: { fontSize: '12px', opacity: 0.8 } }, '二进制文件,不预览'),
226
+ data && !data.binary && h('pre', {
227
+ style: {
228
+ margin: 0, padding: '10px', maxHeight: '320px', overflow: 'auto',
229
+ background: 'rgba(127,127,127,0.08)', borderRadius: '8px', fontSize: '12px',
230
+ whiteSpace: 'pre-wrap', wordBreak: 'break-all',
231
+ },
232
+ }, data.content),
233
+ data && data.truncated && h('div', { style: { fontSize: '11px', opacity: 0.6, marginTop: '4px' } },
234
+ `(仅显示前 ${humanBytes(data.content ? data.content.length : 0)},完整内容请到服务器查看解压目录)`),
235
+ )
236
+ }
237
+
238
+ // ── dump 详情 ───────────────────────────────────────────────────────
239
+
240
+ function DumpDetail({ dumpId, onBack }) {
241
+ const [detail, setDetail] = React.useState(null)
242
+ const [error, setError] = React.useState(null)
243
+ const [section, setSection] = React.useState(null) // 分类过滤
244
+ const [picked, setPicked] = React.useState(null)
245
+
246
+ React.useEffect(() => {
247
+ let alive = true
248
+ api(`/dumps/${encodeURIComponent(dumpId)}`)
249
+ .then(d => { if (alive) setDetail(d) })
250
+ .catch(e => { if (alive) setError(e.message) })
251
+ return () => { alive = false }
252
+ }, [dumpId])
253
+
254
+ if (error) return h('div', null,
255
+ h('div', { style: { color: '#f87171', marginBottom: '8px' } }, error),
256
+ h('button', { type: 'button', onClick: onBack }, '← 返回列表'))
257
+ if (!detail) return h('div', { style: { opacity: 0.7 } }, '加载中…')
258
+
259
+ const inv = detail.inventory
260
+ const sections = Object.entries(inv?.stats?.sections ?? {})
261
+ .sort((a, b) => b[1].bytes - a[1].bytes)
262
+
263
+ return h('div', { style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
264
+ h('div', { style: { display: 'flex', alignItems: 'center', gap: '10px' } },
265
+ h('button', { type: 'button', onClick: onBack }, '←'),
266
+ h('strong', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, detail.label),
267
+ ),
268
+ h('div', { style: { fontSize: '12px', opacity: 0.75 } },
269
+ `${formatTime(detail.createdAt)} · 识别分 ${detail.detected.score} · 命中标记 ${(detail.detected.markers.strong || []).length} 强 / ${(detail.detected.markers.medium || []).length} 中`
270
+ + ` · ${inv?.stats?.totalFiles ?? 0} 个文件 · ${humanBytes(inv?.stats?.totalBytes ?? 0)}`),
271
+ inv?.error && h('div', { style: { color: '#f87171', fontSize: '12px' } }, `清单构建失败: ${inv.error}`),
272
+
273
+ // 分类统计 chips(可点选过滤)
274
+ sections.length > 0 && h('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '6px' } },
275
+ h('button', {
276
+ type: 'button',
277
+ onClick: () => setSection(null),
278
+ style: section === null ? { borderColor: '#60a5fa', color: '#60a5fa' } : undefined,
279
+ }, '全部'),
280
+ sections.map(([key, s]) => {
281
+ const meta = SECTION_META[key] ?? { label: s.label ?? key, color: '#9ca3af' }
282
+ const active = section === key
283
+ return h('button', {
284
+ key, type: 'button',
285
+ onClick: () => setSection(active ? null : key),
286
+ style: { borderColor: meta.color + '88', color: active ? meta.color : undefined },
287
+ }, `${meta.label} ${s.count}`)
288
+ }),
289
+ ),
290
+
291
+ // 文件树(或某分类的平铺文件表)
292
+ inv && !section && inv.tree && h('div', {
293
+ style: {
294
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
295
+ borderRadius: '10px', padding: '8px', maxHeight: '420px', overflow: 'auto',
296
+ },
297
+ }, h(TreeNode, { node: inv.tree, depth: 0, onPick: setPicked, pickedPath: picked?.path })),
298
+ inv && section && h('div', {
299
+ style: {
300
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
301
+ borderRadius: '10px', padding: '8px', maxHeight: '420px', overflow: 'auto',
302
+ },
303
+ }, inv.files.filter(f => !f.dir && f.section === section).map(f =>
304
+ h('div', {
305
+ key: f.path,
306
+ onClick: () => setPicked(f),
307
+ style: {
308
+ display: 'flex', gap: '8px', padding: '3px 6px', cursor: 'pointer', fontSize: '13px',
309
+ borderRadius: '4px', background: picked?.path === f.path ? 'rgba(96,165,250,0.15)' : undefined,
310
+ },
311
+ },
312
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', flex: 1 } }, f.path),
313
+ h('span', { style: { opacity: 0.6, fontSize: '11px', flexShrink: 0 } }, humanBytes(f.size)),
314
+ ))),
315
+
316
+ inv?.truncated && h('div', { style: { fontSize: '11px', opacity: 0.6 } }, '(文件过多,清单已截断)'),
317
+ picked && h(Preview, { dumpId, file: picked }),
318
+ )
319
+ }
320
+
321
+ // ── 面板根组件 ──────────────────────────────────────────────────────
322
+
323
+ function OneTreeLogSection() {
324
+ const [dumps, setDumps] = React.useState(null)
325
+ const [error, setError] = React.useState(null)
326
+ const [selected, setSelected] = React.useState(null)
327
+ const [notice, setNotice] = React.useState(null)
328
+
329
+ const refresh = React.useCallback(() => {
330
+ api('/dumps')
331
+ .then(data => { setDumps(data.dumps); setError(null) })
332
+ .catch(e => setError(e.message))
333
+ }, [])
334
+
335
+ React.useEffect(() => { refresh() }, [refresh])
336
+
337
+ const onIngested = dump => {
338
+ setNotice(`已导入并识别:${dump.label}(识别分 ${dump.detected.score})`)
339
+ setSelected(dump.id)
340
+ refresh()
341
+ }
342
+
343
+ const onDelete = async id => {
344
+ try {
345
+ await api(`/dumps/${encodeURIComponent(id)}`, { method: 'DELETE' })
346
+ if (selected === id) setSelected(null)
347
+ refresh()
348
+ } catch (e) { setError(e.message) }
349
+ }
350
+
351
+ return h('section', {
352
+ 'data-onetree-log': 'settings-section',
353
+ style: {
354
+ display: 'flex', flexDirection: 'column', gap: '16px', padding: '16px',
355
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
356
+ borderRadius: '12px',
357
+ },
358
+ },
359
+ h('strong', null, NAV_LABEL),
360
+ h('p', { style: { margin: 0, color: 'var(--dsw-alias-label-secondary, inherit)', fontSize: '13px' } },
361
+ '上传 AMI MegaRAC OneTree 一键日志(OpenBMC dreport dump)压缩包,自动解包、探测与分类。' +
362
+ '第一阶段:文件清单 + 分类导航 + 预览;第二阶段将接入 SEL/传感器/日志的逐域解析。'),
363
+ h(UploadCard, { onDone: onIngested }),
364
+ h(PathCard, { onDone: onIngested }),
365
+ notice && h('div', { style: { color: '#34d399', fontSize: '12px' } }, notice),
366
+ error && h('div', { style: { color: '#f87171', fontSize: '12px' } }, error),
367
+ dumps !== null && dumps.length === 0 && !selected && h('div', { style: { fontSize: '13px', opacity: 0.7 } },
368
+ '还没有登记过一键日志。'),
369
+ dumps !== null && !selected && dumps.length > 0 && h('div', { style: { display: 'flex', flexDirection: 'column', gap: '8px' } },
370
+ dumps.map(d => h(DumpItem, { key: d.id, dump: d, onOpen: setSelected, onDelete }))),
371
+ selected !== null && h(DumpDetail, { dumpId: selected, onBack: () => { setSelected(null); refresh() } }),
372
+ )
373
+ }
374
+
375
+ function apply(ctx) {
376
+ ctx.slots.inject('settings.section', () => ctx.slots.register(
377
+ {
378
+ name: 'settings.section',
379
+ id: SECTION_ID,
380
+ order: 140,
381
+ label: NAV_LABEL,
382
+ },
383
+ OneTreeLogSection,
384
+ ))
385
+ }
386
+
387
+ exports.name = 'dsh-onetree-log/client'
388
+ exports.inject = ['slots']
389
+ exports.apply = apply
390
+ exports.OneTreeLogSection = OneTreeLogSection
391
+ return module.exports
392
+ },
393
+ })