zipcalm 0.0.0-stage → 1.0.2
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 +260 -2
- package/bin/zipcalm.js +191 -0
- package/package.json +67 -4
- package/server/app.js +309 -0
- package/server/boot.js +145 -0
- package/server/config/defaults.js +153 -0
- package/server/config/index.js +140 -0
- package/server/config/schema.js +244 -0
- package/server/core/auth/password.js +89 -0
- package/server/core/auth/store.js +261 -0
- package/server/core/compress/blacklist.js +236 -0
- package/server/core/compress/capRegistry.js +171 -0
- package/server/core/compress/deleter.js +285 -0
- package/server/core/compress/hardlinkScan.js +67 -0
- package/server/core/compress/orchestrator.js +783 -0
- package/server/core/compress/preflight.js +376 -0
- package/server/core/compress/registry.js +240 -0
- package/server/core/compress/sourceWalk.js +84 -0
- package/server/core/compress/verify.js +47 -0
- package/server/core/engineRunner.js +369 -0
- package/server/core/fs/list.js +164 -0
- package/server/core/fs/platformProfile.js +167 -0
- package/server/core/fs/repairNames.js +150 -0
- package/server/core/fs/roots.js +178 -0
- package/server/core/fs/safePath.js +173 -0
- package/server/core/fs/search.js +108 -0
- package/server/core/fs/stat.js +110 -0
- package/server/core/fs/systemTrash.js +212 -0
- package/server/core/fs/writeOps.js +257 -0
- package/server/core/hardlink/executor.js +106 -0
- package/server/core/hardlink/scanner.js +193 -0
- package/server/core/metrics/collector.js +279 -0
- package/server/core/metrics/procfs.js +107 -0
- package/server/core/task/events.js +82 -0
- package/server/core/task/manager.js +987 -0
- package/server/core/task/recovery.js +116 -0
- package/server/core/task/store.js +240 -0
- package/server/http/middleware/auth.js +130 -0
- package/server/http/middleware/error.js +54 -0
- package/server/http/middleware/logger.js +27 -0
- package/server/http/middleware/pathGuard.js +119 -0
- package/server/http/router.js +29 -0
- package/server/http/routes/auth.js +115 -0
- package/server/http/routes/blacklist.js +97 -0
- package/server/http/routes/capabilities.js +21 -0
- package/server/http/routes/compress.js +339 -0
- package/server/http/routes/fs.js +109 -0
- package/server/http/routes/hardlink.js +160 -0
- package/server/http/routes/health.js +52 -0
- package/server/http/routes/metrics.js +36 -0
- package/server/http/routes/tasks.js +304 -0
- package/server/lib/atomic-json.js +118 -0
- package/server/lib/errors.js +145 -0
- package/server/lib/fs-probe.js +25 -0
- package/server/lib/logger.js +68 -0
- package/server/lib/runtime-guard.js +49 -0
- package/server/lib/throttle.js +42 -0
- package/server/plugins/capabilities/password/index.js +300 -0
- package/server/plugins/capabilities/password/methods/aes256.js +31 -0
- package/server/plugins/capabilities/password/methods/zip20.js +29 -0
- package/server/plugins/formats/_shared/createGuards.js +26 -0
- package/server/plugins/formats/_shared/entrySafety.js +174 -0
- package/server/plugins/formats/_shared/nameRepair.js +123 -0
- package/server/plugins/formats/_shared/streams.js +18 -0
- package/server/plugins/formats/_shared/tar/compression.js +114 -0
- package/server/plugins/formats/_shared/tar/reader.js +74 -0
- package/server/plugins/formats/_shared/tar/writer.js +206 -0
- package/server/plugins/formats/_shared/tarFamily.js +207 -0
- package/server/plugins/formats/rar/index.js +354 -0
- package/server/plugins/formats/rar/js-engine.js +122 -0
- package/server/plugins/formats/rar/js-runner.mjs +129 -0
- package/server/plugins/formats/rar/unrar.js +266 -0
- package/server/plugins/formats/sevenzip/engine.js +355 -0
- package/server/plugins/formats/sevenzip/index.js +461 -0
- package/server/plugins/formats/sevenzip/wasm-runner.mjs +72 -0
- package/server/plugins/formats/tar/index.js +19 -0
- package/server/plugins/formats/targz/index.js +19 -0
- package/server/plugins/formats/tarzst/index.js +26 -0
- package/server/plugins/formats/zip/index.js +587 -0
- package/shared/app-version.js +7 -0
- package/shared/capabilities.js +21 -0
- package/shared/compress-modes.js +38 -0
- package/shared/error-codes.js +117 -0
- package/shared/format-list.js +101 -0
- package/shared/hardlink-hint.js +21 -0
- package/shared/task-phase.js +62 -0
- package/shared/task-status.js +84 -0
- package/web/dist/assets/index-D3zjq7N2.js +131 -0
- package/web/dist/assets/index-D9Vdmrb9.css +1 -0
- package/web/dist/index.html +18 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// 硬链接去重 · 执行器(docs/15 §三.执行:替换顺序是安全核心)
|
|
2
|
+
//
|
|
3
|
+
// 替换顺序(每个副本,三步,绝不能"先删后建"):
|
|
4
|
+
// 1. rename(副本, 副本 + '.hl-tmp-<随机>') // 旧副本挪走,原名腾出
|
|
5
|
+
// 2. link(主文件, 副本) // 硬链接占住原名
|
|
6
|
+
// 3. unlink(临时名) // 删掉旧副本
|
|
7
|
+
// 最坏情况只留一个 .hl-tmp-* 临时文件,**数据始终有一份可达**(崩溃在任意一步都不会丢)。
|
|
8
|
+
//
|
|
9
|
+
// 错误捕获(docs/15):EXDEV(跨 fs)/ EEXIST(目标已存在)/ EPERM(权限)/ ENOSPC。
|
|
10
|
+
// 失败单条隔离(不整批崩),全部落日志 storage/logs/hardlink-<yyyymmdd>.jsonl。
|
|
11
|
+
//
|
|
12
|
+
// 主文件选择:scan 结果里的 master(组内第一个)——execute 只信任 scan 产出的分组,
|
|
13
|
+
// 且执行前逐对复核「主副内容仍一致」(文件可能自扫描后被改动:复核走全量哈希,
|
|
14
|
+
// 失败该组跳过——判定信息过期绝不硬链)。
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import fsp from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { fullHash } from './scanner.js';
|
|
21
|
+
|
|
22
|
+
/** 单条执行日志(jsonl 一行) */
|
|
23
|
+
async function recordLog(ctx, entry) {
|
|
24
|
+
const now = new Date();
|
|
25
|
+
const name = `hardlink-${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}.jsonl`;
|
|
26
|
+
const file = path.join(ctx.logDir, name);
|
|
27
|
+
try {
|
|
28
|
+
await fsp.mkdir(ctx.logDir, { recursive: true });
|
|
29
|
+
await fsp.appendFile(file, `${JSON.stringify({ at: Date.now(), ...entry })}\n`, 'utf8');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
ctx.logger.error('硬链接日志写入失败', { error: error.message });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** 执行前复核:主副全量哈希仍一致(扫描到执行之间文件可能变化) */
|
|
36
|
+
async function verifyPairStillIdentical(masterAbs, duplicateAbs) {
|
|
37
|
+
const [masterHash, duplicateHash] = await Promise.all([fullHash(masterAbs), fullHash(duplicateAbs)]);
|
|
38
|
+
return masterHash === duplicateHash;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 对单个副本执行三步替换;抛错由调用方按单条隔离 */
|
|
42
|
+
async function replaceWithLink(masterAbs, duplicateAbs) {
|
|
43
|
+
const tmpPath = `${duplicateAbs}.hl-tmp-${crypto.randomBytes(4).toString('hex')}`;
|
|
44
|
+
// 1. 旧副本挪走(原名腾出)
|
|
45
|
+
await fsp.rename(duplicateAbs, tmpPath);
|
|
46
|
+
try {
|
|
47
|
+
// 2. 硬链接占住原名(此刻起原名与主文件同一份数据)
|
|
48
|
+
await fsp.link(masterAbs, duplicateAbs);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
// 链接失败:把旧副本挪回原位(回滚第 1 步),数据无损
|
|
51
|
+
await fsp.rename(tmpPath, duplicateAbs).catch(() => {});
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
// 3. 删掉旧副本(此刻安全:原名已由链接保证可达)
|
|
55
|
+
await fsp.unlink(tmpPath);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 执行硬链接去重。
|
|
60
|
+
* @param {object} ctx {logDir, logger, dryRunGuard}
|
|
61
|
+
* @param {Array<{size, master: string(absPath), duplicates: string[](absPath)}> } groups 已复核的分组(absPath 坐标系,调用方负责白名单)
|
|
62
|
+
* @param {object} options {confirmed: boolean} 必须显式 true
|
|
63
|
+
* @returns {Promise<{executed: number, failed: Array<{duplicate, code}>, skippedGroups: Array<{master, reason}>, savedBytes: number}>}
|
|
64
|
+
*/
|
|
65
|
+
export async function executeHardlinkGroups(ctx, groups, options = {}) {
|
|
66
|
+
if (options.confirmed !== true) {
|
|
67
|
+
const error = new Error('执行需要显式确认(confirmed=true)');
|
|
68
|
+
error.code = 'INVALID_STATE';
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const execReport = { executed: 0, failed: [], skippedGroups: [], savedBytes: 0 };
|
|
73
|
+
|
|
74
|
+
for (const group of groups || []) {
|
|
75
|
+
const { master, duplicates, size } = group;
|
|
76
|
+
// 执行前逐对复核:扫描后文件可能已改动,判定信息过期绝不硬链
|
|
77
|
+
let allStillIdentical = true;
|
|
78
|
+
for (const duplicate of duplicates) {
|
|
79
|
+
const identical = await verifyPairStillIdentical(master, duplicate).catch(() => false);
|
|
80
|
+
if (!identical) {
|
|
81
|
+
allStillIdentical = false;
|
|
82
|
+
execReport.skippedGroups.push({ master, reason: 'content_changed_since_scan' });
|
|
83
|
+
await recordLog(ctx, { action: 'skipped', master, duplicate, reason: 'content_changed_since_scan' });
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (!allStillIdentical) continue;
|
|
88
|
+
|
|
89
|
+
for (const duplicate of duplicates) {
|
|
90
|
+
try {
|
|
91
|
+
await replaceWithLink(master, duplicate);
|
|
92
|
+
execReport.executed += 1;
|
|
93
|
+
execReport.savedBytes += size;
|
|
94
|
+
await recordLog(ctx, { action: 'linked', master, duplicate, sizeBytes: size, result: 'ok' });
|
|
95
|
+
ctx.logger.info('硬链接替换完成', { master, duplicate });
|
|
96
|
+
} catch (error) {
|
|
97
|
+
const code = error && error.code ? error.code : 'INTERNAL_ERROR';
|
|
98
|
+
execReport.failed.push({ duplicate, code });
|
|
99
|
+
await recordLog(ctx, { action: 'failed', master, duplicate, result: 'failed', errorCode: code });
|
|
100
|
+
ctx.logger.error('硬链接替换失败', { master, duplicate, code });
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return execReport;
|
|
106
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
// 硬链接去重 · 扫描器(docs/15 §三:size 分组 → 抽样快筛 → 全量哈希终判)
|
|
2
|
+
//
|
|
3
|
+
// 评审教训(docs/15 §二.①,本模块的实现红线):
|
|
4
|
+
// **抽样哈希不能判定重复**——同系列视频(S01E01/S01E02)容器头一致、索引尾也常一致,
|
|
5
|
+
// 中间内容完全不同。抽样若被当终判,合并后其中一集内容永久消失。
|
|
6
|
+
// 所以抽样只做「快速筛」(排除明显不同的,减少全量哈希的量),**终判必须全量 SHA-256**。
|
|
7
|
+
//
|
|
8
|
+
// 跳过规则(docs/15 §三.扫描):
|
|
9
|
+
// - 符号链接(lstat 不跟随)
|
|
10
|
+
// - 已有硬链接的文件(nlink > 1:已是链接副本,再链无收益且语义混乱)
|
|
11
|
+
// - 跨文件系统条目(dev 与扫描根不同:硬链接的硬限制,链不起来)
|
|
12
|
+
// - 非白名单扩展名
|
|
13
|
+
//
|
|
14
|
+
// 结果:只报告组内 ≥ 2 的分组;每组带 master(组内第一个)与 duplicates;dry-run 不动任何文件。
|
|
15
|
+
|
|
16
|
+
import crypto from 'node:crypto';
|
|
17
|
+
import fsp from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/** 抽样快筛的段配置:头 / 尾各 4KB + 中间随机 2 段(docs/15 §二.① 正确做法 2) */
|
|
21
|
+
const SAMPLE_CHUNK = 4096;
|
|
22
|
+
const SAMPLE_MID_SEGMENTS = 2;
|
|
23
|
+
|
|
24
|
+
/** 全量哈希的块大小(流式读,4MB 平衡内存与 syscalls) */
|
|
25
|
+
const HASH_CHUNK = 4 * 1024 * 1024;
|
|
26
|
+
|
|
27
|
+
/** 判断扩展名是否在白名单(大小写不敏感;extensions 形如 ['mp4','mkv'] 不带点) */
|
|
28
|
+
export function isAllowedExtension(fileName, extensions) {
|
|
29
|
+
const ext = path.extname(fileName).replace(/^\./, '').toLowerCase();
|
|
30
|
+
return extensions.includes(ext);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** 递归遍历(自写不复用 sourceWalk:这里要带 dev/nlink 跳过规则与扩展名过滤) */
|
|
34
|
+
export async function walkCandidates(rootAbs, extensions, options = {}) {
|
|
35
|
+
const { onProgress } = options;
|
|
36
|
+
const rootStat = await fsp.stat(rootAbs); // stat:扫描根自身允许是链接后的目录
|
|
37
|
+
const candidates = [];
|
|
38
|
+
let scanned = 0;
|
|
39
|
+
|
|
40
|
+
const walk = async (dirAbs) => {
|
|
41
|
+
let dirents;
|
|
42
|
+
try {
|
|
43
|
+
dirents = await fsp.readdir(dirAbs, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return; // 读不到的子树跳过(权限),与项目其他遍历的容错口径一致
|
|
46
|
+
}
|
|
47
|
+
for (const dirent of dirents) {
|
|
48
|
+
const childAbs = path.join(dirAbs, dirent.name);
|
|
49
|
+
// 只用 lstat 信息判断目录(不跟随链接目录,防环);isSymbolicLink 一律跳过
|
|
50
|
+
if (dirent.isDirectory()) {
|
|
51
|
+
await walk(childAbs);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!dirent.isFile()) continue; // symlink / 设备 / fifo:不进候选
|
|
55
|
+
if (!isAllowedExtension(dirent.name, extensions)) continue;
|
|
56
|
+
const stat = await fsp.lstat(childAbs).catch(() => null);
|
|
57
|
+
if (!stat || !stat.isFile()) continue;
|
|
58
|
+
// 已有硬链接的文件跳过(nlink > 1:已经是某个"去重"的产物或副本)
|
|
59
|
+
if (stat.nlink > 1) continue;
|
|
60
|
+
// 跨文件系统跳过(dev 不同:link() 会 EXDEV,链不起来)
|
|
61
|
+
if (stat.dev !== rootStat.dev) continue;
|
|
62
|
+
scanned += 1; // 语义 = 通过全部跳过规则、进入候选清单的数量(过滤前计数会虚高,实测踩过)
|
|
63
|
+
candidates.push({ absPath: childAbs, size: stat.size, dev: stat.dev, ino: stat.ino });
|
|
64
|
+
if (onProgress && scanned % 100 === 0) onProgress({ scanned, currentFile: childAbs });
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
await walk(rootAbs);
|
|
69
|
+
return { candidates, scanned, rootDev: rootStat.dev };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** 按 size 分组,只留 ≥2 的组(零成本先砍掉绝大部分候选) */
|
|
73
|
+
export function groupBySize(candidates) {
|
|
74
|
+
const bySize = new Map();
|
|
75
|
+
for (const candidate of candidates) {
|
|
76
|
+
if (!bySize.has(candidate.size)) bySize.set(candidate.size, []);
|
|
77
|
+
bySize.get(candidate.size).push(candidate);
|
|
78
|
+
}
|
|
79
|
+
return [...bySize.entries()]
|
|
80
|
+
.filter(([, group]) => group.length >= 2)
|
|
81
|
+
.map(([size, group]) => ({ size, group }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* 流式读取文件的抽样段:头 4KB + 尾 4KB + 中间随机 2 段(各 4KB)。
|
|
86
|
+
* 返回这些段的 sha256 串联指纹——**仅用于快速筛**,不是重复判定。
|
|
87
|
+
*/
|
|
88
|
+
async function sampleFingerprint(absPath, size) {
|
|
89
|
+
const hash = crypto.createHash('sha256');
|
|
90
|
+
const handle = await fsp.open(absPath, 'r');
|
|
91
|
+
try {
|
|
92
|
+
const read = async (position) => {
|
|
93
|
+
const buffer = Buffer.alloc(SAMPLE_CHUNK);
|
|
94
|
+
const { bytesRead } = await handle.read(buffer, 0, SAMPLE_CHUNK, position);
|
|
95
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
96
|
+
};
|
|
97
|
+
await read(0);
|
|
98
|
+
if (size > SAMPLE_CHUNK) await read(size - SAMPLE_CHUNK);
|
|
99
|
+
if (size > SAMPLE_CHUNK * 3) {
|
|
100
|
+
// 中间段偏移必须**确定性**(从 size 派生,禁止 Math.random)——
|
|
101
|
+
// 抽样快筛的指纹只在「同一位置读出的段」之间可比:相同文件必须读相同段才能同指纹。
|
|
102
|
+
// 实测踩过:随机偏移让两个内容相同的文件读到不同位置 → 指纹必不同 → 真重复被快筛
|
|
103
|
+
// 排除(端到端冒烟抓到:real1/real2 全同却 skippedBySample)。确定性偏移同样满足
|
|
104
|
+
// docs/15 的"防恰好都取到相同结构区"意图(位置由 size 变化,不同 size 组内不重叠)。
|
|
105
|
+
const span = size - SAMPLE_CHUNK * 3;
|
|
106
|
+
for (let i = 0; i < SAMPLE_MID_SEGMENTS; i += 1) {
|
|
107
|
+
const pos = SAMPLE_CHUNK + Math.floor((span / (SAMPLE_MID_SEGMENTS + 1)) * (i + 1)) + i * 1024;
|
|
108
|
+
await read(Math.min(pos, size - SAMPLE_CHUNK));
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
} finally {
|
|
112
|
+
await handle.close();
|
|
113
|
+
}
|
|
114
|
+
return hash.digest('hex');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 全量 SHA-256(终判):流式读,4MB 块 */
|
|
118
|
+
export async function fullHash(absPath) {
|
|
119
|
+
const hash = crypto.createHash('sha256');
|
|
120
|
+
const handle = await fsp.open(absPath, 'r');
|
|
121
|
+
try {
|
|
122
|
+
const buffer = Buffer.alloc(HASH_CHUNK);
|
|
123
|
+
for (;;) {
|
|
124
|
+
const { bytesRead } = await handle.read(buffer, 0, HASH_CHUNK, null);
|
|
125
|
+
if (bytesRead === 0) break;
|
|
126
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
127
|
+
}
|
|
128
|
+
} finally {
|
|
129
|
+
await handle.close();
|
|
130
|
+
}
|
|
131
|
+
return hash.digest('hex');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* 扫描主流程:walk → size 分组 → 抽样快筛 → 全量哈希终判。
|
|
136
|
+
*
|
|
137
|
+
* @param {string} rootAbs 扫描根(绝对路径,调用方已过白名单)
|
|
138
|
+
* @param {string[]} extensions 白名单扩展名(不带点)
|
|
139
|
+
* @param {object} [options] { onProgress({stage, scanned, hashed, total}) }
|
|
140
|
+
* @returns {Promise<{groups: Array<{size, hash, master, duplicates, savedBytes}>, stats: {...}}>}
|
|
141
|
+
*/
|
|
142
|
+
export async function scanHardlinkDuplicates(rootAbs, extensions, options = {}) {
|
|
143
|
+
const { onProgress } = options;
|
|
144
|
+
const { candidates, scanned, rootDev } = await walkCandidates(rootAbs, extensions, { onProgress });
|
|
145
|
+
|
|
146
|
+
const groups = [];
|
|
147
|
+
let hashed = 0;
|
|
148
|
+
let skippedBySample = 0;
|
|
149
|
+
|
|
150
|
+
for (const { size, group } of groupBySize(candidates)) {
|
|
151
|
+
// 抽样快筛:指纹相同的才进全量哈希(不同 → 直接排除,省全量读)
|
|
152
|
+
const fingerprints = new Map();
|
|
153
|
+
for (const candidate of group) {
|
|
154
|
+
const fp = await sampleFingerprint(candidate.absPath, size);
|
|
155
|
+
if (!fingerprints.has(fp)) fingerprints.set(fp, []);
|
|
156
|
+
fingerprints.get(fp).push(candidate);
|
|
157
|
+
hashed += 1;
|
|
158
|
+
}
|
|
159
|
+
for (const fpGroup of fingerprints.values()) {
|
|
160
|
+
if (fpGroup.length < 2) {
|
|
161
|
+
skippedBySample += fpGroup.length;
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
// 全量哈希终判(评审红线:抽样不能判重复)
|
|
165
|
+
const hashes = new Map();
|
|
166
|
+
for (const candidate of fpGroup) {
|
|
167
|
+
const digest = await fullHash(candidate.absPath);
|
|
168
|
+
if (!hashes.has(digest)) hashes.set(digest, []);
|
|
169
|
+
hashes.get(digest).push(candidate);
|
|
170
|
+
}
|
|
171
|
+
for (const [digest, digestGroup] of hashes.entries()) {
|
|
172
|
+
if (digestGroup.length < 2) continue; // 抽样同但全量不同:终判正确排除(伪造重复守门人覆盖此路径)
|
|
173
|
+
// 主文件确定规则:**absPath 字典序最小**(docs/15 执行前评审补充)——同一批数据
|
|
174
|
+
// 重扫时组内顺序稳定,master 可复现;不按 mtime(会因访问/复制变化不可复现)
|
|
175
|
+
digestGroup.sort((a, b) => (a.absPath < b.absPath ? -1 : a.absPath > b.absPath ? 1 : 0));
|
|
176
|
+
const [master, ...duplicates] = digestGroup;
|
|
177
|
+
groups.push({
|
|
178
|
+
size,
|
|
179
|
+
hash: digest,
|
|
180
|
+
master: master.absPath,
|
|
181
|
+
duplicates: duplicates.map((d) => d.absPath),
|
|
182
|
+
savedBytes: size * duplicates.length,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
if (onProgress) onProgress({ stage: 'group-done', scanned, hashed });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
groups,
|
|
191
|
+
stats: { scanned, hashed, skippedBySample, rootDev, groupCount: groups.length, savedBytes: groups.reduce((sum, g) => sum + g.savedBytes, 0) },
|
|
192
|
+
};
|
|
193
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
// 性能指标采集(M7):/proc 系统指标 5s + 进程指标 1s → 5 分钟环形缓冲
|
|
2
|
+
//
|
|
3
|
+
// 契约:
|
|
4
|
+
// - docs/M0/API规范.md §4.6:snapshot 的字段集合(system / process / tasks),history 的 window/step
|
|
5
|
+
// - docs/07 §二 采集频率:系统 5s、进程 1s、前端 2s 轮询;§四 只允许这 6 张卡片对应的字段
|
|
6
|
+
// - 依赖策略(lead 拍板):**不引 systeminformation**,全部 Node 内置 + /proc(见 procfs.js 头部)
|
|
7
|
+
//
|
|
8
|
+
// 三条硬约束(lead 验收项,改代码时别丢):
|
|
9
|
+
// 1. 定时器必须 unref(),并在 stop() 里清掉 —— 否则优雅关闭会被这两个定时器拖住;
|
|
10
|
+
// 2. 任何采集失败都降级为 null/0,**绝不向外抛**(定时器里的未捕获异常会把整个服务带走);
|
|
11
|
+
// 3. 环形缓冲只保留最近 5 分钟,不做持久化。
|
|
12
|
+
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import { performance } from 'node:perf_hooks';
|
|
15
|
+
|
|
16
|
+
import { readProcSnapshot, sectorsToBytes } from './procfs.js';
|
|
17
|
+
|
|
18
|
+
/** 采集频率(契约 docs/07 §二,不开放配置 —— 契约写死,避免"配了但没人读"的死配置) */
|
|
19
|
+
const SYSTEM_INTERVAL_MS = 5000;
|
|
20
|
+
/** 进程指标节奏,同时也是环形缓冲的帧间隔 */
|
|
21
|
+
const PROCESS_INTERVAL_MS = 1000;
|
|
22
|
+
/** 环形缓冲覆盖的时长与上限帧数(5 分钟 / 1s) */
|
|
23
|
+
const HISTORY_WINDOW_MS = 5 * 60 * 1000;
|
|
24
|
+
const HISTORY_MAX_FRAMES = Math.ceil(HISTORY_WINDOW_MS / PROCESS_INTERVAL_MS);
|
|
25
|
+
/** 事件循环延迟取最近多少个 tick 的均值(单帧尖刺不该立刻变成"服务很卡") */
|
|
26
|
+
const LAG_SAMPLE_COUNT = 5;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 事件循环延迟(毫秒)——**定时器漂移法**,不用 `perf_hooks.monitorEventLoopDelay`。
|
|
30
|
+
*
|
|
31
|
+
* 为什么不用内置直方图:实测 `monitorEventLoopDelay({ resolution: 20 })` 在**空载**下
|
|
32
|
+
* `mean` 就是 20.95ms ≈ 分辨率本身(分辨率 20ms 时它把量化误差当成了延迟)。用它报"卡不卡"
|
|
33
|
+
* 会把每个部署都显示成"延迟 20ms"。漂移法的原理是"期望时刻 vs 实际时刻"的差,没有量化偏差。
|
|
34
|
+
*/
|
|
35
|
+
export function createLagMeter(intervalMs = PROCESS_INTERVAL_MS) {
|
|
36
|
+
let lastScheduledAt = null;
|
|
37
|
+
const samples = [];
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
/** 在每个 tick 开头调用:返回本次漂移(ms),并记录最近 N 个样本 */
|
|
41
|
+
tick(nowMs = Date.now()) {
|
|
42
|
+
const drift = lastScheduledAt === null ? 0 : Math.max(0, nowMs - lastScheduledAt - intervalMs);
|
|
43
|
+
lastScheduledAt = nowMs;
|
|
44
|
+
samples.push(drift);
|
|
45
|
+
if (samples.length > LAG_SAMPLE_COUNT) samples.shift();
|
|
46
|
+
return drift;
|
|
47
|
+
},
|
|
48
|
+
/** 最近 N 个样本的均值(没有样本时 null) */
|
|
49
|
+
average() {
|
|
50
|
+
if (samples.length === 0) return null;
|
|
51
|
+
return samples.reduce((sum, value) => sum + value, 0) / samples.length;
|
|
52
|
+
},
|
|
53
|
+
reset() {
|
|
54
|
+
lastScheduledAt = null;
|
|
55
|
+
samples.length = 0;
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* 采集器:start() 起两个定时器,stop() 清掉;snapshot()/history() 给路由用。
|
|
62
|
+
* @param {{ logger?: object, taskStats?: () => object }} options
|
|
63
|
+
*/
|
|
64
|
+
export function createMetricsCollector({ logger, taskStats } = {}) {
|
|
65
|
+
const cpuCount = Math.max(1, os.cpus().length);
|
|
66
|
+
const frames = [];
|
|
67
|
+
const lagMeter = createLagMeter(PROCESS_INTERVAL_MS);
|
|
68
|
+
|
|
69
|
+
/** /proc 累计值快照(用于算两次采样之间的差值) */
|
|
70
|
+
let prevCpuTimes = null;
|
|
71
|
+
let prevDisk = null;
|
|
72
|
+
let prevCpuUsage = null;
|
|
73
|
+
let prevTickAt = null;
|
|
74
|
+
let prevElu = performance.eventLoopUtilization();
|
|
75
|
+
|
|
76
|
+
/** 最近一帧(system 部分单独缓存:它 5s 才采一次,1s 的帧复用最近值) */
|
|
77
|
+
let latestSystem = { cpuPercent: null, memUsedBytes: null, memTotalBytes: null, diskReadBps: null, diskWriteBps: null };
|
|
78
|
+
|
|
79
|
+
let systemTimer = null;
|
|
80
|
+
let processTimer = null;
|
|
81
|
+
let running = false;
|
|
82
|
+
|
|
83
|
+
/** 百分比夹取(null 透传) */
|
|
84
|
+
function clampPercent(value) {
|
|
85
|
+
if (value === null || !Number.isFinite(value)) return null;
|
|
86
|
+
return Math.min(100, Math.max(0, Number(value.toFixed(1))));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** 系统指标(含 CPU 差值、内存、磁盘 Bps)——失败字段一律 null */
|
|
90
|
+
async function collectSystem() {
|
|
91
|
+
let snapshot = null;
|
|
92
|
+
try {
|
|
93
|
+
snapshot = await readProcSnapshot();
|
|
94
|
+
} catch {
|
|
95
|
+
snapshot = null;
|
|
96
|
+
}
|
|
97
|
+
const nowMs = Date.now();
|
|
98
|
+
const cpuPercent = computeSystemCpu(snapshot && snapshot.cpuTimes);
|
|
99
|
+
const disk = computeDiskRates(snapshot && snapshot.disk, nowMs);
|
|
100
|
+
latestSystem = {
|
|
101
|
+
cpuPercent: cpuPercent === null ? latestSystem.cpuPercent : cpuPercent,
|
|
102
|
+
memUsedBytes: snapshot && snapshot.memory ? snapshot.memory.usedBytes : latestSystem.memUsedBytes,
|
|
103
|
+
memTotalBytes: snapshot && snapshot.memory ? snapshot.memory.totalBytes : latestSystem.memTotalBytes,
|
|
104
|
+
diskReadBps: disk ? disk.readBps : null,
|
|
105
|
+
diskWriteBps: disk ? disk.writeBps : null,
|
|
106
|
+
};
|
|
107
|
+
prevDisk = snapshot && snapshot.disk ? { ...snapshot.disk, atMs: nowMs } : prevDisk;
|
|
108
|
+
return latestSystem;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** 系统 CPU 使用率:1 − 空闲增量/总增量(/proc/stat 累计值) */
|
|
112
|
+
function computeSystemCpu(cpuTimes) {
|
|
113
|
+
if (!cpuTimes) return null;
|
|
114
|
+
const previous = prevCpuTimes;
|
|
115
|
+
prevCpuTimes = cpuTimes;
|
|
116
|
+
if (!previous) return null;
|
|
117
|
+
const totalDelta = cpuTimes.total - previous.total;
|
|
118
|
+
const idleDelta = cpuTimes.idle - previous.idle;
|
|
119
|
+
if (!(totalDelta > 0)) return null;
|
|
120
|
+
return clampPercent((1 - idleDelta / totalDelta) * 100);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 磁盘读写速率(Bps):扇区差值 × 512 / 实际经过时间 */
|
|
124
|
+
function computeDiskRates(disk, nowMs) {
|
|
125
|
+
if (!disk || !prevDisk) return null;
|
|
126
|
+
const elapsedMs = nowMs - prevDisk.atMs;
|
|
127
|
+
if (!(elapsedMs > 0)) return null;
|
|
128
|
+
const readBytes = sectorsToBytes(disk.readSectors - prevDisk.readSectors);
|
|
129
|
+
const writeBytes = sectorsToBytes(disk.writeSectors - prevDisk.writeSectors);
|
|
130
|
+
return {
|
|
131
|
+
readBps: Math.max(0, Math.round((readBytes * 1000) / elapsedMs)),
|
|
132
|
+
writeBps: Math.max(0, Math.round((writeBytes * 1000) / elapsedMs)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 进程指标:CPU(相对全部核,便于和 system.cpuPercent 直接比较)+ 内存 + 事件循环 */
|
|
137
|
+
function collectProcess() {
|
|
138
|
+
const nowMs = Date.now();
|
|
139
|
+
const lagMs = lagMeter.tick(nowMs);
|
|
140
|
+
|
|
141
|
+
let cpuPercent = null;
|
|
142
|
+
const usage = process.cpuUsage();
|
|
143
|
+
if (prevCpuUsage) {
|
|
144
|
+
const elapsedMs = prevTickAt === null ? PROCESS_INTERVAL_MS : Math.max(1, nowMs - prevTickAt);
|
|
145
|
+
const usedUs = (usage.user - prevCpuUsage.user) + (usage.system - prevCpuUsage.system);
|
|
146
|
+
// 除以核数:与系统 CPU 同口径(0~100 表示"整机占比"),否则 4 核机器上进程跑满会显示 400%
|
|
147
|
+
cpuPercent = clampPercent((usedUs / 1000 / elapsedMs / cpuCount) * 100);
|
|
148
|
+
}
|
|
149
|
+
prevCpuUsage = usage;
|
|
150
|
+
prevTickAt = nowMs;
|
|
151
|
+
|
|
152
|
+
const memory = process.memoryUsage();
|
|
153
|
+
let utilization = null;
|
|
154
|
+
try {
|
|
155
|
+
const elu = performance.eventLoopUtilization(prevElu);
|
|
156
|
+
prevElu = performance.eventLoopUtilization();
|
|
157
|
+
utilization = Number.isFinite(elu.utilization) ? Number(elu.utilization.toFixed(3)) : null;
|
|
158
|
+
} catch {
|
|
159
|
+
utilization = null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
cpuPercent,
|
|
164
|
+
rssBytes: memory.rss,
|
|
165
|
+
heapUsedBytes: memory.heapUsed,
|
|
166
|
+
eventLoopDelayMs: Number((lagMeter.average() === null ? lagMs : lagMeter.average()).toFixed(2)),
|
|
167
|
+
eventLoopUtilization: utilization,
|
|
168
|
+
uptimeMs: Math.round(process.uptime() * 1000),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 任务三件套(契约 §4.6 的 tasks);manager.stats().active 是 taskId,这里转成计数 */
|
|
173
|
+
function collectTasks() {
|
|
174
|
+
if (typeof taskStats !== 'function') return { active: 0, queued: 0, parallel: false };
|
|
175
|
+
try {
|
|
176
|
+
const stats = taskStats() || {};
|
|
177
|
+
return {
|
|
178
|
+
active: stats.active ? 1 : 0,
|
|
179
|
+
queued: Number(stats.waiting) || 0,
|
|
180
|
+
parallel: false, // v1 强制串行(docs/05 §二)
|
|
181
|
+
};
|
|
182
|
+
} catch {
|
|
183
|
+
return { active: 0, queued: 0, parallel: false };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 采一帧(1s 节奏);同步部分不抛异常 */
|
|
188
|
+
function collectFrame() {
|
|
189
|
+
const processStats = collectProcess();
|
|
190
|
+
const frame = {
|
|
191
|
+
ts: Date.now(),
|
|
192
|
+
system: { ...latestSystem },
|
|
193
|
+
process: processStats,
|
|
194
|
+
tasks: collectTasks(),
|
|
195
|
+
};
|
|
196
|
+
frames.push(frame);
|
|
197
|
+
pruneFrames();
|
|
198
|
+
return frame;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** 环形缓冲:按时间窗裁剪(时间窗比帧数更稳:偶尔掉帧也不会提前丢历史) */
|
|
202
|
+
function pruneFrames() {
|
|
203
|
+
const deadline = Date.now() - HISTORY_WINDOW_MS;
|
|
204
|
+
while (frames.length > 0 && (frames[0].ts < deadline || frames.length > HISTORY_MAX_FRAMES)) frames.shift();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function start() {
|
|
208
|
+
if (running) return;
|
|
209
|
+
running = true;
|
|
210
|
+
lagMeter.reset();
|
|
211
|
+
prevElu = performance.eventLoopUtilization();
|
|
212
|
+
// 先采一次系统指标,保证第一个请求不是全 null(否则前端首帧会显示"—")
|
|
213
|
+
void collectSystem().catch(() => {});
|
|
214
|
+
void collectProcess();
|
|
215
|
+
frames.length = 0;
|
|
216
|
+
collectFrame();
|
|
217
|
+
|
|
218
|
+
systemTimer = setInterval(() => {
|
|
219
|
+
void collectSystem().catch((error) => logger && logger.warn && logger.warn('metrics 系统指标采集失败', { error: error.message }));
|
|
220
|
+
}, SYSTEM_INTERVAL_MS);
|
|
221
|
+
processTimer = setInterval(() => {
|
|
222
|
+
try {
|
|
223
|
+
collectFrame();
|
|
224
|
+
} catch (error) {
|
|
225
|
+
// 定时器里的异常绝不能逃逸(未捕获异常会带走整个服务)
|
|
226
|
+
if (logger && logger.warn) logger.warn('metrics 进程指标采集失败', { error: error.message });
|
|
227
|
+
}
|
|
228
|
+
}, PROCESS_INTERVAL_MS);
|
|
229
|
+
// 两个定时器都不该阻止进程退出
|
|
230
|
+
if (typeof systemTimer.unref === 'function') systemTimer.unref();
|
|
231
|
+
if (typeof processTimer.unref === 'function') processTimer.unref();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function stop() {
|
|
235
|
+
if (systemTimer) clearInterval(systemTimer);
|
|
236
|
+
if (processTimer) clearInterval(processTimer);
|
|
237
|
+
systemTimer = null;
|
|
238
|
+
processTimer = null;
|
|
239
|
+
running = false;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** 最新一帧(还没采到时按需采一帧,保证接口可用) */
|
|
243
|
+
function snapshot() {
|
|
244
|
+
if (frames.length === 0) collectFrame();
|
|
245
|
+
return frames[frames.length - 1];
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* 历史样本:window 秒内、每 step 秒取一帧(契约 §4.6 `?window=300&step=5`)。
|
|
250
|
+
* @param {{window?: number, step?: number}} options
|
|
251
|
+
*/
|
|
252
|
+
function history({ window = 300, step = 5 } = {}) {
|
|
253
|
+
const windowMs = Math.min(HISTORY_WINDOW_MS, Math.max(1, window) * 1000);
|
|
254
|
+
const stepMs = Math.max(1, step) * 1000;
|
|
255
|
+
const deadline = Date.now() - windowMs;
|
|
256
|
+
// 用"时间桶内取最后一帧"而不是按 deadline 取模:deadline 随每次调用漂移,取模会让样本数抖动,
|
|
257
|
+
// 桶对齐才是"每 step 秒一个点"的确定语义。
|
|
258
|
+
const lastByBucket = new Map();
|
|
259
|
+
for (const frame of frames) {
|
|
260
|
+
if (frame.ts < deadline) continue;
|
|
261
|
+
lastByBucket.set(Math.floor(frame.ts / stepMs), frame);
|
|
262
|
+
}
|
|
263
|
+
return [...lastByBucket.values()].sort((left, right) => left.ts - right.ts);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return {
|
|
267
|
+
start,
|
|
268
|
+
stop,
|
|
269
|
+
snapshot,
|
|
270
|
+
history,
|
|
271
|
+
get running() {
|
|
272
|
+
return running;
|
|
273
|
+
},
|
|
274
|
+
/** 供测试/自检:当前缓冲帧数 */
|
|
275
|
+
get frameCount() {
|
|
276
|
+
return frames.length;
|
|
277
|
+
},
|
|
278
|
+
};
|
|
279
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// /proc 解析(Linux 专用;读不到一律返回 null,**绝不抛**)
|
|
2
|
+
//
|
|
3
|
+
// 为什么用 /proc 而不是引 systeminformation:
|
|
4
|
+
// 契约(docs/M0/API规范.md §4.6)要的字段全部可由 Node 内置 + /proc 得到;
|
|
5
|
+
// systeminformation 多出来的(温度/风扇/网络/进程列表)恰是 docs/07 §四 明确不做的,
|
|
6
|
+
// 而且它不少方法靠 spawn 系统命令实现 —— 一个"开销必须 <1%"的监控不该在底层 spawn 进程。
|
|
7
|
+
//
|
|
8
|
+
// 降级策略:容器/非 Linux 环境下 /proc 文件可能不存在或格式不同 → 每个解析函数都返回 null,
|
|
9
|
+
// 由上层把对应字段填 null(接口仍然活着,前端显示"—"),**不允许 500**。
|
|
10
|
+
|
|
11
|
+
import fsp from 'node:fs/promises';
|
|
12
|
+
|
|
13
|
+
const SECTOR_BYTES = 512;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 只统计物理盘(sd* / nvme*),**刻意排除** md* / dm-* / loop*:
|
|
17
|
+
* 同一份物理 IO 会在 md(软 RAID 成员)与 dm(LVM/加密)上被重复上报,
|
|
18
|
+
* 全都加进来就是双倍甚至三倍的虚高。NAS 上这个差别非常明显。
|
|
19
|
+
*/
|
|
20
|
+
const PHYSICAL_DISK = /^(sd[a-z]+|nvme\d+n\d+)$/;
|
|
21
|
+
|
|
22
|
+
/** 读文件;不存在/无权限返回 null(不抛) */
|
|
23
|
+
async function readProc(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
return await fsp.readFile(filePath, 'utf8');
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* 系统 CPU 累计时间(jiffies)。解析 /proc/stat 首行 `cpu user nice system idle iowait irq softirq steal`。
|
|
33
|
+
* @returns {{idle: number, total: number} | null}
|
|
34
|
+
*/
|
|
35
|
+
export function parseProcStat(text) {
|
|
36
|
+
if (typeof text !== 'string') return null;
|
|
37
|
+
const line = text.split('\n').find((row) => row.startsWith('cpu '));
|
|
38
|
+
if (!line) return null;
|
|
39
|
+
const values = line.trim().split(/\s+/).slice(1).map((value) => Number.parseInt(value, 10));
|
|
40
|
+
if (values.length < 4 || values.some((value) => !Number.isFinite(value))) return null;
|
|
41
|
+
// idle + iowait 都算"空转"(iowait 时 CPU 没在干活,对"卡不卡"的观感与 idle 一致)
|
|
42
|
+
const idle = (values[3] || 0) + (values[4] || 0);
|
|
43
|
+
const total = values.reduce((sum, value) => sum + value, 0);
|
|
44
|
+
return { idle, total };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 内存(字节)。用 MemAvailable 而不是 MemFree:
|
|
49
|
+
* NAS 上页缓存会吃掉大量内存,MemFree 常年很低 → 会显示"内存快满了"的假警报;
|
|
50
|
+
* MemAvailable 才是内核估计的"还能给新程序用多少"。
|
|
51
|
+
* @returns {{usedBytes: number, totalBytes: number} | null}
|
|
52
|
+
*/
|
|
53
|
+
export function parseMemInfo(text) {
|
|
54
|
+
if (typeof text !== 'string') return null;
|
|
55
|
+
const readKb = (key) => {
|
|
56
|
+
const match = text.match(new RegExp(`^${key}:\\s+(\\d+) kB`, 'm'));
|
|
57
|
+
return match ? Number.parseInt(match[1], 10) * 1024 : null;
|
|
58
|
+
};
|
|
59
|
+
const total = readKb('MemTotal');
|
|
60
|
+
const available = readKb('MemAvailable');
|
|
61
|
+
if (!Number.isFinite(total) || !Number.isFinite(available)) return null;
|
|
62
|
+
return { usedBytes: Math.max(0, total - available), totalBytes: total };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 磁盘累计扇区数(只汇总物理盘)。字段顺序见 proc(5) diskstats:
|
|
67
|
+
* `major minor name reads readsMerged sectorsRead msRead writes writesMerged sectorsWritten ...`
|
|
68
|
+
* @returns {{readSectors: number, writeSectors: number} | null}
|
|
69
|
+
*/
|
|
70
|
+
export function parseDiskStats(text) {
|
|
71
|
+
if (typeof text !== 'string') return null;
|
|
72
|
+
let readSectors = 0;
|
|
73
|
+
let writeSectors = 0;
|
|
74
|
+
let matched = 0;
|
|
75
|
+
for (const line of text.split('\n')) {
|
|
76
|
+
const fields = line.trim().split(/\s+/);
|
|
77
|
+
if (fields.length < 10) continue;
|
|
78
|
+
const device = fields[2];
|
|
79
|
+
if (!PHYSICAL_DISK.test(device)) continue;
|
|
80
|
+
const sectorsRead = Number.parseInt(fields[5], 10);
|
|
81
|
+
const sectorsWritten = Number.parseInt(fields[9], 10);
|
|
82
|
+
if (!Number.isFinite(sectorsRead) || !Number.isFinite(sectorsWritten)) continue;
|
|
83
|
+
readSectors += sectorsRead;
|
|
84
|
+
writeSectors += sectorsWritten;
|
|
85
|
+
matched += 1;
|
|
86
|
+
}
|
|
87
|
+
return matched > 0 ? { readSectors, writeSectors } : null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** 读一次全量原始值(三个来源各自独立降级) */
|
|
91
|
+
export async function readProcSnapshot() {
|
|
92
|
+
const [statText, memText, diskText] = await Promise.all([
|
|
93
|
+
readProc('/proc/stat'),
|
|
94
|
+
readProc('/proc/meminfo'),
|
|
95
|
+
readProc('/proc/diskstats'),
|
|
96
|
+
]);
|
|
97
|
+
return {
|
|
98
|
+
cpuTimes: parseProcStat(statText),
|
|
99
|
+
memory: parseMemInfo(memText),
|
|
100
|
+
disk: parseDiskStats(diskText),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 扇区数 → 字节(供采集器算 Bps) */
|
|
105
|
+
export function sectorsToBytes(sectors) {
|
|
106
|
+
return Number(sectors) * SECTOR_BYTES;
|
|
107
|
+
}
|