Files
2026-10-22 17:07:08 +08:00

76 lines
3.9 KiB
TypeScript

import {lstat, opendir} from 'node:fs/promises';
import path from 'node:path';
import {performance} from 'node:perf_hooks';
import type {Bot} from '@wp2508/mineflayer';
import {get_project_root_path} from '../../../utils/path_utils.ts';
import type {InfoContext} from '../types.ts';
import {metadata, ResourceInfo} from '../resource_metrics.ts';
export interface StorageInfo {
available: boolean;
total_bytes: number | null;
file_count: number;
partial: boolean;
}
/** lstat and streamed directory enumeration never intentionally follow symlinks. Live mutation can race a scan. */
export async function scan_storage(directory: string, cancelled = () => false,
limits = {entries: 20000, depth: 32, milliseconds: 2000}): Promise<StorageInfo> {
const info: StorageInfo = {available: false, total_bytes: null, file_count: 0, partial: false};
const start = performance.now();
let entries = 0;
const pending: [string, number][] = [[directory, 0]];
while (pending.length) {
if (cancelled() || entries >= limits.entries || performance.now() - start >= limits.milliseconds) {
info.partial = true; break;
}
const [current, depth] = pending.pop()!;
entries++;
try {
const stat = await lstat(current);
if (current === directory) {
if (!stat.isDirectory() || stat.isSymbolicLink()) { info.partial = true; break; }
info.available = true;
info.total_bytes = 0;
}
if (stat.isSymbolicLink()) continue;
if (stat.isFile()) {
info.total_bytes! += stat.size;
info.file_count++;
} else if (stat.isDirectory()) {
if (depth >= limits.depth) { info.partial = true; continue; }
const dir = await opendir(current);
for await (const entry of dir) {
if (cancelled() || entries + pending.length >= limits.entries
|| performance.now() - start >= limits.milliseconds) { info.partial = true; break; }
if (!entry.isSymbolicLink()) pending.push([path.join(current, entry.name), depth + 1]);
}
}
} catch (error) {
if (current === directory && (error as NodeJS.ErrnoException).code === 'ENOENT') {
info.available = true;
info.total_bytes = 0;
} else info.partial = true;
}
}
return info;
}
export class init extends ResourceInfo<StorageInfo> {
readonly name = 'used_storage';
readonly i18n = metadata('机器人数据存储', '统计 data/bot/<bot_id> 下文件的逻辑大小,跳过符号链接。'
+ '每 30 秒尽力扫描一次,不重叠执行;上限为 20000 个条目、32 层深度和 2 秒。'
+ '扫描期间的并发文件变动可能影响结果;该指标不代表实际分配的磁盘空间。', {
available: ['存储统计是否可用', '目录可访问或不存在时为 true,不存在按 0 统计;缺少有效机器人编号或根目录不可访问时为 false'],
total_bytes: ['文件总大小', '已观察到的普通文件逻辑大小之和,单位为字节;不可用时为 null'],
file_count: ['文件数量', '已观察到的普通文件数量'],
partial: ['结果是否不完整', '因扫描上限、取消或错误而未完成扫描;保留已读取文件的统计小计'],
});
constructor(_bot: Bot, options?: InfoContext) {
super({available: false, total_bytes: null, file_count: 0, partial: true});
if (!Number.isSafeInteger(options?.bot_id) || options!.bot_id! <= 0) return;
const directory = path.join(get_project_root_path(), 'data', 'bot', String(options!.bot_id));
this.start(30000, async () => { this.publish(await scan_storage(directory, () => this.disposed)); }, true);
}
}