feat: 为项目添加 WebUI

This commit is contained in:
foxhui
2025-12-19 04:43:59 +08:00
parent 1ccaa55608
commit 41e3e022a3
33 changed files with 1842 additions and 40 deletions
+295
View File
@@ -0,0 +1,295 @@
/**
* @fileoverview 配置管理模块
* @description 提供配置读取和写入能力,支持分段更新
*/
import fs from 'fs';
import path from 'path';
import yaml from 'yaml';
import { logger } from './logger.js';
const CONFIG_PATH = path.join(process.cwd(), 'config.yaml');
/**
* 读取原始配置(不带缓存,直接从磁盘读取)
* @returns {object} 原始配置对象
*/
function readRawConfig() {
if (!fs.existsSync(CONFIG_PATH)) {
throw new Error('配置文件不存在');
}
const content = fs.readFileSync(CONFIG_PATH, 'utf8');
return yaml.parse(content);
}
/**
* 写入配置到文件
* @param {object} config - 完整配置对象
*/
function writeConfig(config) {
// 使用 yaml 库的默认序列化(会丢失注释,但结构正确)
const content = yaml.stringify(config, {
indent: 2,
lineWidth: 0 // 不自动换行
});
fs.writeFileSync(CONFIG_PATH, content, 'utf8');
logger.info('管理器', '配置已保存到 config.yaml');
}
/**
* 获取服务器配置
* @returns {object}
*/
export function getServerConfig() {
const config = readRawConfig();
return {
port: config.server?.port || 3000,
authToken: config.server?.auth || '',
keepaliveMode: config.server?.keepalive?.mode || 'comment'
};
}
/**
* 保存服务器配置
* @param {object} data - 服务器配置
*/
export function saveServerConfig(data) {
const config = readRawConfig();
if (!config.server) config.server = {};
if (data.port !== undefined) config.server.port = data.port;
if (data.authToken !== undefined) config.server.auth = data.authToken;
if (data.keepaliveMode !== undefined) {
if (!config.server.keepalive) config.server.keepalive = {};
config.server.keepalive.mode = data.keepaliveMode;
}
writeConfig(config);
}
/**
* 获取浏览器配置
* @returns {object}
*/
export function getBrowserConfig() {
const config = readRawConfig();
const browser = config.browser || {};
const proxy = browser.proxy || {};
return {
path: browser.path || '',
headless: browser.headless || false,
proxy: {
enable: proxy.enable || false,
type: proxy.type || 'http',
host: proxy.host || '',
port: proxy.port || 0,
auth: !!(proxy.user || proxy.passwd),
username: proxy.user || '',
password: proxy.passwd || ''
}
};
}
/**
* 保存浏览器配置
* @param {object} data - 浏览器配置
*/
export function saveBrowserConfig(data) {
const config = readRawConfig();
if (!config.browser) config.browser = {};
if (data.path !== undefined) config.browser.path = data.path;
if (data.headless !== undefined) config.browser.headless = data.headless;
if (data.proxy) {
if (!config.browser.proxy) config.browser.proxy = {};
const p = data.proxy;
if (p.enable !== undefined) config.browser.proxy.enable = p.enable;
if (p.type !== undefined) config.browser.proxy.type = p.type;
if (p.host !== undefined) config.browser.proxy.host = p.host;
if (p.port !== undefined) config.browser.proxy.port = p.port;
if (p.username !== undefined) config.browser.proxy.user = p.username;
if (p.password !== undefined) config.browser.proxy.passwd = p.password;
}
writeConfig(config);
}
/**
* 获取队列配置
* @returns {object}
*/
export function getQueueConfig() {
const config = readRawConfig();
return {
queueBuffer: config.queue?.queueBuffer ?? 2,
imageLimit: config.queue?.imageLimit ?? 5
};
}
/**
* 保存队列配置
* @param {object} data - 队列配置
*/
export function saveQueueConfig(data) {
const config = readRawConfig();
if (!config.queue) config.queue = {};
if (data.queueBuffer !== undefined) config.queue.queueBuffer = data.queueBuffer;
if (data.imageLimit !== undefined) config.queue.imageLimit = data.imageLimit;
writeConfig(config);
}
/**
* 获取实例配置
* @returns {object[]}
*/
export function getInstancesConfig() {
const config = readRawConfig();
const instances = config.backend?.pool?.instances || [];
return instances.map(inst => ({
name: inst.name,
userDataMark: inst.userDataMark || null,
proxy: inst.proxy ? {
enable: inst.proxy.enable || false,
type: inst.proxy.type || 'http',
host: inst.proxy.host || '',
port: inst.proxy.port || 0
} : null,
workers: (inst.workers || []).map(w => ({
name: w.name,
type: w.type,
mergeTypes: w.mergeTypes || [],
mergeMonitor: w.mergeMonitor || null
}))
}));
}
/**
* 保存实例配置
* @param {object[]} data - 实例配置列表
*/
export function saveInstancesConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
if (!config.backend.pool) config.backend.pool = {};
// 转换为 YAML 格式
config.backend.pool.instances = data.map(inst => {
const result = {
name: inst.name
};
if (inst.userDataMark) {
result.userDataMark = inst.userDataMark;
}
if (inst.proxy && inst.proxy.enable) {
result.proxy = {
enable: true,
type: inst.proxy.type || 'http',
host: inst.proxy.host,
port: inst.proxy.port
};
if (inst.proxy.username) result.proxy.user = inst.proxy.username;
if (inst.proxy.password) result.proxy.passwd = inst.proxy.password;
}
result.workers = (inst.workers || []).map(w => {
const worker = {
name: w.name,
type: w.type
};
if (w.type === 'merge' && w.mergeTypes) {
worker.mergeTypes = w.mergeTypes;
if (w.mergeMonitor) worker.mergeMonitor = w.mergeMonitor;
}
return worker;
});
return result;
});
writeConfig(config);
}
/**
* 获取适配器配置
* @returns {object}
*/
export function getAdaptersConfig() {
const config = readRawConfig();
return config.backend?.adapter || {};
}
/**
* 保存适配器配置
* @param {object} data - 适配器配置(键值对)
*/
export function saveAdaptersConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
// 合并而非覆盖,保留其他适配器配置
config.backend.adapter = {
...(config.backend.adapter || {}),
...data
};
writeConfig(config);
}
/**
* 获取 Pool 配置(负载均衡和故障转移)
* @returns {object}
*/
export function getPoolConfig() {
const config = readRawConfig();
const pool = config.backend?.pool || {};
const failover = pool.failover || {};
return {
strategy: pool.strategy || 'least_busy',
failover: {
enabled: failover.enabled !== false, // 默认 true
maxRetries: failover.maxRetries ?? 2
}
};
}
/**
* 保存 Pool 配置
* @param {object} data - Pool 配置
*/
export function savePoolConfig(data) {
const config = readRawConfig();
if (!config.backend) config.backend = {};
if (!config.backend.pool) config.backend.pool = {};
if (data.strategy !== undefined) {
config.backend.pool.strategy = data.strategy;
}
if (data.failover) {
if (!config.backend.pool.failover) config.backend.pool.failover = {};
if (data.failover.enabled !== undefined) {
config.backend.pool.failover.enabled = data.failover.enabled;
}
if (data.failover.maxRetries !== undefined) {
config.backend.pool.failover.maxRetries = data.failover.maxRetries;
}
}
writeConfig(config);
}
+270
View File
@@ -0,0 +1,270 @@
/**
* @fileoverview 配置校验模块
* @description 对前端提交的配置进行严格校验
*/
import { registry } from '../backend/registry.js';
/**
* 校验 Server 配置
* @param {object} data - 服务器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateServerConfig(data) {
const errors = [];
// 端口校验
if (data.port !== undefined) {
if (typeof data.port !== 'number' || !Number.isInteger(data.port)) {
errors.push('port 必须是整数');
} else if (data.port < 1 || data.port > 65535) {
errors.push('port 必须在 1-65535 范围内');
}
}
// Auth Token 校验
if (data.authToken !== undefined) {
if (typeof data.authToken !== 'string') {
errors.push('authToken 必须是字符串');
} else if (data.authToken.length < 10) {
errors.push('authToken 必须至少 10 个字符');
}
}
// Keepalive Mode 校验
if (data.keepaliveMode !== undefined) {
if (!['comment', 'content'].includes(data.keepaliveMode)) {
errors.push('keepaliveMode 必须是 comment 或 content');
}
}
// Queue Buffer 校验
if (data.queueBuffer !== undefined) {
if (typeof data.queueBuffer !== 'number' || !Number.isInteger(data.queueBuffer)) {
errors.push('queueBuffer 必须是整数');
} else if (data.queueBuffer < 0) {
errors.push('queueBuffer 不能为负数');
}
}
// Image Limit 校验
if (data.imageLimit !== undefined) {
if (typeof data.imageLimit !== 'number' || !Number.isInteger(data.imageLimit)) {
errors.push('imageLimit 必须是整数');
} else if (data.imageLimit < 1 || data.imageLimit > 10) {
errors.push('imageLimit 必须在 1-10 范围内');
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Browser 配置
* @param {object} data - 浏览器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateBrowserConfig(data) {
const errors = [];
// Path 校验(可选,字符串)
if (data.path !== undefined && typeof data.path !== 'string') {
errors.push('path 必须是字符串');
}
// Headless 校验
if (data.headless !== undefined && typeof data.headless !== 'boolean') {
errors.push('headless 必须是布尔值');
}
// Proxy 校验
if (data.proxy) {
if (data.proxy.enable !== undefined && typeof data.proxy.enable !== 'boolean') {
errors.push('proxy.enable 必须是布尔值');
}
if (data.proxy.type !== undefined && !['http', 'socks5'].includes(data.proxy.type)) {
errors.push('proxy.type 必须是 http 或 socks5');
}
if (data.proxy.port !== undefined) {
if (typeof data.proxy.port !== 'number' || !Number.isInteger(data.proxy.port)) {
errors.push('proxy.port 必须是整数');
} else if (data.proxy.port < 1 || data.proxy.port > 65535) {
errors.push('proxy.port 必须在 1-65535 范围内');
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Instances 配置
* @param {object[]} data - 实例配置列表
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateInstancesConfig(data) {
const errors = [];
if (!Array.isArray(data)) {
return { valid: false, errors: ['instances 必须是数组'] };
}
if (data.length === 0) {
return { valid: false, errors: ['instances 不能为空'] };
}
const instanceNames = new Set();
const workerNames = new Set();
// 获取有效的适配器类型列表
const validAdapterTypes = new Set(registry.getAdapterIds());
validAdapterTypes.add('merge'); // merge 是特殊类型
for (let i = 0; i < data.length; i++) {
const inst = data[i];
const prefix = `instances[${i}]`;
// Instance 名称校验
if (!inst.name || typeof inst.name !== 'string') {
errors.push(`${prefix}: name 是必填字段且必须是字符串`);
} else if (inst.name.trim() === '') {
errors.push(`${prefix}: name 不能为空`);
} else if (instanceNames.has(inst.name)) {
errors.push(`${prefix}: Instance 名称 "${inst.name}" 重复`);
} else {
instanceNames.add(inst.name);
}
// userDataMark 校验(可选,可为空)
if (inst.userDataMark !== undefined && inst.userDataMark !== null && inst.userDataMark !== '') {
if (typeof inst.userDataMark !== 'string') {
errors.push(`${prefix}: userDataMark 必须是字符串`);
} else if (!/^[a-zA-Z0-9_-]+$/.test(inst.userDataMark)) {
errors.push(`${prefix}: userDataMark 只能包含字母、数字、下划线和连字符`);
}
}
// Proxy 校验(可选)
if (inst.proxy) {
if (inst.proxy.enable !== undefined && typeof inst.proxy.enable !== 'boolean') {
errors.push(`${prefix}.proxy: enable 必须是布尔值`);
}
if (inst.proxy.type !== undefined && !['http', 'socks5'].includes(inst.proxy.type)) {
errors.push(`${prefix}.proxy: type 必须是 http 或 socks5`);
}
if (inst.proxy.port !== undefined) {
if (typeof inst.proxy.port !== 'number') {
errors.push(`${prefix}.proxy: port 必须是数字`);
} else if (inst.proxy.port < 1 || inst.proxy.port > 65535) {
errors.push(`${prefix}.proxy: port 必须在 1-65535 范围内`);
}
}
}
// Workers 校验
if (!inst.workers || !Array.isArray(inst.workers)) {
errors.push(`${prefix}: workers 是必填字段且必须是数组`);
} else if (inst.workers.length === 0) {
errors.push(`${prefix}: workers 不能为空`);
} else {
for (let j = 0; j < inst.workers.length; j++) {
const w = inst.workers[j];
const wPrefix = `${prefix}.workers[${j}]`;
// Worker 名称校验
if (!w.name || typeof w.name !== 'string') {
errors.push(`${wPrefix}: name 是必填字段`);
} else if (w.name.trim() === '') {
errors.push(`${wPrefix}: name 不能为空`);
} else if (workerNames.has(w.name)) {
errors.push(`${wPrefix}: Worker 名称 "${w.name}" 全局重复(Worker 名称必须全局唯一)`);
} else {
workerNames.add(w.name);
}
// Worker type 校验
if (!w.type || typeof w.type !== 'string') {
errors.push(`${wPrefix}: type 是必填字段`);
} else if (!validAdapterTypes.has(w.type)) {
errors.push(`${wPrefix}: type "${w.type}" 不是有效的适配器类型`);
}
// merge 类型特殊校验
if (w.type === 'merge') {
if (!w.mergeTypes || !Array.isArray(w.mergeTypes) || w.mergeTypes.length === 0) {
errors.push(`${wPrefix}: merge 类型必须指定 mergeTypes 数组`);
} else {
for (const mt of w.mergeTypes) {
if (!validAdapterTypes.has(mt) || mt === 'merge') {
errors.push(`${wPrefix}: mergeTypes 中的 "${mt}" 不是有效的适配器类型`);
}
}
}
if (w.mergeMonitor && !w.mergeTypes?.includes(w.mergeMonitor)) {
errors.push(`${wPrefix}: mergeMonitor "${w.mergeMonitor}" 必须是 mergeTypes 中的一个`);
}
}
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Pool 配置
* @param {object} data - Pool 配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validatePoolConfig(data) {
const errors = [];
// Strategy 校验
if (data.strategy !== undefined) {
if (!['least_busy', 'round_robin', 'random'].includes(data.strategy)) {
errors.push('strategy 必须是 least_busy、round_robin 或 random');
}
}
// Failover 校验
if (data.failover) {
if (data.failover.enabled !== undefined && typeof data.failover.enabled !== 'boolean') {
errors.push('failover.enabled 必须是布尔值');
}
if (data.failover.maxRetries !== undefined) {
if (typeof data.failover.maxRetries !== 'number' || !Number.isInteger(data.failover.maxRetries)) {
errors.push('failover.maxRetries 必须是整数');
} else if (data.failover.maxRetries < 0) {
errors.push('failover.maxRetries 不能为负数');
}
}
}
return { valid: errors.length === 0, errors };
}
/**
* 校验 Adapters 配置
* @param {object} data - 适配器配置
* @returns {{valid: boolean, errors: string[]}}
*/
export function validateAdaptersConfig(data) {
const errors = [];
if (typeof data !== 'object' || data === null) {
return { valid: false, errors: ['adapters 配置必须是对象'] };
}
// gemini_biz 配置校验
if (data.gemini_biz) {
if (data.gemini_biz.entryUrl !== undefined) {
if (typeof data.gemini_biz.entryUrl !== 'string') {
errors.push('gemini_biz.entryUrl 必须是字符串');
} else if (data.gemini_biz.entryUrl && !data.gemini_biz.entryUrl.startsWith('https://')) {
errors.push('gemini_biz.entryUrl 必须以 https:// 开头');
}
}
}
return { valid: errors.length === 0, errors };
}
+256
View File
@@ -0,0 +1,256 @@
/**
* @fileoverview 系统信息工具模块
* @description 提供系统状态、数据文件夹管理等功能
*/
import os from 'os';
import fs from 'fs';
import path from 'path';
import { logger } from './logger.js';
// 服务启动时间
const startTime = Date.now();
// 版本信息(从 package.json 读取)
let version = '1.0.0';
try {
const pkgPath = path.join(process.cwd(), 'package.json');
if (fs.existsSync(pkgPath)) {
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
version = pkg.version || '1.0.0';
}
} catch (e) { /* ignore */ }
// CPU 使用率采样数据
let lastCpuInfo = null;
/**
* 获取 CPU 使用率(跨平台)
* @returns {number} CPU 使用率百分比
*/
function getCpuUsage() {
const cpus = os.cpus();
if (cpus.length === 0) return 0;
let totalIdle = 0;
let totalTick = 0;
for (const cpu of cpus) {
for (const type in cpu.times) {
totalTick += cpu.times[type];
}
totalIdle += cpu.times.idle;
}
const currentInfo = { idle: totalIdle, total: totalTick };
if (!lastCpuInfo) {
lastCpuInfo = currentInfo;
return 0; // 第一次调用无法计算
}
const idleDiff = currentInfo.idle - lastCpuInfo.idle;
const totalDiff = currentInfo.total - lastCpuInfo.total;
lastCpuInfo = currentInfo;
if (totalDiff === 0) return 0;
const usage = 100 - (idleDiff / totalDiff * 100);
return Math.round(usage * 10) / 10;
}
/**
* 获取系统状态
* @returns {object} 系统状态信息
*/
export function getSystemStatus() {
const totalMem = os.totalmem();
const freeMem = os.freemem();
const usedMem = totalMem - freeMem;
// 检测运行模式
const isXvfb = !!process.env.XVFB_RUNNING;
const isHeadless = process.env.HEADLESS === 'true';
return {
status: isXvfb ? 'xvfb' : (isHeadless ? 'headless' : 'normal'),
version,
systemVersion: `${os.type()} ${os.release()}`,
uptime: Math.floor((Date.now() - startTime) / 1000),
cpuUsage: getCpuUsage(),
memoryUsage: {
total: Math.round(totalMem / 1024 / 1024),
used: Math.round(usedMem / 1024 / 1024),
free: Math.round(freeMem / 1024 / 1024)
}
};
}
/**
* 获取数据文件夹列表
* @param {object[]} workers - 当前 Worker 配置列表
* @returns {object[]} 数据文件夹信息
*/
export function getDataFolders(workers = []) {
const dataDir = path.join(process.cwd(), 'data');
if (!fs.existsSync(dataDir)) {
return [];
}
const folders = [];
const entries = fs.readdirSync(dataDir, { withFileTypes: true });
// 构建 userDataDir -> workerName 映射
const workerMap = new Map();
for (const w of workers) {
if (w.userDataDir) {
workerMap.set(path.basename(w.userDataDir), w.name);
}
}
for (const entry of entries) {
if (entry.isDirectory() && entry.name.startsWith('camoufoxUserData')) {
const folderPath = path.join(dataDir, entry.name);
let size = 0;
// 计算文件夹大小(递归,但限制深度避免性能问题)
try {
size = getFolderSize(folderPath, 3);
} catch (e) { /* ignore */ }
folders.push({
name: entry.name,
path: `data/${entry.name}`,
size: formatSize(size),
sizeBytes: size,
instance: workerMap.get(entry.name) || null
});
}
}
return folders;
}
/**
* 删除指定的数据文件夹
* @param {string[]} folderNames - 要删除的文件夹名称列表
* @param {object[]} workers - 当前 Worker 配置列表(用于检查是否正在使用)
* @returns {{success: boolean, deleted: string[], errors: string[]}}
*/
export function deleteDataFolders(folderNames, workers = []) {
const dataDir = path.join(process.cwd(), 'data');
const deleted = [];
const errors = [];
// 构建正在使用的文件夹集合
const inUse = new Set();
for (const w of workers) {
if (w.userDataDir) {
inUse.add(path.basename(w.userDataDir));
}
}
for (const name of folderNames) {
// 安全检查:只允许删除 camoufoxUserData 开头的文件夹
if (!name.startsWith('camoufoxUserData')) {
errors.push(`${name}: 不允许删除非用户数据文件夹`);
continue;
}
// 检查是否正在使用
if (inUse.has(name)) {
errors.push(`${name}: 文件夹正在被 Worker 使用`);
continue;
}
const folderPath = path.join(dataDir, name);
// 检查是否存在
if (!fs.existsSync(folderPath)) {
errors.push(`${name}: 文件夹不存在`);
continue;
}
// 删除文件夹
try {
fs.rmSync(folderPath, { recursive: true, force: true });
deleted.push(name);
logger.info('系统', `已删除数据文件夹: ${name}`);
} catch (e) {
errors.push(`${name}: ${e.message}`);
}
}
return {
success: errors.length === 0,
deleted,
errors
};
}
/**
* 清理临时文件
* @param {string} tempDir - 临时目录路径
* @returns {{success: boolean, cleaned: number}}
*/
export function clearTempFiles(tempDir) {
if (!tempDir || !fs.existsSync(tempDir)) {
return { success: true, cleaned: 0 };
}
let cleaned = 0;
try {
const files = fs.readdirSync(tempDir);
for (const file of files) {
try {
fs.unlinkSync(path.join(tempDir, file));
cleaned++;
} catch (e) { /* ignore */ }
}
logger.info('系统', `已清理 ${cleaned} 个临时文件`);
} catch (e) {
logger.warn('系统', `清理临时文件失败: ${e.message}`);
}
return { success: true, cleaned };
}
// ==================== 辅助函数 ====================
/**
* 计算文件夹大小
* @param {string} dirPath - 目录路径
* @param {number} maxDepth - 最大深度
* @returns {number} 字节数
*/
function getFolderSize(dirPath, maxDepth) {
if (maxDepth <= 0) return 0;
let size = 0;
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);
if (entry.isFile()) {
try {
size += fs.statSync(fullPath).size;
} catch (e) { /* ignore */ }
} else if (entry.isDirectory()) {
size += getFolderSize(fullPath, maxDepth - 1);
}
}
return size;
}
/**
* 格式化文件大小
* @param {number} bytes - 字节数
* @returns {string} 格式化后的字符串
*/
function formatSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
return `${(bytes / 1024 / 1024 / 1024).toFixed(1)} GB`;
}