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 { 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 { readonly name = 'used_storage'; readonly i18n = metadata('机器人数据存储', '统计 data/bot/ 下文件的逻辑大小,跳过符号链接。' + '每 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); } }