76 lines
3.9 KiB
TypeScript
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);
|
|
}
|
|
}
|