@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 +21 -0
- package/README.md +100 -0
- package/cordis.patch.yml +1 -0
- package/lib/client.js +393 -0
- package/lib/detect.js +179 -0
- package/lib/extract.js +310 -0
- package/lib/index.js +272 -0
- package/lib/inventory.js +166 -0
- package/lib/store.js +194 -0
- package/package.json +56 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-onetree-log — host half.
|
|
3
|
+
*
|
|
4
|
+
* 在 DSH web 服务器上注册同源前缀路由 `/onetree-log*`:
|
|
5
|
+
* POST /onetree-log/upload?name=<原文件名> 原始字节流上传(非 multipart,天然
|
|
6
|
+
* 流式落盘,上限 512MiB)→ 解包探测登记
|
|
7
|
+
* POST /onetree-log/open-path {path,label} 登记服务器本地压缩包或已解压 dump 目录
|
|
8
|
+
* GET /onetree-log/dumps 登记列表(时间倒序)
|
|
9
|
+
* GET /onetree-log/dumps/<id> 详情(meta + 文件清单/分类统计/树)
|
|
10
|
+
* GET /onetree-log/dumps/<id>/file?path=<rel> 文件预览(文本前 256KiB;二进制探测)
|
|
11
|
+
* DELETE /onetree-log/dumps/<id> 删除登记(path 类不动用户原文件/目录)
|
|
12
|
+
*
|
|
13
|
+
* 信任围栏与 @iasiv5/dsh-obmc-web 一致:Host 须 loopback 或部署信任域,
|
|
14
|
+
* Sec-Fetch-Site 不得 cross-site,Origin 须同 Host 主机名。这不是认证——
|
|
15
|
+
* 认证由部署在 GUI 前面的网关(dsh-auth-caddy 等)负责。
|
|
16
|
+
*
|
|
17
|
+
* 第二阶段扩展点:在 `/onetree-log/dumps/<id>/section/<name>` 上挂
|
|
18
|
+
* 逐域解析器(sel/sensors/journal/…),见 lib/inventory.js 的 SECTIONS。
|
|
19
|
+
*/
|
|
20
|
+
import fs from 'node:fs'
|
|
21
|
+
import path from 'node:path'
|
|
22
|
+
import { pipeline } from 'node:stream/promises'
|
|
23
|
+
import { Readable } from 'node:stream'
|
|
24
|
+
import { DumpStore } from './store.js'
|
|
25
|
+
|
|
26
|
+
export const name = '@inventec/dsh-onetree-log'
|
|
27
|
+
export const inject = ['webServer', 'webRuntime']
|
|
28
|
+
|
|
29
|
+
const MAX_UPLOAD_BYTES = 512 * 1024 * 1024
|
|
30
|
+
const PREVIEW_BYTES = 256 * 1024
|
|
31
|
+
const JSON_BODY_LIMIT = 8192
|
|
32
|
+
const TMP_ROOT = () => path.join(store.dumpsDir, '.tmp-uploads')
|
|
33
|
+
|
|
34
|
+
let store = null
|
|
35
|
+
|
|
36
|
+
function writeJson(res, status, payload) {
|
|
37
|
+
const body = JSON.stringify(payload)
|
|
38
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
39
|
+
res.end(body)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function readJsonBody(req) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const chunks = []
|
|
45
|
+
let size = 0
|
|
46
|
+
req.on('data', chunk => {
|
|
47
|
+
size += chunk.length
|
|
48
|
+
if (size > JSON_BODY_LIMIT) { reject(new Error('request body too large')); req.destroy(); return }
|
|
49
|
+
chunks.push(chunk)
|
|
50
|
+
})
|
|
51
|
+
req.on('end', () => {
|
|
52
|
+
try {
|
|
53
|
+
resolve(chunks.length === 0 ? {} : JSON.parse(Buffer.concat(chunks).toString('utf8')))
|
|
54
|
+
} catch {
|
|
55
|
+
reject(new Error('invalid JSON body'))
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
req.on('error', reject)
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 上传字节流 → 临时文件(限额流式落盘,不进内存)。 */
|
|
63
|
+
async function saveUpload(req, destPath) {
|
|
64
|
+
let received = 0
|
|
65
|
+
const counter = new Readable().wrap(req) // 仅用于错误传播;直接流 req
|
|
66
|
+
const out = fs.createWriteStream(destPath)
|
|
67
|
+
await new Promise((resolve, reject) => {
|
|
68
|
+
req.on('data', chunk => {
|
|
69
|
+
received += chunk.length
|
|
70
|
+
if (received > MAX_UPLOAD_BYTES) {
|
|
71
|
+
reject(new Error(`上传超过 ${(MAX_UPLOAD_BYTES / 1024 / 1024).toFixed(0)}MiB 上限;超大日志请放到服务器本地后用「登记服务器路径」导入`))
|
|
72
|
+
req.destroy()
|
|
73
|
+
out.destroy()
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
if (!out.write(chunk)) {
|
|
77
|
+
req.pause()
|
|
78
|
+
out.once('drain', () => req.resume())
|
|
79
|
+
}
|
|
80
|
+
})
|
|
81
|
+
req.on('end', () => out.end(() => resolve()))
|
|
82
|
+
req.on('error', reject)
|
|
83
|
+
out.on('error', reject)
|
|
84
|
+
})
|
|
85
|
+
void counter
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function sanitizeUploadName(raw) {
|
|
89
|
+
const base = path.basename(String(raw ?? ''))
|
|
90
|
+
if (base === '' || base === '.' || base === '..' || base.includes('/') || base.includes('\\') || base.includes('\0')) {
|
|
91
|
+
throw new Error('非法的上传文件名')
|
|
92
|
+
}
|
|
93
|
+
return base.slice(0, 200)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── 信任围栏(与 dsh-obmc-web 同款) ─────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
function isLoopbackHostname(hostname) {
|
|
99
|
+
if (hostname === 'localhost' || hostname === '[::1]' || hostname === '::1') return true
|
|
100
|
+
const parts = hostname.split('.')
|
|
101
|
+
return parts.length === 4
|
|
102
|
+
&& parts[0] === '127'
|
|
103
|
+
&& parts.every(part => /^\d{1,3}$/.test(part) && Number(part) <= 255)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function trustedRequest(req, trustedHosts) {
|
|
107
|
+
const host = req.headers.host
|
|
108
|
+
if (typeof host !== 'string' || host === '') return false
|
|
109
|
+
let hostUrl
|
|
110
|
+
try { hostUrl = new URL(`http://${host}`) } catch { return false }
|
|
111
|
+
const trusted = (trustedHosts ?? []).some(entry => {
|
|
112
|
+
try {
|
|
113
|
+
const entryUrl = new URL(`http://${entry}`)
|
|
114
|
+
const withPort = entryUrl.port !== ''
|
|
115
|
+
return withPort
|
|
116
|
+
? `${entryUrl.hostname}:${entryUrl.port}` === hostUrl.host
|
|
117
|
+
: entryUrl.hostname === hostUrl.hostname
|
|
118
|
+
} catch { return false }
|
|
119
|
+
})
|
|
120
|
+
if (!isLoopbackHostname(hostUrl.hostname) && !trusted) return false
|
|
121
|
+
if (req.headers['sec-fetch-site'] === 'cross-site') return false
|
|
122
|
+
const origin = req.headers.origin
|
|
123
|
+
if (origin === undefined) return true
|
|
124
|
+
try {
|
|
125
|
+
return new URL(origin).hostname === hostUrl.hostname
|
|
126
|
+
} catch {
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ── 文件预览 ─────────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
function looksBinary(buf) {
|
|
134
|
+
const n = Math.min(buf.length, 2048)
|
|
135
|
+
for (let i = 0; i < n; i += 1) {
|
|
136
|
+
const b = buf[i]
|
|
137
|
+
if (b === 0) return true
|
|
138
|
+
if (b < 7 || (b > 13 && b < 32 && b !== 27)) return true
|
|
139
|
+
}
|
|
140
|
+
return false
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function previewFile(absPath) {
|
|
144
|
+
const stat = fs.statSync(absPath)
|
|
145
|
+
if (!stat.isFile()) throw new Error('不是常规文件')
|
|
146
|
+
const handle = fs.openSync(absPath, 'r')
|
|
147
|
+
try {
|
|
148
|
+
const len = Math.min(stat.size, PREVIEW_BYTES)
|
|
149
|
+
const buf = Buffer.alloc(len)
|
|
150
|
+
fs.readSync(handle, buf, 0, len, 0)
|
|
151
|
+
if (looksBinary(buf)) {
|
|
152
|
+
return { binary: true, size: stat.size, truncated: stat.size > len }
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
binary: false,
|
|
156
|
+
size: stat.size,
|
|
157
|
+
truncated: stat.size > len,
|
|
158
|
+
content: buf.toString('utf8'),
|
|
159
|
+
}
|
|
160
|
+
} finally {
|
|
161
|
+
fs.closeSync(handle)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── 路由分发 ─────────────────────────────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
async function dispatch(req, res, log) {
|
|
168
|
+
const url = new URL(req.url ?? '/', 'http://dsh.internal')
|
|
169
|
+
const parts = url.pathname.split('/').filter(Boolean) // ['onetree-log', ...]
|
|
170
|
+
const method = req.method ?? 'GET'
|
|
171
|
+
|
|
172
|
+
// GET /onetree-log/dumps
|
|
173
|
+
if (parts[1] === 'dumps' && parts.length === 2) {
|
|
174
|
+
if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
175
|
+
return writeJson(res, 200, { dumps: store.list() })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (parts.length === 1 && parts[0] === 'onetree-log') {
|
|
179
|
+
if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
180
|
+
return writeJson(res, 200, { dumps: store.list() })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (parts[1] === 'dumps' && parts.length === 3) {
|
|
184
|
+
const id = parts[2]
|
|
185
|
+
if (method === 'GET') {
|
|
186
|
+
const detail = store.detail(id)
|
|
187
|
+
if (!detail) return writeJson(res, 404, { error: 'dump 不存在' })
|
|
188
|
+
return writeJson(res, 200, detail)
|
|
189
|
+
}
|
|
190
|
+
if (method === 'DELETE') {
|
|
191
|
+
return writeJson(res, store.remove(id) ? 200 : 404, { ok: true })
|
|
192
|
+
}
|
|
193
|
+
return writeJson(res, 405, { error: 'method not allowed' })
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (parts[1] === 'dumps' && parts[3] === 'file' && parts.length === 4) {
|
|
197
|
+
if (method !== 'GET') return writeJson(res, 405, { error: 'method not allowed' })
|
|
198
|
+
const rel = url.searchParams.get('path') ?? ''
|
|
199
|
+
try {
|
|
200
|
+
const abs = store.resolveFile(parts[2], rel)
|
|
201
|
+
return writeJson(res, 200, previewFile(abs))
|
|
202
|
+
} catch (error) {
|
|
203
|
+
return writeJson(res, 400, { error: error.message })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (parts[1] === 'upload' && parts.length === 2) {
|
|
208
|
+
if (method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
209
|
+
const name = sanitizeUploadName(url.searchParams.get('name'))
|
|
210
|
+
fs.mkdirSync(TMP_ROOT(), { recursive: true })
|
|
211
|
+
const tmp = path.join(TMP_ROOT(), `${Date.now()}-${Math.random().toString(16).slice(2)}-${name}`)
|
|
212
|
+
try {
|
|
213
|
+
await saveUpload(req, tmp)
|
|
214
|
+
const meta = await store.ingestArchive(tmp, { sourceKind: 'upload', label: name })
|
|
215
|
+
log?.info?.(`[dsh-onetree-log] ingested upload ${meta.id} (${meta.detected.score} 分)`)
|
|
216
|
+
return writeJson(res, 200, { ok: true, dump: { ...meta, rootDir: undefined } })
|
|
217
|
+
} catch (error) {
|
|
218
|
+
try { fs.rmSync(tmp, { force: true }) } catch { /* ignore */ }
|
|
219
|
+
return writeJson(res, 400, { error: error.message })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (parts[1] === 'open-path' && parts.length === 2) {
|
|
224
|
+
if (method !== 'POST') return writeJson(res, 405, { error: 'method not allowed' })
|
|
225
|
+
try {
|
|
226
|
+
const body = await readJsonBody(req)
|
|
227
|
+
const p = String(body.path ?? '').trim()
|
|
228
|
+
if (p === '') return writeJson(res, 400, { error: 'path 不能为空' })
|
|
229
|
+
const resolved = path.resolve(p)
|
|
230
|
+
const stat = fs.statSync(resolved) // 不存在时抛错 → 400
|
|
231
|
+
const label = String(body.label ?? path.basename(resolved)).slice(0, 200)
|
|
232
|
+
const meta = stat.isDirectory()
|
|
233
|
+
? store.ingestDir(resolved, { label })
|
|
234
|
+
: await store.ingestArchive(resolved, { sourceKind: 'path-archive', label })
|
|
235
|
+
log?.info?.(`[dsh-onetree-log] ingested path ${meta.id} (${meta.sourceKind})`)
|
|
236
|
+
return writeJson(res, 200, { ok: true, dump: { ...meta, rootDir: undefined } })
|
|
237
|
+
} catch (error) {
|
|
238
|
+
return writeJson(res, 400, { error: error.message })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return writeJson(res, 404, { error: 'not found' })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export function apply(ctx) {
|
|
246
|
+
store = new DumpStore()
|
|
247
|
+
ctx.logger?.info?.(`[dsh-onetree-log] store at ${store.dumpsDir},已登记 ${store.list().length} 个 dump`)
|
|
248
|
+
|
|
249
|
+
const trustedHostsOf = () => (Array.isArray(ctx.webRuntime?.trustedHosts) ? ctx.webRuntime.trustedHosts : [])
|
|
250
|
+
try {
|
|
251
|
+
ctx.effect(() => ctx.webServer.register({
|
|
252
|
+
kind: 'prefix',
|
|
253
|
+
path: '/onetree-log',
|
|
254
|
+
handler: (req, res) => {
|
|
255
|
+
if (!trustedRequest(req, trustedHostsOf())) {
|
|
256
|
+
res.writeHead(403, { 'content-type': 'text/plain; charset=utf-8' })
|
|
257
|
+
res.end('dsh-onetree-log: forbidden')
|
|
258
|
+
return
|
|
259
|
+
}
|
|
260
|
+
dispatch(req, res, ctx.logger).catch(error => {
|
|
261
|
+
ctx.logger?.warn?.(`[dsh-onetree-log] ${req.method} ${req.url} failed: ${error.message}`)
|
|
262
|
+
try {
|
|
263
|
+
if (!res.headersSent) writeJson(res, 500, { error: error.message })
|
|
264
|
+
else res.end()
|
|
265
|
+
} catch { /* socket gone */ }
|
|
266
|
+
})
|
|
267
|
+
},
|
|
268
|
+
}), 'dsh-onetree-log: /onetree-log prefix')
|
|
269
|
+
} catch (error) {
|
|
270
|
+
ctx.logger?.warn?.(`[dsh-onetree-log] skipped route registration: ${error.message}`)
|
|
271
|
+
}
|
|
272
|
+
}
|
package/lib/inventory.js
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* inventory.js — dump 文件清单与基础分类。
|
|
3
|
+
*
|
|
4
|
+
* 第二阶段逐域解析的基石:把 dump 根目录走一遍,产出
|
|
5
|
+
* - files[]:每个文件的相对路径 / 大小 / 所属 section(域)
|
|
6
|
+
* - stats:按 section 聚合(文件数 / 字节数),+ 总量与截断标记
|
|
7
|
+
* - tree:嵌套目录树(供前端渲染)
|
|
8
|
+
*
|
|
9
|
+
* section 划分与《OneTree 一键日志分析方案》P1 报告 §4.3 对齐;
|
|
10
|
+
* KNOWN 表是"文件名 → section"的映射,第二阶段的解析器(sel/sensors/journal…)
|
|
11
|
+
* 直接按 section 挂到对应文件集上,未识别文件归入 other,不丢失。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
|
|
16
|
+
export const SECTIONS = {
|
|
17
|
+
overview: { label: '概览', color: '#8b9cf7' },
|
|
18
|
+
sel: { label: 'SEL 事件', color: '#f87171' },
|
|
19
|
+
sensors: { label: '传感器', color: '#fbbf24' },
|
|
20
|
+
elog: { label: 'phosphor 事件', color: '#fb923c' },
|
|
21
|
+
journal: { label: '系统日志', color: '#60a5fa' },
|
|
22
|
+
inventory: { label: 'FRU/库存', color: '#34d399' },
|
|
23
|
+
services: { label: '服务/进程', color: '#a78bfa' },
|
|
24
|
+
boot: { label: '启动', color: '#f472b6' },
|
|
25
|
+
thermal: { label: '温度/风扇', color: '#fdba74' },
|
|
26
|
+
power: { label: '电源', color: '#facc15' },
|
|
27
|
+
network: { label: '网络', color: '#22d3ee' },
|
|
28
|
+
versions: { label: '版本', color: '#94a3b8' },
|
|
29
|
+
console: { label: '串口控制台', color: '#e879f9' },
|
|
30
|
+
other: { label: '其他', color: '#9ca3af' },
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 文件名(basename)→ section 的已知映射。第二阶段在此表中追加即可。 */
|
|
34
|
+
export const KNOWN_FILES = {
|
|
35
|
+
// overview
|
|
36
|
+
'summary.log': 'overview', 'em-system.json': 'overview', 'bmc-state.log': 'overview',
|
|
37
|
+
'host-state.log': 'overview', 'chassis-state.log': 'overview', 'settings.log': 'overview',
|
|
38
|
+
'uptime.log': 'overview', 'os-release': 'overview', 'timedate.log': 'overview',
|
|
39
|
+
// sel
|
|
40
|
+
'selinfo.log': 'sel', 'selinfo': 'sel', 'ipmitool_sel_list.log': 'sel',
|
|
41
|
+
// sensors
|
|
42
|
+
'sensorinfo.log': 'sensors', 'sensor-readings.log': 'sensors', 'hwmon.log': 'thermal',
|
|
43
|
+
// elog
|
|
44
|
+
'elogall.log': 'elog', 'eventlog.log': 'elog',
|
|
45
|
+
// journal
|
|
46
|
+
'journal-pretty.log': 'journal', 'journalctl.log': 'journal', 'journal.log': 'journal',
|
|
47
|
+
'dmesg.log': 'journal', 'kernalringbuff.log': 'journal', 'kernelringbuff.log': 'journal',
|
|
48
|
+
'kernalcmdline.log': 'journal', 'kernlcmdline.log': 'journal',
|
|
49
|
+
// inventory
|
|
50
|
+
'fru-info.log': 'inventory', 'inventory.log': 'inventory', 'fru_info.txt': 'inventory',
|
|
51
|
+
// services
|
|
52
|
+
'failed-services.log': 'services', 'pslist.log': 'services', 'top.log': 'services',
|
|
53
|
+
'freemem.log': 'services', 'meminfo': 'services', 'cpuinfo': 'services',
|
|
54
|
+
'slabinfo.log': 'services', 'softirqs.log': 'services', 'interrupts.log': 'services',
|
|
55
|
+
'lsof.log': 'services', 'varfilelist.log': 'services', 'tmpfilelist.log': 'services',
|
|
56
|
+
'disk-usage.log': 'services', 'mntinfo.log': 'services', 'dreport.log': 'services',
|
|
57
|
+
// boot
|
|
58
|
+
'biospostcode.log': 'boot', 'boot-progress-info.log': 'boot', 'fw-printenv.log': 'boot',
|
|
59
|
+
'bios.log': 'boot',
|
|
60
|
+
// thermal
|
|
61
|
+
'thermal_info.log': 'thermal', 'fan_info.log': 'thermal', 'faninfo.log': 'thermal',
|
|
62
|
+
// power
|
|
63
|
+
'power_statistics.csv': 'power', 'psu_info.log': 'power', 'psuinfo.log': 'power',
|
|
64
|
+
'powerinfo.log': 'power',
|
|
65
|
+
// network
|
|
66
|
+
'network.log': 'network', 'netstat.log': 'network', 'iproute.log': 'network',
|
|
67
|
+
'ipaddr.log': 'network', 'iplink.log': 'network', 'arptable.log': 'network',
|
|
68
|
+
'routeinfo.log': 'network', 'networkrouteinfo.log': 'network', 'networkconfig.log': 'network',
|
|
69
|
+
'arpcntlconf.log': 'network', 'channelconfig.log': 'network', 'channelaccess.log': 'network',
|
|
70
|
+
// versions
|
|
71
|
+
'fw-version.log': 'versions', 'app_revision.txt': 'versions', 'package_info': 'versions',
|
|
72
|
+
'version_info.log': 'versions',
|
|
73
|
+
// console
|
|
74
|
+
'obmc-console.log': 'console', 'dpu_console': 'console', 'console.log': 'console',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const WALK_MAX = 20_000
|
|
78
|
+
|
|
79
|
+
function classify(basename) {
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(KNOWN_FILES, basename)) {
|
|
81
|
+
return { section: KNOWN_FILES[basename], known: true }
|
|
82
|
+
}
|
|
83
|
+
return { section: 'other', known: false }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 构建 dump 清单。返回:
|
|
88
|
+
* { files: [{path, name, size, section, known}], stats, tree, truncated }
|
|
89
|
+
*/
|
|
90
|
+
export function buildInventory(rootDir, { maxFiles = 10_000 } = {}) {
|
|
91
|
+
const files = []
|
|
92
|
+
const stats = { sections: {}, totalFiles: 0, totalBytes: 0, knownFiles: 0, otherFiles: 0 }
|
|
93
|
+
let truncated = false
|
|
94
|
+
let walked = 0
|
|
95
|
+
|
|
96
|
+
const bump = (section, size, known) => {
|
|
97
|
+
const s = stats.sections[section] ?? (stats.sections[section] = { count: 0, bytes: 0, label: SECTIONS[section]?.label ?? section })
|
|
98
|
+
s.count += 1
|
|
99
|
+
s.bytes += size
|
|
100
|
+
stats.totalFiles += 1
|
|
101
|
+
stats.totalBytes += size
|
|
102
|
+
if (known) stats.knownFiles += 1
|
|
103
|
+
else stats.otherFiles += 1
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const walk = (dir, rel) => {
|
|
107
|
+
let entries
|
|
108
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
109
|
+
entries.sort((a, b) => a.name.localeCompare(b.name))
|
|
110
|
+
for (const e of entries) {
|
|
111
|
+
if (++walked > WALK_MAX) { truncated = true; return }
|
|
112
|
+
if (files.length >= maxFiles) { truncated = true; return }
|
|
113
|
+
const childRel = rel === '' ? e.name : `${rel}/${e.name}`
|
|
114
|
+
if (e.isDirectory()) {
|
|
115
|
+
files.push({ path: childRel, name: e.name, size: 0, dir: true, section: null, known: false })
|
|
116
|
+
walk(path.join(dir, e.name), childRel)
|
|
117
|
+
} else if (e.isFile()) {
|
|
118
|
+
let size = 0
|
|
119
|
+
try { size = fs.statSync(path.join(dir, e.name)).size } catch { /* ignore */ }
|
|
120
|
+
const { section, known } = classify(e.name)
|
|
121
|
+
files.push({ path: childRel, name: e.name, size, dir: false, section, known })
|
|
122
|
+
bump(section, size, known)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
walk(rootDir, '')
|
|
127
|
+
|
|
128
|
+
return { files, stats, tree: buildTree(files), truncated }
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** files[] → 嵌套树(目录含 size 汇总)。 */
|
|
132
|
+
export function buildTree(files) {
|
|
133
|
+
const root = { name: '', dir: true, children: new Map(), size: 0, fileCount: 0 }
|
|
134
|
+
for (const f of files) {
|
|
135
|
+
const segments = f.path.split('/')
|
|
136
|
+
let node = root
|
|
137
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
138
|
+
const name = segments[i]
|
|
139
|
+
const isLeaf = i === segments.length - 1
|
|
140
|
+
if (isLeaf && !f.dir) {
|
|
141
|
+
node.children.set(name, { name, dir: false, size: f.size, section: f.section, known: f.known })
|
|
142
|
+
let up = node
|
|
143
|
+
while (up) { up.size += f.size; up.fileCount += 1; up = up.parent }
|
|
144
|
+
} else {
|
|
145
|
+
if (!node.children.has(name)) {
|
|
146
|
+
const child = { name, dir: true, children: new Map(), size: 0, fileCount: 0, parent: node }
|
|
147
|
+
node.children.set(name, child)
|
|
148
|
+
}
|
|
149
|
+
node = node.children.get(name)
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const shape = node => {
|
|
154
|
+
if (!node.dir) {
|
|
155
|
+
return { name: node.name, dir: false, size: node.size, section: node.section, known: node.known }
|
|
156
|
+
}
|
|
157
|
+
const children = [...node.children.values()].sort((a, b) => {
|
|
158
|
+
if (a.dir !== b.dir) return a.dir ? -1 : 1
|
|
159
|
+
return a.name.localeCompare(b.name)
|
|
160
|
+
}).map(shape)
|
|
161
|
+
const out = { name: node.name, dir: true, size: node.size, fileCount: node.fileCount }
|
|
162
|
+
if (children.length > 0) out.children = children
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
return shape(root)
|
|
166
|
+
}
|
package/lib/store.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* store.js — dump 持久化与生命周期。
|
|
3
|
+
*
|
|
4
|
+
* 目录布局(root 可用 ONETREE_LOG_DIR 覆盖):
|
|
5
|
+
* <root>/dumps/<id>/archive/<原始文件名> ← 上传的压缩包(open-path 登记的原地文件不复制)
|
|
6
|
+
* <root>/dumps/<id>/extracted/… ← 解包产物(dump 根在其中或即为其本身)
|
|
7
|
+
* <root>/dumps/<id>/meta.json ← 元数据
|
|
8
|
+
*
|
|
9
|
+
* id 形如 20260919-153012-ffdc_xxx(时间戳 + 归档名 slug),列表按 id 倒序即时间倒序。
|
|
10
|
+
* meta 记录 sourceKind(upload | path-archive | path-dir):path 类不拥有原文件,
|
|
11
|
+
* 删除时只清本插件目录;dir 类连 extracted 也不删(用户自己的目录)。
|
|
12
|
+
*/
|
|
13
|
+
import fs from 'node:fs'
|
|
14
|
+
import path from 'node:path'
|
|
15
|
+
import os from 'node:os'
|
|
16
|
+
import { randomBytes } from 'node:crypto'
|
|
17
|
+
import { extractAndDetect, detectDumpRoot } from './detect.js'
|
|
18
|
+
import { isArchive } from './extract.js'
|
|
19
|
+
import { buildInventory } from './inventory.js'
|
|
20
|
+
|
|
21
|
+
export function storeRoot() {
|
|
22
|
+
return process.env.ONETREE_LOG_DIR ?? path.join(os.homedir(), '.local', 'share', 'dsh-onetree-log')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const slugify = name => String(name)
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.replace(/\.(tar\.gz|tar\.zst|tgz|tzst|tar|zip)$/i, '')
|
|
28
|
+
.replace(/[^a-z0-9._-]+/g, '_')
|
|
29
|
+
.replace(/^_+|_+$/g, '')
|
|
30
|
+
.slice(0, 48) || 'dump'
|
|
31
|
+
|
|
32
|
+
const pad2 = n => String(n).padStart(2, '0')
|
|
33
|
+
|
|
34
|
+
function timestampId(date = new Date()) {
|
|
35
|
+
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`
|
|
36
|
+
+ `-${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`
|
|
37
|
+
+ `-${randomBytes(2).toString('hex')}`
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export class DumpStore {
|
|
41
|
+
constructor(root) {
|
|
42
|
+
this.root = root ?? storeRoot()
|
|
43
|
+
this.dumpsDir = path.join(this.root, 'dumps')
|
|
44
|
+
this.cache = new Map() // id → meta
|
|
45
|
+
this.reload()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
reload() {
|
|
49
|
+
this.cache.clear()
|
|
50
|
+
fs.mkdirSync(this.dumpsDir, { recursive: true })
|
|
51
|
+
for (const e of fs.readdirSync(this.dumpsDir, { withFileTypes: true })) {
|
|
52
|
+
if (!e.isDirectory()) continue
|
|
53
|
+
const metaPath = path.join(this.dumpsDir, e.name, 'meta.json')
|
|
54
|
+
try {
|
|
55
|
+
const meta = JSON.parse(fs.readFileSync(metaPath, 'utf8'))
|
|
56
|
+
if (meta?.id === e.name) this.cache.set(meta.id, meta)
|
|
57
|
+
} catch { /* 半途中断的 ingest:目录在但 meta 缺失 → 视为空壳,保留待覆盖/删除 */ }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
list() {
|
|
62
|
+
return [...this.cache.values()]
|
|
63
|
+
.sort((a, b) => (a.id < b.id ? 1 : a.id > b.id ? -1 : 0))
|
|
64
|
+
.map(m => ({
|
|
65
|
+
id: m.id, label: m.label, createdAt: m.createdAt, sourceKind: m.sourceKind,
|
|
66
|
+
sourcePath: m.sourcePath ?? null, archiveName: m.archiveName ?? null,
|
|
67
|
+
archiveSize: m.archiveSize ?? null, format: m.format ?? null,
|
|
68
|
+
detected: { score: m.detected?.score ?? 0, markers: m.detected?.markers ?? { strong: [], medium: [] }, hint: m.detected?.hint ?? null },
|
|
69
|
+
rootDir: m.rootDir,
|
|
70
|
+
}))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get(id) {
|
|
74
|
+
const key = String(id ?? '')
|
|
75
|
+
// id 直接拼进文件系统路径:只放行时间戳-hex-slug 形态的安全字符
|
|
76
|
+
if (!/^[0-9]{8}-[0-9]{6}-[a-f0-9]{4}-[a-z0-9._-]+$/.test(key)) return undefined
|
|
77
|
+
return this.cache.get(key)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** ingest 完成后的完整详情:meta + 现算的文件清单(带内存缓存)。 */
|
|
81
|
+
detail(id) {
|
|
82
|
+
const meta = this.get(id)
|
|
83
|
+
if (!meta) return undefined
|
|
84
|
+
const detail = { ...meta }
|
|
85
|
+
try {
|
|
86
|
+
detail.inventory = buildInventory(meta.rootDir)
|
|
87
|
+
} catch (error) {
|
|
88
|
+
detail.inventory = { files: [], stats: { sections: {}, totalFiles: 0, totalBytes: 0, knownFiles: 0, otherFiles: 0 }, tree: null, truncated: false, error: error.message }
|
|
89
|
+
}
|
|
90
|
+
return detail
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* 登记一个压缩包(上传落盘的临时文件,或服务器本地路径)。
|
|
95
|
+
* upload 模式会把 archive 移进自己的目录;path 模式原地引用。
|
|
96
|
+
*/
|
|
97
|
+
async ingestArchive(archivePath, { sourceKind = 'upload', label } = {}) {
|
|
98
|
+
const resolved = path.resolve(String(archivePath))
|
|
99
|
+
if (!isArchive(resolved)) {
|
|
100
|
+
throw new Error(`不是受支持的压缩包(.tar / .tar.gz / .tgz / .tar.zst / .tzst / .zip): ${path.basename(resolved)}`)
|
|
101
|
+
}
|
|
102
|
+
const stat = fs.statSync(resolved)
|
|
103
|
+
if (stat.size === 0) throw new Error('压缩包为空文件')
|
|
104
|
+
const base = slugify(label ?? path.basename(resolved))
|
|
105
|
+
const id = `${timestampId()}-${base}`
|
|
106
|
+
const home = path.join(this.dumpsDir, id)
|
|
107
|
+
fs.mkdirSync(home, { recursive: true })
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
const detected = await extractAndDetect(resolved, home)
|
|
111
|
+
let archiveName = path.basename(resolved)
|
|
112
|
+
let archiveSize = stat.size
|
|
113
|
+
if (sourceKind === 'upload') {
|
|
114
|
+
const archiveHome = path.join(home, 'archive')
|
|
115
|
+
fs.mkdirSync(archiveHome, { recursive: true })
|
|
116
|
+
fs.renameSync(resolved, path.join(archiveHome, archiveName))
|
|
117
|
+
}
|
|
118
|
+
const meta = {
|
|
119
|
+
id,
|
|
120
|
+
label: label ?? path.basename(resolved),
|
|
121
|
+
createdAt: new Date().toISOString(),
|
|
122
|
+
sourceKind,
|
|
123
|
+
sourcePath: sourceKind === 'upload' ? null : resolved,
|
|
124
|
+
archiveName,
|
|
125
|
+
archiveSize,
|
|
126
|
+
format: null,
|
|
127
|
+
detected: { score: detected.score, markers: detected.markers, hint: detected.hint },
|
|
128
|
+
rootDir: detected.rootDir,
|
|
129
|
+
}
|
|
130
|
+
fs.writeFileSync(path.join(home, 'meta.json'), JSON.stringify(meta, null, 2))
|
|
131
|
+
this.cache.set(id, meta)
|
|
132
|
+
return meta
|
|
133
|
+
} catch (error) {
|
|
134
|
+
// 失败清理:不留半成品目录;upload 的临时文件也一并删除
|
|
135
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
136
|
+
if (sourceKind === 'upload') { try { fs.rmSync(resolved, { force: true }) } catch { /* ignore */ } }
|
|
137
|
+
throw error
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** 登记一个已解压的 dump 目录(不复制,原地分析;删除登记不删目录)。 */
|
|
142
|
+
ingestDir(dirPath, { label } = {}) {
|
|
143
|
+
const resolved = path.resolve(String(dirPath))
|
|
144
|
+
if (!fs.statSync(resolved).isDirectory()) throw new Error('路径不是目录')
|
|
145
|
+
const detected = detectDumpRoot(resolved)
|
|
146
|
+
const id = `${timestampId()}-${slugify(label ?? path.basename(resolved))}`
|
|
147
|
+
const home = path.join(this.dumpsDir, id)
|
|
148
|
+
fs.mkdirSync(home, { recursive: true })
|
|
149
|
+
const meta = {
|
|
150
|
+
id,
|
|
151
|
+
label: label ?? path.basename(resolved),
|
|
152
|
+
createdAt: new Date().toISOString(),
|
|
153
|
+
sourceKind: 'path-dir',
|
|
154
|
+
sourcePath: resolved,
|
|
155
|
+
archiveName: null,
|
|
156
|
+
archiveSize: null,
|
|
157
|
+
format: null,
|
|
158
|
+
detected: { score: detected.score, markers: detected.markers, hint: detected.hint },
|
|
159
|
+
rootDir: detected.rootDir,
|
|
160
|
+
}
|
|
161
|
+
fs.writeFileSync(path.join(home, 'meta.json'), JSON.stringify(meta, null, 2))
|
|
162
|
+
this.cache.set(id, meta)
|
|
163
|
+
return meta
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** 删除登记;只删自己拥有的数据(extracted + archive + meta)。 */
|
|
167
|
+
remove(id) {
|
|
168
|
+
const meta = this.get(id)
|
|
169
|
+
if (!meta) return false
|
|
170
|
+
// path-dir:rootDir 是用户目录,绝不能删;只清登记目录(里面只有 meta.json)
|
|
171
|
+
const home = path.join(this.dumpsDir, id)
|
|
172
|
+
if (meta.sourceKind === 'path-dir') {
|
|
173
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
174
|
+
} else {
|
|
175
|
+
fs.rmSync(home, { recursive: true, force: true })
|
|
176
|
+
}
|
|
177
|
+
this.cache.delete(id)
|
|
178
|
+
return true
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 在 dump 根内安全解析相对路径(拒绝逃逸),返回绝对路径或抛错。 */
|
|
182
|
+
resolveFile(id, relPath) {
|
|
183
|
+
const meta = this.get(id)
|
|
184
|
+
if (!meta) throw new Error('dump 不存在')
|
|
185
|
+
const rel = String(relPath ?? '')
|
|
186
|
+
if (rel.includes('..') || rel.startsWith('/') || rel.includes('\0')) {
|
|
187
|
+
throw new Error('非法的文件路径')
|
|
188
|
+
}
|
|
189
|
+
const target = path.resolve(meta.rootDir, ...rel.split('/').filter(s => s !== '' && s !== '.'))
|
|
190
|
+
const base = path.resolve(meta.rootDir)
|
|
191
|
+
if (target !== base && !target.startsWith(base + path.sep)) throw new Error('非法的文件路径')
|
|
192
|
+
return target
|
|
193
|
+
}
|
|
194
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inventec/dsh-onetree-log",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "AMI MegaRAC OneTree 一键日志(OpenBMC dreport dump)上传与分析框架:设置面板「OneTree 日志」上传压缩包或登记服务器本地路径,安全解包、自动探测 dump 根、文件清单分类,为第二阶段逐域解析提供内核。",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./client": "./lib/client.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib/",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"author": "iasiv5",
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "git+https://github.com/iasiv5/dsh-onetree-log.git"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public",
|
|
26
|
+
"registry": "https://registry.npmjs.org/"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=22.15.0"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"dsh",
|
|
33
|
+
"dsh-plugin",
|
|
34
|
+
"deepseek-harness",
|
|
35
|
+
"ami",
|
|
36
|
+
"megarac",
|
|
37
|
+
"onetree",
|
|
38
|
+
"openbmc",
|
|
39
|
+
"bmc",
|
|
40
|
+
"ffdc",
|
|
41
|
+
"dreport",
|
|
42
|
+
"log-analysis",
|
|
43
|
+
"settings-section"
|
|
44
|
+
],
|
|
45
|
+
"dsh": {
|
|
46
|
+
"bundle": {
|
|
47
|
+
"patch": "./cordis.patch.yml"
|
|
48
|
+
},
|
|
49
|
+
"client": {
|
|
50
|
+
"platform": "web",
|
|
51
|
+
"inject": [
|
|
52
|
+
"@deepseek-ai/dsh-client-ui-layout"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|