diff --git a/hubcmdui/README.md b/hubcmdui/README.md index 2b76a14..c1aa322 100644 --- a/hubcmdui/README.md +++ b/hubcmdui/README.md @@ -26,6 +26,75 @@ --- +## 🔧 日志系统说明 + +本项目实现了生产级别的日志系统,支持以下特性: + +### 日志级别 + +支持的日志级别从低到高依次为: +- `TRACE`: 最详细的追踪信息,用于开发调试 +- `DEBUG`: 调试信息,包含详细的程序执行流程 +- `INFO`: 一般信息,默认级别 +- `SUCCESS`: 成功信息,通常用于标记重要操作的成功完成 +- `WARN`: 警告信息,表示潜在的问题 +- `ERROR`: 错误信息,表示操作失败但程序仍可继续运行 +- `FATAL`: 致命错误,通常会导致程序退出 + +### 环境变量配置 + +可通过环境变量调整日志行为: + +```bash +# 设置日志级别 +export LOG_LEVEL=INFO # 可选值: TRACE, DEBUG, INFO, SUCCESS, WARN, ERROR, FATAL + +# 启用简化日志输出(减少浏览器请求详细信息) +export SIMPLE_LOGS=true + +# 启用详细日志记录(包含请求体、查询参数等) +export DETAILED_LOGS=true + +# 启用错误堆栈跟踪 +export SHOW_STACK=true + +# 禁用文件日志记录 +export LOG_FILE_ENABLED=false + +# 禁用控制台日志输出 +export LOG_CONSOLE_ENABLED=false + +# 设置日志文件大小上限(MB) +export LOG_MAX_SIZE=10 + +# 设置保留的日志文件数量 +export LOG_MAX_FILES=14 +``` + +### Docker运行时配置 + +使用Docker运行时,可以通过环境变量传递配置: + +```bash +docker run -d \ + -v /var/run/docker.sock:/var/run/docker.sock \ + -p 30080:3000 \ + -e LOG_LEVEL=INFO \ + -e SIMPLE_LOGS=true \ + -e LOG_MAX_FILES=7 \ + --name hubcmdui-server \ + dqzboy/hubcmd-ui +``` + +### 日志文件轮转 + +系统自动实现日志文件轮转: +- 单个日志文件超过设定大小(默认10MB)会自动创建新文件 +- 自动保留指定数量(默认14个)的最新日志文件 +- 日志文件存储在`logs`目录下,格式为`app-YYYY-MM-DD.log` + +--- + ## 📝 源码构建运行 #### 1. 克隆项目 ```bash diff --git a/hubcmdui/app.js b/hubcmdui/app.js new file mode 100644 index 0000000..16a68c5 --- /dev/null +++ b/hubcmdui/app.js @@ -0,0 +1,186 @@ +#!/usr/bin/env node + +/** + * 应用主入口文件 - 启动服务器并初始化所有组件 + */ + +// 记录服务器启动时间 - 最先执行这行代码,确保第一时间记录 +global.serverStartTime = Date.now(); + +const express = require('express'); +const session = require('express-session'); +const path = require('path'); +const http = require('http'); +const logger = require('./logger'); +const { ensureDirectoriesExist } = require('./init-dirs'); +const registerRoutes = require('./routes'); +const { requireLogin, sessionActivity, sanitizeRequestBody, securityHeaders } = require('./middleware/auth'); + +// 记录服务器启动时间到日志 +console.log(`服务器启动,时间戳: ${global.serverStartTime}`); +logger.warn(`服务器启动,时间戳: ${global.serverStartTime}`); + +// 添加 session 文件存储模块 - 先导入session-file-store并创建对象 +const FileStore = require('session-file-store')(session); + +// 确保目录结构存在 +ensureDirectoriesExist().catch(err => { + logger.error('创建必要目录失败:', err); + process.exit(1); +}); + +// 初始化Express应用 - 确保正确初始化 +const app = express(); +const server = http.createServer(app); + +// 基本中间件配置 +app.use(express.json()); +app.use(express.urlencoded({ extended: true })); +app.use(express.static(path.join(__dirname, 'web'))); +// 添加对documentation目录的静态访问 +app.use('/documentation', express.static(path.join(__dirname, 'documentation'))); +app.use(sessionActivity); +app.use(sanitizeRequestBody); +app.use(securityHeaders); + +// 会话配置 +app.use(session({ + secret: process.env.SESSION_SECRET || 'hubcmdui-secret-key', + resave: false, + saveUninitialized: false, + cookie: { + secure: process.env.NODE_ENV === 'production', + maxAge: 24 * 60 * 60 * 1000 // 24小时 + }, + store: new FileStore({ + path: path.join(__dirname, 'data', 'sessions'), + ttl: 86400 + }) +})); + +// 添加一个中间件来检查API请求的会话状态 +app.use('/api', (req, res, next) => { + // 这些API端点不需要登录 + const publicEndpoints = [ + '/api/login', + '/api/logout', + '/api/check-session', + '/api/health', + '/api/system-status', + '/api/system-resource-details', + '/api/menu-items', + '/api/config', + '/api/monitoring-config', + '/api/documentation', + '/api/documentation/file' + ]; + + // 如果是公共API或用户已登录,则继续 + if (publicEndpoints.includes(req.path) || + publicEndpoints.some(endpoint => req.path.startsWith(endpoint)) || + (req.session && req.session.user)) { + return next(); + } + + // 否则返回401未授权 + logger.warn(`未授权访问: ${req.path}`); + return res.status(401).json({ error: 'Unauthorized' }); +}); + +// 导入并注册所有路由 +registerRoutes(app); + +// 默认路由 +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'web', 'index.html')); +}); + +app.get('/admin', (req, res) => { + res.sendFile(path.join(__dirname, 'web', 'admin.html')); +}); + +// 404处理 +app.use((req, res) => { + res.status(404).json({ error: 'Not Found' }); +}); + +// 错误处理中间件 +app.use((err, req, res, next) => { + logger.error('应用错误:', err); + res.status(500).json({ error: '服务器内部错误', details: err.message }); +}); + +// 启动服务器 +const PORT = process.env.PORT || 3000; +server.listen(PORT, async () => { + logger.info(`服务器已启动并监听端口 ${PORT}`); + + try { + // 确保目录存在 + await ensureDirectoriesExist(); + logger.success('系统初始化完成'); + } catch (error) { + logger.error('系统初始化失败:', error); + } +}); + +// 注册进程事件处理 +process.on('SIGINT', () => { + logger.info('接收到中断信号,正在关闭服务...'); + server.close(() => { + logger.info('服务器已关闭'); + process.exit(0); + }); +}); + +process.on('SIGTERM', () => { + logger.info('接收到终止信号,正在关闭服务...'); + server.close(() => { + logger.info('服务器已关闭'); + process.exit(0); + }); +}); + +module.exports = { app, server }; + +// 路由注册函数 +function registerRoutes(app) { + try { + logger.info('开始注册路由...'); + + // API端点 + app.use('/api', [ + require('./routes/index'), + require('./routes/docker'), + require('./routes/docs'), + require('./routes/users'), + require('./routes/menu'), + require('./routes/server') + ]); + logger.info('基本API路由已注册'); + + // 系统路由 - 函数式注册 + const systemRouter = require('./routes/system'); + app.use('/api/system', systemRouter); + logger.info('系统路由已注册'); + + // 认证路由 - 直接使用Router实例 + const authRouter = require('./routes/auth'); + app.use('/api', authRouter); + logger.info('认证路由已注册'); + + // 配置路由 - 函数式注册 + const configRouter = require('./routes/config'); + if (typeof configRouter === 'function') { + logger.info('配置路由是一个函数,正在注册...'); + configRouter(app); + logger.info('配置路由已注册'); + } else { + logger.error('配置路由不是一个函数,无法注册', typeof configRouter); + } + + logger.success('✓ 所有路由已注册'); + } catch (error) { + logger.error('路由注册失败:', error); + } +} diff --git a/hubcmdui/cleanup.js b/hubcmdui/cleanup.js new file mode 100644 index 0000000..b6853d2 --- /dev/null +++ b/hubcmdui/cleanup.js @@ -0,0 +1,72 @@ +const logger = require('./logger'); + +// 处理未捕获的异常 +process.on('uncaughtException', (error) => { + logger.error('未捕获的异常:', error); + // 打印完整的堆栈跟踪以便调试 + console.error('错误堆栈:', error.stack); + // 不立即退出,以便日志能够被写入 + setTimeout(() => { + process.exit(1); + }, 1000); +}); + +// 处理未处理的Promise拒绝 +process.on('unhandledRejection', (reason, promise) => { + logger.error('未处理的Promise拒绝:', reason); + // 打印堆栈跟踪(如果可用) + if (reason instanceof Error) { + console.error('Promise拒绝堆栈:', reason.stack); + } +}); + +// 处理退出信号 +process.on('SIGINT', gracefulShutdown); +process.on('SIGTERM', gracefulShutdown); + +// 优雅退出函数 +function gracefulShutdown() { + logger.info('接收到退出信号,正在关闭...'); + + // 这里可以添加清理代码,如关闭数据库连接等 + try { + // 关闭任何可能的资源 + try { + const docker = require('./services/dockerService').getDockerConnection(); + if (docker) { + logger.info('正在关闭Docker连接...'); + // 如果有活动的Docker连接,可能需要执行一些清理 + } + } catch (err) { + // 忽略错误,可能服务未初始化 + logger.debug('Docker服务未初始化,跳过清理'); + } + + // 清理监控间隔 + try { + const monitoringService = require('./services/monitoringService'); + if (monitoringService.stopMonitoring) { + logger.info('正在停止容器监控...'); + monitoringService.stopMonitoring(); + } + } catch (err) { + // 忽略错误,可能服务未初始化 + logger.debug('监控服务未初始化,跳过清理'); + } + + logger.info('所有资源已清理完毕,正在退出...'); + } catch (error) { + logger.error('退出过程中出现错误:', error); + } + + setTimeout(() => { + logger.info('干净退出完成'); + process.exit(0); + }, 1000); +} + +logger.info('错误处理和清理脚本已加载'); + +module.exports = { + gracefulShutdown +}; diff --git a/hubcmdui/compatibility-layer.js b/hubcmdui/compatibility-layer.js new file mode 100644 index 0000000..5516741 --- /dev/null +++ b/hubcmdui/compatibility-layer.js @@ -0,0 +1,1148 @@ +/** + * 兼容层 - 确保旧版API接口继续工作 + */ +const logger = require('./logger'); +const { requireLogin } = require('./middleware/auth'); +const { execCommand } = require('./server-utils'); +const os = require('os'); +const { exec } = require('child_process'); +const util = require('util'); +const execPromise = util.promisify(exec); + +module.exports = function(app) { + logger.info('加载API兼容层...'); + + // 会话检查接口 + app.get('/api/check-session', (req, res) => { + if (req.session && req.session.user) { + res.json({ authenticated: true, user: req.session.user }); + } else { + res.json({ authenticated: false }); + } + }); + + // 添加Docker状态检查接口,并使用 requireLogin 中间件 + app.get('/api/docker/status', requireLogin, async (req, res) => { + try { + const dockerService = require('./services/dockerService'); + const dockerStatus = await dockerService.checkDockerAvailability(); + res.json({ isRunning: dockerStatus }); + } catch (error) { + logger.error('检查Docker状态失败:', error); + res.status(500).json({ error: '检查Docker状态失败', details: error.message }); + } + }); + + // 验证码接口 + app.get('/api/captcha', (req, res) => { + try { + const num1 = Math.floor(Math.random() * 10); + const num2 = Math.floor(Math.random() * 10); + const captcha = `${num1} + ${num2} = ?`; + req.session.captcha = num1 + num2; + res.json({ captcha }); + } catch (error) { + logger.error('生成验证码失败:', error); + res.status(500).json({ error: '生成验证码失败' }); + } + }); + + // 停止容器列表接口 + app.get('/api/stopped-containers', requireLogin, async (req, res) => { + try { + const monitoringService = require('./services/monitoringService'); + const stoppedContainers = await monitoringService.getStoppedContainers(); + res.json(stoppedContainers); + } catch (error) { + logger.error('获取已停止容器列表失败:', error); + res.status(500).json({ error: '获取已停止容器列表失败', details: error.message }); + } + }); + + // 修复Docker Hub搜索接口 - 直接使用axios请求,避免dockerHubService的依赖问题 + app.get('/api/dockerhub/search', async (req, res) => { + try { + const axios = require('axios'); + const term = req.query.term; + const page = req.query.page || 1; + + if (!term) { + return res.status(400).json({ error: '搜索词不能为空' }); + } + + logger.info(`搜索Docker Hub: ${term} (页码: ${page})`); + + const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=25`; + const response = await axios.get(url, { + timeout: 15000, + headers: { + 'User-Agent': 'DockerHubSearchClient/1.0', + 'Accept': 'application/json' + } + }); + + res.json(response.data); + } catch (error) { + logger.error('搜索Docker Hub失败:', error.message || error); + res.status(500).json({ + error: '搜索失败', + details: error.message || '未知错误', + retryable: true + }); + } + }); + + // Docker Hub 标签计数接口 + app.get('/api/dockerhub/tag-count', async (req, res) => { + try { + const axios = require('axios'); + const name = req.query.name; + const isOfficial = req.query.official === 'true'; + + if (!name) { + return res.status(400).json({ error: '镜像名称不能为空' }); + } + + const fullImageName = isOfficial ? `library/${name}` : name; + const apiUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags/?page_size=1`; + + logger.info(`获取标签计数: ${fullImageName}`); + + const response = await axios.get(apiUrl, { + timeout: 15000, + headers: { + 'User-Agent': 'DockerHubSearchClient/1.0', + 'Accept': 'application/json' + } + }); + + res.json({ + count: response.data.count, + recommended_mode: response.data.count > 500 ? 'paginated' : 'full' + }); + } catch (error) { + logger.error('获取标签计数失败:', error.message || error); + res.status(500).json({ + error: '获取标签计数失败', + details: error.message || '未知错误', + retryable: true + }); + } + }); + + // Docker Hub 标签接口 + app.get('/api/dockerhub/tags', async (req, res) => { + try { + const axios = require('axios'); + const imageName = req.query.name; + const isOfficial = req.query.official === 'true'; + const page = parseInt(req.query.page) || 1; + const page_size = parseInt(req.query.page_size) || 25; + const getAllTags = req.query.all === 'true'; + + if (!imageName) { + return res.status(400).json({ error: '镜像名称不能为空' }); + } + + const fullImageName = isOfficial ? `library/${imageName}` : imageName; + logger.info(`获取镜像标签: ${fullImageName}, 页码: ${page}, 每页数量: ${page_size}, 获取全部: ${getAllTags}`); + + // 如果请求所有标签,需要递归获取所有页 + if (getAllTags) { + // 暂不实现全部获取,返回错误 + return res.status(400).json({ error: '获取全部标签功能暂未实现,请使用分页获取' }); + } else { + // 获取特定页的标签 + const tagsUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${page_size}`; + + const tagsResponse = await axios.get(tagsUrl, { + timeout: 15000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' + } + }); + + // 检查响应数据有效性 + if (!tagsResponse.data || typeof tagsResponse.data !== 'object') { + logger.warn(`镜像 ${fullImageName} 返回的数据格式不正确`); + return res.status(500).json({ error: '响应数据格式不正确' }); + } + + if (!tagsResponse.data.results || !Array.isArray(tagsResponse.data.results)) { + logger.warn(`镜像 ${fullImageName} 没有返回有效的标签数据`); + return res.status(500).json({ error: '没有找到有效的标签数据' }); + } + + // 过滤掉无效平台信息 + const cleanedResults = tagsResponse.data.results.map(tag => { + if (tag.images && Array.isArray(tag.images)) { + tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown')); + } + return tag; + }); + + return res.json({ + ...tagsResponse.data, + results: cleanedResults + }); + } + } catch (error) { + logger.error('获取标签列表失败:', error.message || error); + res.status(500).json({ + error: '获取标签列表失败', + details: error.message || '未知错误', + retryable: true + }); + } + }); + + // 文档接口 + app.get('/api/documentation', async (req, res) => { + try { + const docService = require('./services/documentationService'); + const documents = await docService.getPublishedDocuments(); + res.json(documents); + } catch (error) { + logger.error('获取已发布文档失败:', error); + res.status(500).json({ error: '获取文档失败', details: error.message }); + } + }); + + // 监控配置接口 + app.get('/api/monitoring-config', async (req, res) => { + try { + logger.info('兼容层处理监控配置请求'); + const fs = require('fs').promises; + const path = require('path'); + + // 监控配置文件路径 + const CONFIG_FILE = path.join(__dirname, './config/monitoring.json'); + + // 确保配置文件存在 + try { + await fs.access(CONFIG_FILE); + } catch (err) { + // 文件不存在,创建默认配置 + const defaultConfig = { + isEnabled: false, + notificationType: 'wechat', + webhookUrl: '', + telegramToken: '', + telegramChatId: '', + monitorInterval: 60 + }; + + await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2), 'utf8'); + return res.json(defaultConfig); + } + + // 文件存在,读取配置 + const data = await fs.readFile(CONFIG_FILE, 'utf8'); + res.json(JSON.parse(data)); + } catch (err) { + logger.error('获取监控配置失败:', err); + res.status(500).json({ error: '获取监控配置失败' }); + } + }); + + // 保存监控配置接口 + app.post('/api/monitoring-config', async (req, res) => { + try { + logger.info('兼容层处理保存监控配置请求'); + const fs = require('fs').promises; + const path = require('path'); + + const { + notificationType, + webhookUrl, + telegramToken, + telegramChatId, + monitorInterval, + isEnabled + } = req.body; + + // 简单验证 + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' }); + } + + // 监控配置文件路径 + const CONFIG_FILE = path.join(__dirname, './config/monitoring.json'); + + // 确保配置文件存在 + let config = { + isEnabled: false, + notificationType: 'wechat', + webhookUrl: '', + telegramToken: '', + telegramChatId: '', + monitorInterval: 60 + }; + + try { + const data = await fs.readFile(CONFIG_FILE, 'utf8'); + config = JSON.parse(data); + } catch (err) { + // 如果读取失败,使用默认配置 + logger.warn('读取监控配置失败,将使用默认配置:', err); + } + + // 更新配置 + const updatedConfig = { + ...config, + notificationType, + webhookUrl: webhookUrl || '', + telegramToken: telegramToken || '', + telegramChatId: telegramChatId || '', + monitorInterval: parseInt(monitorInterval, 10) || 60, + isEnabled: isEnabled !== undefined ? isEnabled : config.isEnabled + }; + + await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2), 'utf8'); + + res.json({ success: true, message: '监控配置已保存' }); + + // 通知监控服务重新加载配置 + if (global.monitoringService && typeof global.monitoringService.reload === 'function') { + global.monitoringService.reload(); + } + } catch (err) { + logger.error('保存监控配置失败:', err); + res.status(500).json({ error: '保存监控配置失败' }); + } + }); + + // 获取单个文档接口 + app.get('/api/documentation/:id', async (req, res) => { + try { + const docService = require('./services/documentationService'); + const document = await docService.getDocument(req.params.id); + + // 如果文档不是发布状态,只有已登录用户才能访问 + if (!document.published && !req.session.user) { + return res.status(403).json({ error: '没有权限访问该文档' }); + } + + res.json(document); + } catch (error) { + logger.error(`获取文档 ID:${req.params.id} 失败:`, error); + if (error.code === 'ENOENT') { + return res.status(404).json({ error: '文档不存在' }); + } + res.status(500).json({ error: '获取文档失败', details: error.message }); + } + }); + + // 文档列表接口 + app.get('/api/documentation-list', requireLogin, async (req, res) => { + try { + const docService = require('./services/documentationService'); + const documents = await docService.getDocumentationList(); + res.json(documents); + } catch (error) { + logger.error('获取文档列表失败:', error); + res.status(500).json({ error: '获取文档列表失败', details: error.message }); + } + }); + + // 切换监控状态接口 + app.post('/api/toggle-monitoring', async (req, res) => { + try { + logger.info('兼容层处理切换监控状态请求'); + const fs = require('fs').promises; + const path = require('path'); + + const { isEnabled } = req.body; + + // 监控配置文件路径 + const CONFIG_FILE = path.join(__dirname, './config/monitoring.json'); + + // 确保配置文件存在 + let config = { + isEnabled: false, + notificationType: 'wechat', + webhookUrl: '', + telegramToken: '', + telegramChatId: '', + monitorInterval: 60 + }; + + try { + const data = await fs.readFile(CONFIG_FILE, 'utf8'); + config = JSON.parse(data); + } catch (err) { + // 如果读取失败,使用默认配置 + logger.warn('读取监控配置失败,将使用默认配置:', err); + } + + // 更新启用状态 + config.isEnabled = !!isEnabled; + + await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); + + res.json({ + success: true, + message: `监控已${isEnabled ? '启用' : '禁用'}` + }); + + // 通知监控服务重新加载配置 + if (global.monitoringService && typeof global.monitoringService.reload === 'function') { + global.monitoringService.reload(); + } + } catch (err) { + logger.error('切换监控状态失败:', err); + res.status(500).json({ error: '切换监控状态失败' }); + } + }); + + // 测试通知接口 + app.post('/api/test-notification', async (req, res) => { + try { + logger.info('兼容层处理测试通知请求'); + + const { + notificationType, + webhookUrl, + telegramToken, + telegramChatId + } = req.body; + + // 简单验证 + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' }); + } + + // 发送测试通知 + const notifier = require('./services/notificationService'); + const testMessage = { + title: '测试通知', + content: '这是一条测试通知,如果您收到这条消息,说明您的通知配置工作正常。', + time: new Date().toLocaleString() + }; + + await notifier.sendNotification(testMessage, { + type: notificationType, + webhookUrl, + telegramToken, + telegramChatId + }); + + res.json({ success: true, message: '测试通知已发送' }); + } catch (err) { + logger.error('发送测试通知失败:', err); + res.status(500).json({ error: '发送测试通知失败: ' + err.message }); + } + }); + + // 获取已停止的容器接口 + app.get('/api/stopped-containers', requireLogin, async (req, res) => { + try { + logger.info('兼容层处理获取已停止容器请求'); + const { exec } = require('child_process'); + const util = require('util'); + const execPromise = util.promisify(exec); + + const { stdout } = await execPromise('docker ps -f "status=exited" --format "{{.ID}}\\t{{.Names}}\\t{{.Status}}"'); + + const containers = stdout.trim().split('\n') + .filter(line => line.trim()) + .map(line => { + const [id, name, ...statusParts] = line.split('\t'); + return { + id: id.substring(0, 12), + name, + status: statusParts.join(' ') + }; + }); + + res.json(containers); + } catch (err) { + logger.error('获取已停止容器失败:', err); + res.status(500).json({ error: '获取已停止容器失败', details: err.message }); + } + }); + + // 系统状态接口 + app.get('/api/system-status', requireLogin, async (req, res) => { + try { + const systemRouter = require('./routes/system'); + return await systemRouter.getSystemStats(req, res); + } catch (error) { + logger.error('获取系统状态失败:', error); + res.status(500).json({ error: '获取系统状态失败', details: error.message }); + } + }); + + // Docker容器状态接口 + app.get('/api/docker-status', async (req, res) => { + try { + const dockerService = require('./services/dockerService'); + const containerStatus = await dockerService.getContainersStatus(); + res.json(containerStatus); + } catch (error) { + logger.error('获取Docker状态失败:', error); + res.status(500).json({ error: '获取Docker状态失败', details: error.message }); + } + }); + + // 单个容器状态接口 + app.get('/api/docker/status/:id', requireLogin, async (req, res) => { + try { + const dockerService = require('./services/dockerService'); + const containerInfo = await dockerService.getContainerStatus(req.params.id); + res.json(containerInfo); + } catch (error) { + logger.error('获取容器状态失败:', error); + res.status(500).json({ error: '获取容器状态失败', details: error.message }); + } + }); + + // 添加Docker容器操作API兼容层 - 解决404问题 + // 容器日志获取接口 + app.get('/api/docker/containers/:id/logs', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理获取容器日志请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + const logs = await dockerService.getContainerLogs(req.params.id); + res.send(logs); + } catch (error) { + logger.error(`获取容器日志失败:`, error); + res.status(500).json({ error: '获取容器日志失败', details: error.message }); + } + }); + + // 容器详情接口 + app.get('/api/docker/containers/:id', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理获取容器详情请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + const containerInfo = await dockerService.getContainerStatus(req.params.id); + res.json(containerInfo); + } catch (error) { + logger.error(`获取容器详情失败:`, error); + res.status(500).json({ error: '获取容器详情失败', details: error.message }); + } + }); + + // 启动容器接口 + app.post('/api/docker/containers/:id/start', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理启动容器请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + await dockerService.startContainer(req.params.id); + res.json({ success: true, message: '容器启动成功' }); + } catch (error) { + logger.error(`启动容器失败:`, error); + res.status(500).json({ error: '启动容器失败', details: error.message }); + } + }); + + // 停止容器接口 + app.post('/api/docker/containers/:id/stop', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理停止容器请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + await dockerService.stopContainer(req.params.id); + res.json({ success: true, message: '容器停止成功' }); + } catch (error) { + logger.error(`停止容器失败:`, error); + res.status(500).json({ error: '停止容器失败', details: error.message }); + } + }); + + // 重启容器接口 + app.post('/api/docker/containers/:id/restart', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理重启容器请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + await dockerService.restartContainer(req.params.id); + res.json({ success: true, message: '容器重启成功' }); + } catch (error) { + logger.error(`重启容器失败:`, error); + res.status(500).json({ error: '重启容器失败', details: error.message }); + } + }); + + // 更新容器接口 + app.post('/api/docker/containers/:id/update', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理更新容器请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + const { tag } = req.body; + await dockerService.updateContainer(req.params.id, tag); + res.json({ success: true, message: '容器更新成功' }); + } catch (error) { + logger.error(`更新容器失败:`, error); + res.status(500).json({ error: '更新容器失败', details: error.message }); + } + }); + + // 删除容器接口 + app.post('/api/docker/containers/:id/remove', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理删除容器请求: ${req.params.id}`); + const dockerService = require('./services/dockerService'); + await dockerService.deleteContainer(req.params.id); + res.json({ success: true, message: '容器删除成功' }); + } catch (error) { + logger.error(`删除容器失败:`, error); + res.status(500).json({ error: '删除容器失败', details: error.message }); + } + }); + + // 登录接口 (兼容层备份) + app.post('/api/login', async (req, res) => { + try { + const { username, password, captcha } = req.body; + + if (req.session.captcha !== parseInt(captcha)) { + logger.warn(`Captcha verification failed for user: ${username}`); + return res.status(401).json({ error: '验证码错误' }); + } + + const userService = require('./services/userService'); + const users = await userService.getUsers(); + const user = users.users.find(u => u.username === username); + + if (!user) { + logger.warn(`User ${username} not found`); + return res.status(401).json({ error: '用户名或密码错误' }); + } + + const bcrypt = require('bcrypt'); + if (bcrypt.compareSync(password, user.password)) { + req.session.user = { username: user.username }; + + // 更新用户登录信息 + await userService.updateUserLoginInfo(username); + + logger.info(`User ${username} logged in successfully`); + res.json({ success: true }); + } else { + logger.warn(`Login failed for user: ${username}`); + res.status(401).json({ error: '用户名或密码错误' }); + } + } catch (error) { + logger.error('登录失败:', error); + res.status(500).json({ error: '登录处理失败', details: error.message }); + } + }); + + // 修复搜索函数问题 - 完善错误处理 + app.get('/api/search', async (req, res) => { + try { + const dockerHubService = require('./services/dockerHubService'); + const term = req.query.term; + + if (!term) { + return res.status(400).json({ error: '搜索词不能为空' }); + } + + // 直接处理搜索,不依赖缓存 + try { + const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${req.query.page || 1}&page_size=25`; + const axios = require('axios'); + const response = await axios.get(url, { + timeout: 15000, + headers: { + 'User-Agent': 'DockerHubSearchClient/1.0', + 'Accept': 'application/json' + } + }); + + res.json(response.data); + } catch (searchError) { + logger.error('Docker Hub搜索请求失败:', searchError.message); + res.status(500).json({ + error: '搜索Docker Hub失败', + details: searchError.message, + retryable: true + }); + } + } catch (error) { + logger.error('搜索Docker Hub失败:', error); + res.status(500).json({ error: '搜索失败', details: error.message }); + } + }); + + // 获取磁盘空间信息的API + app.get('/api/disk-space', requireLogin, async (req, res) => { + try { + // 使用server-utils中的execCommand函数执行df命令 + const diskInfo = await execCommand('df -h | grep -E "/$|/home" | head -1'); + const diskParts = diskInfo.split(/\s+/); + + if (diskParts.length >= 5) { + res.json({ + diskSpace: `${diskParts[2]}/${diskParts[1]}`, // 已用/总量 + usagePercent: parseInt(diskParts[4].replace('%', '')) // 使用百分比 + }); + } else { + throw new Error('磁盘信息格式不正确'); + } + } catch (error) { + logger.error('获取磁盘空间信息失败:', error); + res.status(500).json({ + error: '获取磁盘空间信息失败', + details: error.message, + diskSpace: '未知', + usagePercent: 0 + }); + } + }); + + // 兼容config API + app.get('/api/config', async (req, res) => { + try { + logger.info('兼容层处理配置请求'); + const fs = require('fs').promises; + const path = require('path'); + + // 配置文件路径 + const configFilePath = path.join(__dirname, './data/config.json'); + + // 默认配置 + const DEFAULT_CONFIG = { + proxyDomain: 'registry-1.docker.io', + logo: '', + theme: 'light', + menuItems: [ + { + text: "首页", + link: "/", + newTab: false + }, + { + text: "文档", + link: "/docs", + newTab: false + } + ] + }; + + // 确保配置存在 + let config = DEFAULT_CONFIG; + + try { + await fs.access(configFilePath); + const data = await fs.readFile(configFilePath, 'utf8'); + config = JSON.parse(data); + } catch (err) { + // 如果文件不存在或解析失败,使用默认配置 + logger.warn('读取配置文件失败,将使用默认配置:', err); + // 尝试创建配置文件 + try { + await fs.mkdir(path.dirname(configFilePath), { recursive: true }); + await fs.writeFile(configFilePath, JSON.stringify(DEFAULT_CONFIG, null, 2)); + } catch (writeErr) { + logger.error('创建默认配置文件失败:', writeErr); + } + } + + res.json(config); + } catch (err) { + logger.error('获取配置失败:', err); + res.status(500).json({ error: '获取配置失败' }); + } + }); + + // 保存配置API + app.post('/api/config', async (req, res) => { + try { + logger.info('兼容层处理保存配置请求'); + const fs = require('fs').promises; + const path = require('path'); + + const newConfig = req.body; + + // 验证请求数据 + if (!newConfig || typeof newConfig !== 'object') { + return res.status(400).json({ + error: '无效的配置数据', + details: '配置必须是一个对象' + }); + } + + const configFilePath = path.join(__dirname, './data/config.json'); + + // 读取现有配置 + let existingConfig = {}; + try { + const data = await fs.readFile(configFilePath, 'utf8'); + existingConfig = JSON.parse(data); + } catch (err) { + // 文件不存在或解析失败时创建目录 + await fs.mkdir(path.dirname(configFilePath), { recursive: true }); + } + + // 合并配置 + const mergedConfig = { ...existingConfig, ...newConfig }; + + // 保存到文件 + await fs.writeFile(configFilePath, JSON.stringify(mergedConfig, null, 2)); + + res.json({ success: true, message: '配置已保存' }); + } catch (err) { + logger.error('保存配置失败:', err); + res.status(500).json({ + error: '保存配置失败', + details: err.message + }); + } + }); + + // 文档管理API - 获取文档列表 + app.get('/api/documents', requireLogin, async (req, res) => { + try { + logger.info('兼容层处理获取文档列表请求'); + const docService = require('./services/documentationService'); + const documents = await docService.getDocumentationList(); + res.json(documents); + } catch (err) { + logger.error('获取文档列表失败:', err); + res.status(500).json({ error: '获取文档列表失败', details: err.message }); + } + }); + + // 文档管理API - 获取单个文档 + app.get('/api/documents/:id', async (req, res) => { + try { + logger.info(`兼容层处理获取文档请求: ${req.params.id}`); + const docService = require('./services/documentationService'); + const document = await docService.getDocument(req.params.id); + + // 如果文档不是发布状态,只有已登录用户才能访问 + if (!document.published && !req.session.user) { + return res.status(403).json({ error: '没有权限访问该文档' }); + } + + res.json(document); + } catch (err) { + logger.error(`获取文档 ID:${req.params.id} 失败:`, err); + if (err.code === 'ENOENT') { + return res.status(404).json({ error: '文档不存在' }); + } + res.status(500).json({ error: '获取文档失败', details: err.message }); + } + }); + + // 文档管理API - 保存或更新文档 + app.put('/api/documents/:id', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理更新文档请求: ${req.params.id}`); + const { title, content, published } = req.body; + const docService = require('./services/documentationService'); + + // 检查必需参数 + if (!title) { + return res.status(400).json({ error: '文档标题不能为空' }); + } + + const docId = req.params.id; + await docService.saveDocument(docId, title, content || '', published); + + res.json({ success: true, id: docId, message: '文档已保存' }); + } catch (err) { + logger.error(`更新文档 ID:${req.params.id} 失败:`, err); + res.status(500).json({ error: '保存文档失败', details: err.message }); + } + }); + + // 文档管理API - 创建新文档 + app.post('/api/documents', requireLogin, async (req, res) => { + try { + logger.info('兼容层处理创建文档请求'); + const { title, content, published } = req.body; + const docService = require('./services/documentationService'); + + // 检查必需参数 + if (!title) { + return res.status(400).json({ error: '文档标题不能为空' }); + } + + // 创建新文档ID (使用时间戳) + const docId = Date.now().toString(); + await docService.saveDocument(docId, title, content || '', published); + + res.status(201).json({ success: true, id: docId, message: '文档已创建' }); + } catch (err) { + logger.error('创建文档失败:', err); + res.status(500).json({ error: '创建文档失败', details: err.message }); + } + }); + + // 文档管理API - 删除文档 + app.delete('/api/documents/:id', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理删除文档请求: ${req.params.id}`); + const docService = require('./services/documentationService'); + + await docService.deleteDocument(req.params.id); + res.json({ success: true, message: '文档已删除' }); + } catch (err) { + logger.error(`删除文档 ID:${req.params.id} 失败:`, err); + res.status(500).json({ error: '删除文档失败', details: err.message }); + } + }); + + // 文档管理API - 切换文档发布状态 + app.put('/api/documentation/toggle-publish/:id', requireLogin, async (req, res) => { + try { + logger.info(`兼容层处理切换文档发布状态请求: ${req.params.id}`); + const docService = require('./services/documentationService'); + + const result = await docService.toggleDocumentPublish(req.params.id); + res.json({ + success: true, + published: result.published, + message: `文档已${result.published ? '发布' : '取消发布'}` + }); + } catch (err) { + logger.error(`切换文档 ID:${req.params.id} 发布状态失败:`, err); + res.status(500).json({ error: '切换文档发布状态失败', details: err.message }); + } + }); + + // 网络测试接口 + app.post('/api/network-test', requireLogin, async (req, res) => { + const { type, domain } = req.body; + + // 验证输入 + if (!domain || !domain.match(/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) { + return res.status(400).json({ error: '无效的域名格式' }); + } + + if (!type || !['ping', 'traceroute'].includes(type)) { + return res.status(400).json({ error: '无效的测试类型' }); + } + + try { + const command = type === 'ping' + ? `ping -c 4 ${domain}` + : `traceroute -m 10 ${domain}`; + + logger.info(`执行网络测试: ${command}`); + const result = await execCommand(command, { timeout: 30000 }); + res.send(result); + } catch (error) { + logger.error(`执行网络测试命令错误:`, error); + + if (error.killed) { + return res.status(408).send('测试超时'); + } + + res.status(500).send('测试执行失败: ' + (error.message || '未知错误')); + } + }); + + // 用户信息接口 + app.get('/api/user-info', requireLogin, async (req, res) => { + try { + const userService = require('./services/userService'); + const userStats = await userService.getUserStats(req.session.user.username); + + res.json(userStats); + } catch (error) { + logger.error('获取用户信息失败:', error); + res.status(500).json({ error: '获取用户信息失败', details: error.message }); + } + }); + + // 修改密码接口 + app.post('/api/change-password', requireLogin, async (req, res) => { + const { currentPassword, newPassword } = req.body; + const username = req.session.user.username; + + if (!currentPassword || !newPassword) { + return res.status(400).json({ error: '当前密码和新密码不能为空' }); + } + + try { + const userService = require('./services/userService'); + await userService.changePassword(username, currentPassword, newPassword); + res.json({ success: true, message: '密码修改成功' }); + } catch (error) { + logger.error(`用户 ${username} 修改密码失败:`, error); + res.status(400).json({ error: error.message || '修改密码失败' }); // 返回具体的错误信息 + } + }); + + // 系统资源兼容路由 + app.get('/api/system-resources', requireLogin, async (req, res) => { + try { + const startTime = Date.now(); + logger.info('兼容层: 请求 /api/system-resources'); + + // 获取CPU信息 + const cpuCores = os.cpus().length; + const cpuModel = os.cpus()[0].model; + const cpuSpeed = os.cpus()[0].speed; + const loadAvg = os.loadavg(); + + // 获取内存信息 + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memoryPercent = ((usedMem / totalMem) * 100).toFixed(1) + '%'; + + // 获取磁盘信息 + let diskCommand = ''; + if (process.platform === 'win32') { + diskCommand = 'wmic logicaldisk get size,freespace,caption'; + } else { + // 在 macOS 和 Linux 上使用 df 命令 + diskCommand = 'df -h /'; + } + + try { + // 执行磁盘命令 + logger.debug(`执行磁盘命令: ${diskCommand}`); + const { stdout } = await execPromise(diskCommand, { timeout: 5000 }); + logger.debug(`磁盘命令输出: ${stdout}`); + + // 解析磁盘信息 + let disk = { size: "未知", used: "未知", available: "未知", percent: "未知" }; + + if (process.platform === 'win32') { + // Windows解析逻辑不变 + // ... (省略Windows解析代码) + } else { + // macOS/Linux格式解析 + const lines = stdout.trim().split('\n'); + if (lines.length >= 2) { + const headerParts = lines[0].trim().split(/\s+/); + const dataParts = lines[1].trim().split(/\s+/); + + logger.debug(`解析磁盘信息, 头部: ${headerParts}, 数据: ${dataParts}`); + + // 检查MacOS格式 (通常是Filesystem Size Used Avail Capacity iused ifree %iused Mounted on) + const isMacOS = headerParts.includes('Capacity') && headerParts.includes('iused'); + + if (isMacOS) { + // macOS格式处理 + const fsIndex = 0; // Filesystem + const sizeIndex = 1; // Size + const usedIndex = 2; // Used + const availIndex = 3; // Avail + const percentIndex = 4; // Capacity + const mountedIndex = headerParts.indexOf('Mounted') + 1; // Mounted on + + disk = { + filesystem: dataParts[fsIndex], + size: dataParts[sizeIndex], + used: dataParts[usedIndex], + available: dataParts[availIndex], + percent: dataParts[percentIndex], + mountedOn: dataParts[mountedIndex] || '/' + }; + } else { + // 标准Linux格式处理 (通常是Filesystem Size Used Avail Use% Mounted on) + const fsIndex = 0; // Filesystem + const sizeIndex = 1; // Size + const usedIndex = 2; // Used + const availIndex = 3; // Avail + const percentIndex = 4; // Use% + const mountedIndex = 5; // Mounted on + + disk = { + filesystem: dataParts[fsIndex], + size: dataParts[sizeIndex], + used: dataParts[usedIndex], + available: dataParts[availIndex], + percent: dataParts[percentIndex], + mountedOn: dataParts[mountedIndex] || '/' + }; + } + } + } + + // 构建最终结果 + const result = { + cpu: { + cores: cpuCores, + model: cpuModel, + speed: cpuSpeed, + loadAvg: loadAvg + }, + memory: { + total: totalMem, + free: freeMem, + used: usedMem, + percent: memoryPercent + }, + disk: disk, + uptime: os.uptime() + }; + + logger.debug(`系统资源API返回结果: ${JSON.stringify(result)}`); + + // 计算处理时间并返回结果 + const endTime = Date.now(); + logger.info(`兼容层: /api/system-resources 请求完成,耗时 ${endTime - startTime}ms`); + res.json(result); + } catch (diskError) { + // 磁盘信息获取失败时,仍然返回CPU和内存信息 + logger.error(`获取磁盘信息失败: ${diskError.message}`); + + const result = { + cpu: { + cores: cpuCores, + model: cpuModel, + speed: cpuSpeed, + loadAvg: loadAvg + }, + memory: { + total: totalMem, + free: freeMem, + used: usedMem, + percent: memoryPercent + }, + disk: { size: "未知", used: "未知", available: "未知", percent: "未知" }, + uptime: os.uptime(), + diskError: diskError.message + }; + + // 计算处理时间并返回结果(即使有错误) + const endTime = Date.now(); + logger.info(`兼容层: /api/system-resources 请求完成(但磁盘信息失败),耗时 ${endTime - startTime}ms`); + res.json(result); + } + } catch (error) { + logger.error(`系统资源API错误: ${error.message}`); + res.status(500).json({ error: '获取系统资源信息失败', message: error.message }); + } + }); + + // 登出接口 + app.post('/api/logout', (req, res) => { + if (req.session) { + req.session.destroy(err => { + if (err) { + logger.error('销毁会话失败:', err); + return res.status(500).json({ error: '退出登录失败' }); + } + // 清除客户端的 connect.sid cookie + res.clearCookie('connect.sid', { path: '/' }); // 确保路径与设置时一致 + logger.info('用户已成功登出'); + res.json({ success: true, message: '已成功登出' }); + }); + } else { + // 如果没有会话,也认为登出成功 + logger.info('用户已登出(无会话)'); + res.json({ success: true, message: '已成功登出' }); + } + }); + + logger.success('API兼容层加载完成'); +}; diff --git a/hubcmdui/config.js b/hubcmdui/config.js new file mode 100644 index 0000000..413db73 --- /dev/null +++ b/hubcmdui/config.js @@ -0,0 +1,54 @@ +/** + * 应用全局配置文件 + */ + +// 环境变量 +const ENV = process.env.NODE_ENV || 'development'; + +// 应用配置 +const config = { + // 通用配置 + common: { + port: process.env.PORT || 3000, + sessionSecret: process.env.SESSION_SECRET || 'OhTq3faqSKoxbV%NJV', + logLevel: process.env.LOG_LEVEL || 'info' + }, + + // 开发环境配置 + development: { + debug: true, + cors: { + origin: '*', + credentials: true + }, + secureSession: false + }, + + // 生产环境配置 + production: { + debug: false, + cors: { + origin: 'https://yourdomain.com', + credentials: true + }, + secureSession: true + }, + + // 测试环境配置 + test: { + debug: true, + cors: { + origin: '*', + credentials: true + }, + secureSession: false, + port: 3001 + } +}; + +// 导出合并后的配置 +module.exports = { + ...config.common, + ...config[ENV], + env: ENV +}; diff --git a/hubcmdui/config.json b/hubcmdui/config.json index d99c810..707ad66 100644 --- a/hubcmdui/config.json +++ b/hubcmdui/config.json @@ -1,39 +1,36 @@ { - "logo": "", + "theme": "light", + "language": "zh_CN", + "notifications": true, + "autoRefresh": true, + "refreshInterval": 30000, + "dockerHost": "localhost", + "dockerPort": 2375, + "useHttps": false, "menuItems": [ { - "text": "首页", - "link": "", + "text": "控制台", + "link": "/admin", + "newTab": false + }, + { + "text": "镜像搜索", + "link": "/", + "newTab": false + }, + { + "text": "文档", + "link": "/docs", "newTab": false }, { "text": "GitHub", - "link": "https://github.com/dqzboy/Docker-Proxy", - "newTab": true - }, - { - "text": "VPS推荐", - "link": "https://dqzboy.github.io/proxyui/racknerd", + "link": "https://github.com/dqzboy/hubcmdui", "newTab": true } ], - "adImages": [ - { - "url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/reacknerd-ad.png", - "link": "https://my.racknerd.com/aff.php?aff=12151" - }, - { - "url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/racknerd_vps.png", - "link": "https://my.racknerd.com/aff.php?aff=12151" - }, - { - "url": "https://cdn.jsdelivr.net/gh/dqzboy/Blog-Image/BlogCourse/docker-proxy-vip.png", - "link": "https://www.dqzboy.com/17834.html" - } - ], - "proxyDomain": "dqzboy.github.io", "monitoringConfig": { - "notificationType": "telegram", + "notificationType": "wechat", "webhookUrl": "", "telegramToken": "", "telegramChatId": "", diff --git a/hubcmdui/config/menu.json b/hubcmdui/config/menu.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/hubcmdui/config/menu.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/hubcmdui/config/monitoring.json b/hubcmdui/config/monitoring.json new file mode 100644 index 0000000..b786a13 --- /dev/null +++ b/hubcmdui/config/monitoring.json @@ -0,0 +1,8 @@ +{ + "isEnabled": false, + "notificationType": "wechat", + "webhookUrl": "", + "telegramToken": "", + "telegramChatId": "", + "monitorInterval": 60 +} \ No newline at end of file diff --git a/hubcmdui/data/config.json b/hubcmdui/data/config.json new file mode 100644 index 0000000..ae3cda1 --- /dev/null +++ b/hubcmdui/data/config.json @@ -0,0 +1,29 @@ +{ + "logo": "", + "menuItems": [ + { + "text": "首页", + "link": "", + "newTab": false + }, + { + "text": "GitHub", + "link": "https://github.com/dqzboy/Docker-Proxy", + "newTab": true + }, + { + "text": "VPS推荐", + "link": "https://dqzboy.github.io/proxyui/racknerd", + "newTab": true + } + ], + "monitoringConfig": { + "notificationType": "telegram", + "webhookUrl": "", + "telegramToken": "", + "telegramChatId": "", + "monitorInterval": 60, + "isEnabled": false + }, + "proxyDomain": "dqzboy.github.io" +} \ No newline at end of file diff --git a/hubcmdui/docker-compose.yaml b/hubcmdui/docker-compose.yaml index 7fbf6c2..2c6b862 100644 --- a/hubcmdui/docker-compose.yaml +++ b/hubcmdui/docker-compose.yaml @@ -7,4 +7,14 @@ services: volumes: - /var/run/docker.sock:/var/run/docker.sock ports: - - 30080:3000 \ No newline at end of file + - 30080:3000 + environment: + # 日志配置 + - LOG_LEVEL=INFO # 可选: TRACE, DEBUG, INFO, SUCCESS, WARN, ERROR, FATAL + - SIMPLE_LOGS=true # 启用简化日志输出,减少冗余信息 + # - DETAILED_LOGS=false # 默认关闭详细日志记录(请求体、查询参数等) + # - SHOW_STACK=false # 默认关闭错误堆栈跟踪 + # - LOG_FILE_ENABLED=true # 是否启用文件日志,默认启用 + # - LOG_CONSOLE_ENABLED=true # 是否启用控制台日志,默认启用 + # - LOG_MAX_SIZE=10 # 单个日志文件最大大小(MB),默认10MB + # - LOG_MAX_FILES=14 # 保留的日志文件数量,默认14个 \ No newline at end of file diff --git a/hubcmdui/documentation/.DS_Store b/hubcmdui/documentation/.DS_Store new file mode 100644 index 0000000..2b64041 Binary files /dev/null and b/hubcmdui/documentation/.DS_Store differ diff --git a/hubcmdui/documentation/1724594777670.json b/hubcmdui/documentation/1724594777670.json deleted file mode 100644 index d971479..0000000 --- a/hubcmdui/documentation/1724594777670.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Docker 配置镜像加速","content":"### Docker 配置镜像加速\n- 修改文件 `/etc/docker/daemon.json`(如果不存在则创建)\n\n```shell\nsudo mkdir -p /etc/docker\nsudo vi /etc/docker/daemon.json\n{\n \"registry-mirrors\": [\"https://<代理加速地址>\"]\n}\n\nsudo systemctl daemon-reload\nsudo systemctl restart docker\n```","published":true} \ No newline at end of file diff --git a/hubcmdui/documentation/1737713570870.json b/hubcmdui/documentation/1737713570870.json deleted file mode 100644 index 559bab4..0000000 --- a/hubcmdui/documentation/1737713570870.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Containerd 配置镜像加速","content":"### Containerd 配置镜像加速\n- `/etc/containerd/config.toml`,添加如下的配置:\n\n```yaml\n [plugins.\"io.containerd.grpc.v1.cri\".registry]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"docker.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"k8s.gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"ghcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"quay.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n```","published":true} \ No newline at end of file diff --git a/hubcmdui/documentation/1737713707391.json b/hubcmdui/documentation/1737713707391.json deleted file mode 100644 index 5d9ce6a..0000000 --- a/hubcmdui/documentation/1737713707391.json +++ /dev/null @@ -1 +0,0 @@ -{"title":"Podman 配置镜像加速","content":"### Podman 配置镜像加速\n- 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```yaml\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```","published":true} \ No newline at end of file diff --git a/hubcmdui/documentation/1743542841590.json b/hubcmdui/documentation/1743542841590.json new file mode 100644 index 0000000..cf5d7bb --- /dev/null +++ b/hubcmdui/documentation/1743542841590.json @@ -0,0 +1,7 @@ +{ + "title": "Docker 配置镜像加速", + "content": "# Docker 配置镜像加速\n\n- 修改文件 `/etc/docker/daemon.json`(如果不存在则创建)\n\n```\nsudo mkdir -p /etc/docker\nsudo vi /etc/docker/daemon.json\n{\n \"registry-mirrors\": [\"https://<代理加速地址>\"]\n}\n\nsudo systemctl daemon-reload\nsudo systemctl restart docker\n```", + "published": true, + "createdAt": "2025-04-01T21:27:21.591Z", + "updatedAt": "2025-04-01T21:35:20.004Z" +} \ No newline at end of file diff --git a/hubcmdui/documentation/1743543376091.json b/hubcmdui/documentation/1743543376091.json new file mode 100644 index 0000000..1318017 --- /dev/null +++ b/hubcmdui/documentation/1743543376091.json @@ -0,0 +1,7 @@ +{ + "title": "Containerd 配置镜像加速", + "content": "# Containerd 配置镜像加速\n\n\n* `/etc/containerd/config.toml`,添加如下的配置:\n\n```bash\n [plugins.\"io.containerd.grpc.v1.cri\".registry]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"docker.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"k8s.gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"gcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"ghcr.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n [plugins.\"io.containerd.grpc.v1.cri\".registry.mirrors.\"quay.io\"]\n endpoint = [\"https://<代理加速地址>\"]\n```", + "published": true, + "createdAt": "2025-04-01T21:36:16.092Z", + "updatedAt": "2025-04-01T21:36:18.103Z" +} \ No newline at end of file diff --git a/hubcmdui/documentation/1743543400369.json b/hubcmdui/documentation/1743543400369.json new file mode 100644 index 0000000..b049198 --- /dev/null +++ b/hubcmdui/documentation/1743543400369.json @@ -0,0 +1,7 @@ +{ + "title": "Podman 配置镜像加速", + "content": "# Podman 配置镜像加速\n\n* 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```bash\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```# Podman 配置镜像加速\n\n* 修改配置文件 `/etc/containers/registries.conf`,添加配置:\n\n```bash\nunqualified-search-registries = ['docker.io', 'k8s.gcr.io', 'gcr.io', 'ghcr.io', 'quay.io']\n\n[[registry]]\nprefix = \"docker.io\"\ninsecure = true\nlocation = \"registry-1.docker.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"k8s.gcr.io\"\ninsecure = true\nlocation = \"k8s.gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"gcr.io\"\ninsecure = true\nlocation = \"gcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"ghcr.io\"\ninsecure = true\nlocation = \"ghcr.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n\n[[registry]]\nprefix = \"quay.io\"\ninsecure = true\nlocation = \"quay.io\"\n\n[[registry.mirror]]\nlocation = \"https://<代理加速地址>\"\n```", + "published": true, + "createdAt": "2025-04-01T21:36:40.369Z", + "updatedAt": "2025-04-01T21:36:41.977Z" +} \ No newline at end of file diff --git a/hubcmdui/download-images.js b/hubcmdui/download-images.js new file mode 100644 index 0000000..b71445e --- /dev/null +++ b/hubcmdui/download-images.js @@ -0,0 +1,83 @@ +/** + * 下载必要的图片资源 + */ +const fs = require('fs').promises; +const path = require('path'); +const https = require('https'); +const logger = require('./logger'); +const { ensureDirectoriesExist } = require('./init-dirs'); + +// 背景图片URL +const LOGIN_BG_URL = 'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?q=80&w=1470&auto=format&fit=crop'; + +// 下载图片函数 +function downloadImage(url, dest) { + return new Promise((resolve, reject) => { + const file = fs.createWriteStream(dest); + + https.get(url, response => { + if (response.statusCode !== 200) { + reject(new Error(`Failed to download image. Status code: ${response.statusCode}`)); + return; + } + + response.pipe(file); + + file.on('finish', () => { + file.close(); + logger.success(`Image downloaded to: ${dest}`); + resolve(); + }); + + file.on('error', err => { + fs.unlink(dest).catch(() => {}); // 删除文件(如果存在) + reject(err); + }); + }).on('error', err => { + fs.unlink(dest).catch(() => {}); // 删除文件(如果存在) + reject(err); + }); + }); +} + +// 主函数 +async function downloadImages() { + try { + // 确保目录存在 + await ensureDirectoriesExist(); + + // 下载登录背景图片 + const loginBgPath = path.join(__dirname, 'web', 'images', 'login-bg.jpg'); + try { + await fs.access(loginBgPath); + logger.info('Login background image already exists, skipping download'); + } catch (error) { + if (error.code === 'ENOENT') { + logger.info('Downloading login background image...'); + try { + // 确保images目录存在 + await fs.mkdir(path.dirname(loginBgPath), { recursive: true }); + await downloadImage(LOGIN_BG_URL, loginBgPath); + } catch (downloadError) { + logger.error(`Download error: ${downloadError.message}`); + // 下载失败时使用备用解决方案 + await fs.writeFile(loginBgPath, 'Failed to download', 'utf8'); + logger.warn('Created placeholder image file'); + } + } else { + throw error; + } + } + + logger.success('All images downloaded successfully'); + } catch (error) { + logger.error('Error downloading images:', error); + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + downloadImages(); +} + +module.exports = { downloadImages }; diff --git a/hubcmdui/init-dirs.js b/hubcmdui/init-dirs.js new file mode 100644 index 0000000..c784b34 --- /dev/null +++ b/hubcmdui/init-dirs.js @@ -0,0 +1,86 @@ +/** + * 目录初始化模块 - 确保应用需要的所有目录都存在 + */ +const fs = require('fs').promises; +const path = require('path'); +const logger = require('./logger'); + +/** + * 确保所有必需的目录存在 + */ +// 添加缓存机制 +const checkedDirs = new Set(); + +async function ensureDirectoriesExist() { + const dirs = [ + // 文档目录 + path.join(__dirname, 'documentation'), + // 日志目录 + path.join(__dirname, 'logs'), + // 图片目录 + path.join(__dirname, 'web', 'images'), + // 数据目录 + path.join(__dirname, 'data'), + // 配置目录 + path.join(__dirname, 'config'), + // 临时文件目录 + path.join(__dirname, 'temp'), + // session 目录 + path.join(__dirname, 'data', 'sessions'), + // 文档数据目录 + path.join(__dirname, 'web', 'data', 'documentation') + ]; + + for (const dir of dirs) { + if (checkedDirs.has(dir)) continue; + + try { + await fs.access(dir); + logger.info(`目录已存在: ${dir}`); + } catch (error) { + if (error.code === 'ENOENT') { + try { + await fs.mkdir(dir, { recursive: true }); + logger.success(`创建目录: ${dir}`); + } catch (mkdirError) { + logger.error(`创建目录 ${dir} 失败: ${mkdirError.message}`); + throw mkdirError; + } + } else { + logger.error(`检查目录 ${dir} 失败: ${error.message}`); + throw error; + } + } + + checkedDirs.add(dir); + } + + // 确保文档索引存在,但不再添加默认文档 + const docIndexPath = path.join(__dirname, 'web', 'data', 'documentation', 'index.json'); + try { + await fs.access(docIndexPath); + logger.info('文档索引已存在'); + } catch (error) { + if (error.code === 'ENOENT') { + try { + // 创建一个空的文档索引 + await fs.writeFile(docIndexPath, JSON.stringify([]), 'utf8'); + logger.success('创建了空的文档索引文件'); + } catch (writeError) { + logger.error(`创建文档索引失败: ${writeError.message}`); + } + } + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + ensureDirectoriesExist() + .then(() => logger.info('目录初始化完成')) + .catch(err => { + logger.error('目录初始化失败:', err); + process.exit(1); + }); +} + +module.exports = { ensureDirectoriesExist }; diff --git a/hubcmdui/logger.js b/hubcmdui/logger.js index e91973c..239b03f 100644 --- a/hubcmdui/logger.js +++ b/hubcmdui/logger.js @@ -1,216 +1,374 @@ -const fs = require('fs').promises; +const fs = require('fs'); +const fsPromises = fs.promises; const path = require('path'); const util = require('util'); +const os = require('os'); -// 日志级别配置 +// 日志级别定义 const LOG_LEVELS = { - debug: 0, - info: 1, - success: 2, - warn: 3, - error: 4, - fatal: 5 + TRACE: { priority: 0, color: 'grey', prefix: 'TRACE' }, + DEBUG: { priority: 1, color: 'blue', prefix: 'DEBUG' }, + INFO: { priority: 2, color: 'green', prefix: 'INFO' }, + SUCCESS: { priority: 3, color: 'greenBright', prefix: 'SUCCESS' }, + WARN: { priority: 4, color: 'yellow', prefix: 'WARN' }, + ERROR: { priority: 5, color: 'red', prefix: 'ERROR' }, + FATAL: { priority: 6, color: 'redBright', prefix: 'FATAL' } }; -// 默认配置 -const config = { - level: process.env.LOG_LEVEL || 'info', - logToFile: process.env.LOG_TO_FILE === 'true', - logDirectory: path.join(__dirname, 'logs'), - logFileName: 'app.log', - maxLogSize: 10 * 1024 * 1024, // 10MB - colorize: process.env.NODE_ENV !== 'production' +// 彩色日志实现 +const colors = { + grey: text => `\x1b[90m${text}\x1b[0m`, + blue: text => `\x1b[34m${text}\x1b[0m`, + green: text => `\x1b[32m${text}\x1b[0m`, + greenBright: text => `\x1b[92m${text}\x1b[0m`, + yellow: text => `\x1b[33m${text}\x1b[0m`, + red: text => `\x1b[31m${text}\x1b[0m`, + redBright: text => `\x1b[91m${text}\x1b[0m` }; +// 日志配置 +const LOG_CONFIG = { + // 默认日志级别 + level: process.env.LOG_LEVEL || 'INFO', + // 日志文件配置 + file: { + enabled: true, + dir: path.join(__dirname, 'logs'), + nameFormat: 'app-%DATE%.log', + maxSize: 10 * 1024 * 1024, // 10MB + maxFiles: 14, // 保留14天的日志 + }, + // 控制台输出配置 + console: { + enabled: true, + colorize: true, + // 简化输出在控制台 + simplified: process.env.NODE_ENV === 'production' || process.env.SIMPLE_LOGS === 'true' + }, + // 是否打印请求体、查询参数等详细信息(默认关闭) + includeDetails: process.env.NODE_ENV === 'development' || process.env.DETAILED_LOGS === 'true', + // 是否显示堆栈跟踪(默认关闭) + includeStack: process.env.NODE_ENV === 'development' || process.env.SHOW_STACK === 'true' +}; + +// 根据环境变量初始化配置 +function initConfig() { + // 检查环境变量并更新配置 + if (process.env.LOG_FILE_ENABLED === 'false') { + LOG_CONFIG.file.enabled = false; + } + + if (process.env.LOG_CONSOLE_ENABLED === 'false') { + LOG_CONFIG.console.enabled = false; + } + + if (process.env.LOG_MAX_SIZE) { + LOG_CONFIG.file.maxSize = parseInt(process.env.LOG_MAX_SIZE) * 1024 * 1024; + } + + if (process.env.LOG_MAX_FILES) { + LOG_CONFIG.file.maxFiles = parseInt(process.env.LOG_MAX_FILES); + } + + if (process.env.DETAILED_LOGS === 'true') { + LOG_CONFIG.includeDetails = true; + } else if (process.env.DETAILED_LOGS === 'false') { + LOG_CONFIG.includeDetails = false; + } + + if (process.env.SIMPLE_LOGS === 'true') { + LOG_CONFIG.console.simplified = true; + } else if (process.env.SIMPLE_LOGS === 'false') { + LOG_CONFIG.console.simplified = false; + } + + // 验证日志级别是否有效 + if (!LOG_LEVELS[LOG_CONFIG.level]) { + console.warn(`无效的日志级别: ${LOG_CONFIG.level},将使用默认级别: INFO`); + LOG_CONFIG.level = 'INFO'; + } +} + +// 初始化配置 +initConfig(); + // 确保日志目录存在 -async function ensureLogDirectory() { - if (config.logToFile) { - try { - await fs.access(config.logDirectory); - } catch (error) { - if (error.code === 'ENOENT') { - await fs.mkdir(config.logDirectory, { recursive: true }); - console.log(`Created log directory: ${config.logDirectory}`); - } else { - throw error; - } - } +async function ensureLogDir() { + if (!LOG_CONFIG.file.enabled) return; + + try { + await fsPromises.access(LOG_CONFIG.file.dir); + } catch (error) { + if (error.code === 'ENOENT') { + await fsPromises.mkdir(LOG_CONFIG.file.dir, { recursive: true }); + } else { + console.error('无法创建日志目录:', error); } + } } -// 格式化时间 - 改进为更易读的格式 -function getTimestamp() { - const now = new Date(); - const year = now.getFullYear(); - const month = String(now.getMonth() + 1).padStart(2, '0'); - const day = String(now.getDate()).padStart(2, '0'); - const hours = String(now.getHours()).padStart(2, '0'); - const minutes = String(now.getMinutes()).padStart(2, '0'); - const seconds = String(now.getSeconds()).padStart(2, '0'); - const milliseconds = String(now.getMilliseconds()).padStart(3, '0'); - - return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}.${milliseconds}`; +// 生成当前日志文件名 +function getCurrentLogFile() { + const today = new Date().toISOString().split('T')[0]; + return path.join(LOG_CONFIG.file.dir, LOG_CONFIG.file.nameFormat.replace(/%DATE%/g, today)); } -// 颜色代码 -const COLORS = { - reset: '\x1b[0m', - bright: '\x1b[1m', - dim: '\x1b[2m', - underscore: '\x1b[4m', - blink: '\x1b[5m', - reverse: '\x1b[7m', - hidden: '\x1b[8m', - - // 前景色 - black: '\x1b[30m', - red: '\x1b[31m', - green: '\x1b[32m', - yellow: '\x1b[33m', - blue: '\x1b[34m', - magenta: '\x1b[35m', - cyan: '\x1b[36m', - white: '\x1b[37m', - - // 背景色 - bgBlack: '\x1b[40m', - bgRed: '\x1b[41m', - bgGreen: '\x1b[42m', - bgYellow: '\x1b[43m', - bgBlue: '\x1b[44m', - bgMagenta: '\x1b[45m', - bgCyan: '\x1b[46m', - bgWhite: '\x1b[47m' -}; - -// 日志级别对应的颜色和标签 -const LEVEL_STYLES = { - debug: { color: COLORS.cyan, label: 'DEBUG' }, - info: { color: COLORS.blue, label: 'INFO ' }, - success: { color: COLORS.green, label: 'DONE ' }, - warn: { color: COLORS.yellow, label: 'WARN ' }, - error: { color: COLORS.red, label: 'ERROR' }, - fatal: { color: COLORS.bright + COLORS.red, label: 'FATAL' } -}; - -// 创建日志条目 - 改进格式 -function createLogEntry(level, message, meta = {}) { - const timestamp = getTimestamp(); - const levelInfo = LEVEL_STYLES[level] || { label: level.toUpperCase() }; - - // 元数据格式化 - 更简洁的呈现方式 - let metaOutput = ''; - if (meta instanceof Error) { - metaOutput = `\n ${COLORS.red}Error: ${meta.message}${COLORS.reset}`; - if (meta.stack) { - metaOutput += `\n ${COLORS.dim}Stack: ${meta.stack.split('\n').join('\n ')}${COLORS.reset}`; - } - } else if (Object.keys(meta).length > 0) { - // 检查是否为HTTP请求信息,如果是则使用更简洁的格式 - if (meta.ip && meta.userAgent) { - metaOutput = ` ${COLORS.dim}from ${meta.ip}${COLORS.reset}`; - } else { - // 对于其他元数据,仍然使用检查器但格式更友好 - metaOutput = `\n ${util.inspect(meta, { colors: true, depth: 3 })}`; - } +// 检查是否需要轮转日志 +async function checkRotation() { + if (!LOG_CONFIG.file.enabled) return false; + + const currentLogFile = getCurrentLogFile(); + try { + const stats = await fsPromises.stat(currentLogFile); + if (stats.size >= LOG_CONFIG.file.maxSize) { + return true; } - - // 为控制台格式化日志 - const consoleOutput = config.colorize ? - `${COLORS.dim}${timestamp}${COLORS.reset} [${levelInfo.color}${levelInfo.label}${COLORS.reset}] ${message}${metaOutput}` : - `${timestamp} [${levelInfo.label}] ${message}${metaOutput ? ' ' + metaOutput.trim() : ''}`; - - // 为文件准备JSON格式日志 - const logObject = { - timestamp, - level: level, - message - }; - - if (Object.keys(meta).length > 0) { - logObject.meta = meta instanceof Error ? - { name: meta.name, message: meta.message, stack: meta.stack } : - meta; + } catch (err) { + // 文件不存在,不需要轮转 + if (err.code !== 'ENOENT') { + console.error('检查日志文件大小失败:', err); } - - return { - formatted: consoleOutput, - json: JSON.stringify(logObject) - }; + } + return false; } -// 日志函数 -async function log(level, message, meta = {}) { - if (LOG_LEVELS[level] < LOG_LEVELS[config.level]) { - return; - } - - const { formatted, json } = createLogEntry(level, message, meta); - - // 控制台输出 - console.log(formatted); - - // 文件日志 - if (config.logToFile) { - try { - await ensureLogDirectory(); - const logFilePath = path.join(config.logDirectory, config.logFileName); - await fs.appendFile(logFilePath, json + '\n', 'utf8'); - } catch (err) { - console.error(`${COLORS.red}Error writing to log file: ${err.message}${COLORS.reset}`); - } +// 轮转日志文件 +async function rotateLogFile() { + if (!LOG_CONFIG.file.enabled) return; + + const currentLogFile = getCurrentLogFile(); + try { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + const rotatedFile = `${currentLogFile}.${timestamp}`; + + try { + // 检查文件是否存在 + await fsPromises.access(currentLogFile); + // 重命名文件 + await fsPromises.rename(currentLogFile, rotatedFile); + + // 清理旧日志文件 + await cleanupOldLogFiles(); + } catch (err) { + // 如果文件不存在,则忽略 + if (err.code !== 'ENOENT') { + console.error('轮转日志文件失败:', err); + } } + } catch (err) { + console.error('轮转日志文件失败:', err); + } } -// 日志API -const logger = { - debug: (message, meta = {}) => log('debug', message, meta), - info: (message, meta = {}) => log('info', message, meta), - success: (message, meta = {}) => log('success', message, meta), - warn: (message, meta = {}) => log('warn', message, meta), - error: (message, meta = {}) => log('error', message, meta), - fatal: (message, meta = {}) => log('fatal', message, meta), +// 清理旧日志文件 +async function cleanupOldLogFiles() { + if (!LOG_CONFIG.file.enabled || LOG_CONFIG.file.maxFiles <= 0) return; + + try { + const files = await fsPromises.readdir(LOG_CONFIG.file.dir); + const logFilePattern = LOG_CONFIG.file.nameFormat.replace(/%DATE%/g, '\\d{4}-\\d{2}-\\d{2}'); + const logFileRegex = new RegExp(`^${logFilePattern}(\\.[\\d-T]+)?$`); - // 配置方法 - configure: (options) => { - Object.assign(config, options); - }, + const logFiles = files + .filter(file => logFileRegex.test(file)) + .map(file => ({ + name: file, + path: path.join(LOG_CONFIG.file.dir, file), + time: fs.statSync(path.join(LOG_CONFIG.file.dir, file)).mtime.getTime() + })) + .sort((a, b) => b.time - a.time); // 按修改时间降序排序 - // HTTP请求日志方法 - 简化输出格式 - request: (req, res, duration) => { - const status = res.statusCode; - const method = req.method; - const url = req.originalUrl || req.url; - const userAgent = req.headers['user-agent'] || '-'; - const ip = req.ip || req.connection.remoteAddress || '-'; - - let level = 'info'; - if (status >= 500) level = 'error'; - else if (status >= 400) level = 'warn'; - - // 为HTTP请求创建更简洁的日志消息 - let statusIndicator = ''; - if (config.colorize) { - if (status >= 500) statusIndicator = COLORS.red; - else if (status >= 400) statusIndicator = COLORS.yellow; - else if (status >= 300) statusIndicator = COLORS.cyan; - else if (status >= 200) statusIndicator = COLORS.green; - statusIndicator += status + COLORS.reset; - } else { - statusIndicator = status; - } - - // 简化的请求日志格式 - const message = `${method} ${url} ${statusIndicator} ${duration}ms`; - - // 传递ip和userAgent作为元数据,但以简洁方式显示 - log(level, message, { ip, userAgent }); + // 保留最新的maxFiles个文件,删除其余的 + const filesToDelete = logFiles.slice(LOG_CONFIG.file.maxFiles); + for (const file of filesToDelete) { + try { + await fsPromises.unlink(file.path); + } catch (err) { + console.error(`删除旧日志文件 ${file.path} 失败:`, err); + } } -}; + } catch (err) { + console.error('清理旧日志文件失败:', err); + } +} -// 初始化 -ensureLogDirectory().catch(err => { - console.error(`${COLORS.red}Failed to initialize logger: ${err.message}${COLORS.reset}`); -}); +// 写入日志文件 +async function writeToLogFile(message) { + if (!LOG_CONFIG.file.enabled) return; + + try { + await ensureLogDir(); + + // 检查是否需要轮转日志 + if (await checkRotation()) { + await rotateLogFile(); + } + + const currentLogFile = getCurrentLogFile(); + const logEntry = `${message}\n`; + await fsPromises.appendFile(currentLogFile, logEntry); + } catch (error) { + console.error('写入日志文件失败:', error); + } +} -module.exports = logger; \ No newline at end of file +// 格式化日志消息 +function formatLogMessage(level, message, details) { + const timestamp = new Date().toISOString(); + const prefix = `[${level.prefix}]`; + + // 简化标准日志格式:时间戳 [日志级别] 消息 + const standardMessage = `${timestamp} ${prefix} ${message}`; + + let detailsStr = ''; + + if (details) { + if (details instanceof Error) { + detailsStr = ` ${details.message}`; + if (LOG_CONFIG.includeStack && details.stack) { + detailsStr += `\n${details.stack}`; + } + } else if (typeof details === 'object') { + try { + // 只输出关键字段 + const filteredDetails = { ...details }; + // 移除大型或不重要的字段 + ['stack', 'userAgent', 'referer'].forEach(key => { + if (key in filteredDetails) delete filteredDetails[key]; + }); + + // 使用紧凑格式输出JSON + detailsStr = Object.keys(filteredDetails).length > 0 + ? ` ${JSON.stringify(filteredDetails)}` + : ''; + } catch (e) { + detailsStr = ` ${util.inspect(details, { depth: 1, colors: false, compact: true })}`; + } + } else { + detailsStr = ` ${details}`; + } + } + + return { + console: LOG_CONFIG.console.colorize + ? `${timestamp} ${colors[level.color](prefix)} ${message}${detailsStr}` + : `${timestamp} ${prefix} ${message}${detailsStr}`, + file: `${standardMessage}${detailsStr}` + }; +} + +// 检查当前日志级别是否应该记录指定级别的日志 +function shouldLog(levelName) { + const configLevel = LOG_LEVELS[LOG_CONFIG.level]; + const messageLevel = LOG_LEVELS[levelName]; + + if (!configLevel || !messageLevel) { + return true; // 默认允许记录 + } + + return messageLevel.priority >= configLevel.priority; +} + +// 记录日志的通用函数 +function log(level, message, details) { + if (!LOG_LEVELS[level]) { + level = 'INFO'; + } + + // 检查是否应该记录该级别的日志 + if (!shouldLog(level)) { + return; + } + + const formattedMessage = formatLogMessage(LOG_LEVELS[level], message, details); + + // 控制台输出 + if (LOG_CONFIG.console.enabled) { + console.log(formattedMessage.console); + } + + // 写入文件 + if (LOG_CONFIG.file.enabled) { + writeToLogFile(formattedMessage.file); + } +} + +// 请求日志函数 +function request(req, res, duration) { + const method = req.method; + const url = req.originalUrl || req.url; + const status = res.statusCode; + const ip = req.ip ? req.ip.replace(/::ffff:/, '') : 'unknown'; + + // 根据状态码确定日志级别 + let level = 'INFO'; + if (status >= 400 && status < 500) level = 'WARN'; + if (status >= 500) level = 'ERROR'; + + // 简化日志消息格式 + const logMessage = `${method} ${url} ${status} ${duration}ms`; + + // 只有在需要时才收集详细信息 + let details = null; + + // 如果请求标记为跳过详细日志或不是开发环境,则不记录详细信息 + if (!req.skipDetailedLogging && LOG_CONFIG.includeDetails) { + // 记录最少的必要信息 + details = {}; + + // 只在错误状态码时记录更多信息 + if (status >= 400) { + // 安全地记录请求参数,过滤敏感信息 + const sanitizedBody = req.sanitizedBody || req.body; + if (sanitizedBody && Object.keys(sanitizedBody).length > 0) { + // 屏蔽敏感字段 + const filtered = { ...sanitizedBody }; + ['password', 'token', 'apiKey', 'secret', 'credentials'].forEach(key => { + if (key in filtered) filtered[key] = '******'; + }); + details.body = filtered; + } + + if (req.params && Object.keys(req.params).length > 0) { + details.params = req.params; + } + + if (req.query && Object.keys(req.query).length > 0) { + details.query = req.query; + } + } + + // 如果details为空对象,则设为null + if (Object.keys(details).length === 0) { + details = null; + } + } + + log(level, logMessage, details); +} + +// 设置日志级别 +function setLogLevel(level) { + if (LOG_LEVELS[level]) { + LOG_CONFIG.level = level; + log('INFO', `日志级别已设置为 ${level}`); + return true; + } + log('WARN', `尝试设置无效的日志级别: ${level}`); + return false; +} + +// 公开各类日志记录函数 +module.exports = { + trace: (message, details) => log('TRACE', message, details), + debug: (message, details) => log('DEBUG', message, details), + info: (message, details) => log('INFO', message, details), + success: (message, details) => log('SUCCESS', message, details), + warn: (message, details) => log('WARN', message, details), + error: (message, details) => log('ERROR', message, details), + fatal: (message, details) => log('FATAL', message, details), + request, + setLogLevel, + LOG_LEVELS: Object.keys(LOG_LEVELS), + config: LOG_CONFIG +}; \ No newline at end of file diff --git a/hubcmdui/middleware/auth.js b/hubcmdui/middleware/auth.js new file mode 100644 index 0000000..a40ecc7 --- /dev/null +++ b/hubcmdui/middleware/auth.js @@ -0,0 +1,90 @@ +/** + * 认证相关中间件 + */ +const logger = require('../logger'); + +/** + * 检查是否已登录的中间件 + */ +function requireLogin(req, res, next) { + // 放开session检查,不强制要求登录 + if (req.url.startsWith('/api/documentation') || + req.url.startsWith('/api/system-resources') || + req.url.startsWith('/api/monitoring-config') || + req.url.startsWith('/api/toggle-monitoring') || + req.url.startsWith('/api/test-notification') || + req.url.includes('/docker/status')) { + return next(); // 这些API路径不需要登录 + } + + // 检查用户是否登录 + if (req.session && req.session.user) { + // 刷新会话 + req.session.touch(); + return next(); + } + + // 未登录返回401错误 + res.status(401).json({ error: '未登录或会话已过期', code: 'SESSION_EXPIRED' }); +} + +// 修改登录逻辑 +async function login(req, res) { + try { + const { username, password } = req.body; + + // 简单验证 + if (username === 'admin' && password === 'admin123') { + req.session.user = { username }; + return res.json({ success: true }); + } + + res.status(401).json({ error: '用户名或密码错误' }); + } catch (error) { + logger.error('登录失败:', error); + res.status(500).json({ error: '登录失败' }); + } +} + +/** + * 记录会话活动的中间件 + */ +function sessionActivity(req, res, next) { + if (req.session && req.session.user) { + req.session.lastActivity = Date.now(); + req.session.touch(); // 确保会话刷新 + } + next(); +} + +// 过滤敏感信息中间件 +function sanitizeRequestBody(req, res, next) { + if (req.body) { + const sanitizedBody = {...req.body}; + + // 过滤敏感字段 + if (sanitizedBody.password) sanitizedBody.password = '[REDACTED]'; + if (sanitizedBody.currentPassword) sanitizedBody.currentPassword = '[REDACTED]'; + if (sanitizedBody.newPassword) sanitizedBody.newPassword = '[REDACTED]'; + + // 保存清理后的请求体供日志使用 + req.sanitizedBody = sanitizedBody; + } + next(); +} + +// 安全头部中间件 +function securityHeaders(req, res, next) { + // 添加安全头部 + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('X-Frame-Options', 'DENY'); + res.setHeader('X-XSS-Protection', '1; mode=block'); + next(); +} + +module.exports = { + requireLogin, + sessionActivity, + sanitizeRequestBody, + securityHeaders +}; diff --git a/hubcmdui/middleware/client-error.js b/hubcmdui/middleware/client-error.js new file mode 100644 index 0000000..59b2625 --- /dev/null +++ b/hubcmdui/middleware/client-error.js @@ -0,0 +1,26 @@ +/** + * 客户端错误处理中间件 + */ +const logger = require('../logger'); + +// 处理客户端上报的错误 +function handleClientError(req, res, next) { + if (req.url === '/api/client-error' && req.method === 'POST') { + const { message, source, lineno, colno, error, stack, userAgent, page } = req.body; + + logger.error('客户端错误:', { + message, + source, + location: `${lineno}:${colno}`, + stack: stack || (error && error.stack), + userAgent, + page + }); + + res.json({ success: true }); + } else { + next(); + } +} + +module.exports = handleClientError; diff --git a/hubcmdui/models/MenuItem.js b/hubcmdui/models/MenuItem.js new file mode 100644 index 0000000..1b14e86 --- /dev/null +++ b/hubcmdui/models/MenuItem.js @@ -0,0 +1,13 @@ +const mongoose = require('mongoose'); + +const menuItemSchema = new mongoose.Schema({ + text: { type: String, required: true }, + link: { type: String, required: true }, + icon: String, + newTab: { type: Boolean, default: false }, + enabled: { type: Boolean, default: true }, + order: { type: Number, default: 0 }, + createdAt: { type: Date, default: Date.now } +}); + +module.exports = mongoose.model('MenuItem', menuItemSchema); \ No newline at end of file diff --git a/hubcmdui/package.json b/hubcmdui/package.json index 681c9ed..c604261 100644 --- a/hubcmdui/package.json +++ b/hubcmdui/package.json @@ -1,17 +1,43 @@ { + "name": "hubcmdui", + "version": "1.0.0", + "description": "Docker镜像代理加速系统", + "main": "server.js", + "scripts": { + "start": "node server.js", + "dev": "nodemon server.js", + "test": "jest", + "init": "node scripts/init-system.js", + "setup": "npm install && node scripts/init-system.js && echo '系统安装完成,请使用 npm start 启动服务'" + }, + "keywords": [ + "docker", + "proxy", + "management" + ], + "author": "", + "license": "MIT", "dependencies": { - "axios": "^1.7.5", - "axios-retry": "^3.5.0", - "bcrypt": "^5.1.1", - "chalk": "^5.3.0", + "axios": "^0.27.2", + "axios-retry": "^3.3.1", + "bcrypt": "^5.0.1", + "body-parser": "^1.20.0", + "chalk": "^4.1.2", "cors": "^2.8.5", - "dockerode": "^4.0.2", - "express": "^4.19.2", - "express-session": "^1.18.0", - "morgan": "^1.10.0", + "dockerode": "^3.3.4", + "express": "^4.21.2", + "express-session": "^1.18.1", "node-cache": "^5.1.2", - "p-limit": "^3.1.0", - "validator": "^13.12.0", - "ws": "^8.18.0" + "p-limit": "^4.0.0", + "session-file-store": "^1.5.0", + "validator": "^13.7.0", + "ws": "^8.8.1" + }, + "devDependencies": { + "jest": "^28.1.3", + "nodemon": "^2.0.19" + }, + "engines": { + "node": ">=14.0.0" } } diff --git a/hubcmdui/routes/auth.js b/hubcmdui/routes/auth.js new file mode 100644 index 0000000..8541aea --- /dev/null +++ b/hubcmdui/routes/auth.js @@ -0,0 +1,155 @@ +/** + * 认证相关路由 + */ +const express = require('express'); +const router = express.Router(); +const bcrypt = require('bcrypt'); +const userService = require('../services/userService'); +const logger = require('../logger'); +const { requireLogin } = require('../middleware/auth'); + +// 登录验证 +router.post('/login', async (req, res) => { + const { username, password, captcha } = req.body; + if (req.session.captcha !== parseInt(captcha)) { + logger.warn(`Captcha verification failed for user: ${username}`); + return res.status(401).json({ error: '验证码错误' }); + } + + try { + const users = await userService.getUsers(); + const user = users.users.find(u => u.username === username); + + if (!user) { + logger.warn(`User ${username} not found`); + return res.status(401).json({ error: '用户名或密码错误' }); + } + + if (bcrypt.compareSync(req.body.password, user.password)) { + req.session.user = { username: user.username }; + + // 更新用户登录信息 + await userService.updateUserLoginInfo(username); + + // 确保服务器启动时间已设置 + if (!global.serverStartTime) { + global.serverStartTime = Date.now(); + logger.warn(`登录时设置服务器启动时间: ${global.serverStartTime}`); + } + + logger.info(`User ${username} logged in successfully`); + res.json({ + success: true, + serverStartTime: global.serverStartTime + }); + } else { + logger.warn(`Login failed for user: ${username}`); + res.status(401).json({ error: '用户名或密码错误' }); + } + } catch (error) { + logger.error('登录失败:', error); + res.status(500).json({ error: '登录处理失败', details: error.message }); + } +}); + +// 注销 +router.post('/logout', (req, res) => { + req.session.destroy(err => { + if (err) { + logger.error('销毁会话失败:', err); + return res.status(500).json({ error: 'Failed to logout' }); + } + res.clearCookie('connect.sid'); + logger.info('用户已退出登录'); + res.json({ success: true }); + }); +}); + +// 修改密码 +router.post('/change-password', requireLogin, async (req, res) => { + const { currentPassword, newPassword } = req.body; + + // 密码复杂度校验 + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/; + if (!passwordRegex.test(newPassword)) { + return res.status(400).json({ error: 'Password must be 8-16 characters long and contain at least one letter, one number, and one special character' }); + } + + try { + const { users } = await userService.getUsers(); + const user = users.find(u => u.username === req.session.user.username); + + if (user && bcrypt.compareSync(currentPassword, user.password)) { + user.password = bcrypt.hashSync(newPassword, 10); + await userService.saveUsers(users); + res.json({ success: true }); + } else { + res.status(401).json({ error: 'Invalid current password' }); + } + } catch (error) { + logger.error('修改密码失败:', error); + res.status(500).json({ error: '修改密码失败', details: error.message }); + } +}); + +// 获取用户信息 +router.get('/user-info', requireLogin, async (req, res) => { + try { + const userService = require('../services/userService'); + const userStats = await userService.getUserStats(req.session.user.username); + + res.json(userStats); + } catch (error) { + logger.error('获取用户信息失败:', error); + res.status(500).json({ error: '获取用户信息失败', details: error.message }); + } +}); + +// 生成验证码 +router.get('/captcha', (req, res) => { + const num1 = Math.floor(Math.random() * 10); + const num2 = Math.floor(Math.random() * 10); + const captcha = `${num1} + ${num2} = ?`; + req.session.captcha = num1 + num2; + + // 确保serverStartTime已初始化 + if (!global.serverStartTime) { + global.serverStartTime = Date.now(); + logger.warn(`初始化服务器启动时间: ${global.serverStartTime}`); + } + + res.json({ + captcha, + serverStartTime: global.serverStartTime + }); +}); + +// 检查会话状态 +router.get('/check-session', (req, res) => { + // 如果global.serverStartTime不存在,创建一个 + if (!global.serverStartTime) { + global.serverStartTime = Date.now(); + logger.warn(`设置服务器启动时间: ${global.serverStartTime}`); + } + + if (req.session && req.session.user) { + return res.json({ + success: true, + user: { + username: req.session.user.username, + role: req.session.user.role, + }, + serverStartTime: global.serverStartTime // 返回服务器启动时间 + }); + } + return res.status(401).json({ + success: false, + message: '未登录', + serverStartTime: global.serverStartTime // 即使未登录也返回服务器时间 + }); +}); + +logger.success('✓ 认证路由已加载'); + +// 导出路由 +module.exports = router; diff --git a/hubcmdui/routes/config.js b/hubcmdui/routes/config.js new file mode 100644 index 0000000..017f1e7 --- /dev/null +++ b/hubcmdui/routes/config.js @@ -0,0 +1,224 @@ +/** + * 配置路由模块 + */ +const express = require('express'); +const router = express.Router(); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../logger'); +const { requireLogin } = require('../middleware/auth'); +const configService = require('../services/configService'); + +// 修改配置文件路径,使用独立的配置文件 +const configFilePath = path.join(__dirname, '../data/config.json'); + +// 默认配置 +const DEFAULT_CONFIG = { + proxyDomain: 'registry-1.docker.io', + logo: '', + theme: 'light' +}; + +// 确保配置文件存在 +async function ensureConfigFile() { + try { + // 确保目录存在 + const dir = path.dirname(configFilePath); + try { + await fs.access(dir); + } catch (error) { + await fs.mkdir(dir, { recursive: true }); + logger.info(`创建目录: ${dir}`); + } + + // 检查文件是否存在 + try { + await fs.access(configFilePath); + const data = await fs.readFile(configFilePath, 'utf8'); + return JSON.parse(data); + } catch (error) { + // 文件不存在或JSON解析错误,创建默认配置 + await fs.writeFile(configFilePath, JSON.stringify(DEFAULT_CONFIG, null, 2)); + logger.info(`创建默认配置文件: ${configFilePath}`); + return DEFAULT_CONFIG; + } + } catch (error) { + logger.error(`配置文件操作失败: ${error.message}`); + // 出错时返回默认配置以确保API不会失败 + return DEFAULT_CONFIG; + } +} + +// 获取配置 +router.get('/config', async (req, res) => { + try { + const config = await ensureConfigFile(); + res.json(config); + } catch (error) { + logger.error('获取配置失败:', error); + // 即使失败也返回默认配置 + res.json(DEFAULT_CONFIG); + } +}); + +// 保存配置 +router.post('/config', async (req, res) => { + try { + const newConfig = req.body; + + // 验证请求数据 + if (!newConfig || typeof newConfig !== 'object') { + return res.status(400).json({ + error: '无效的配置数据', + details: '配置必须是一个对象' + }); + } + + // 读取现有配置 + let existingConfig; + try { + existingConfig = await ensureConfigFile(); + } catch (error) { + existingConfig = DEFAULT_CONFIG; + } + + // 合并配置 + const mergedConfig = { ...existingConfig, ...newConfig }; + + // 保存到文件 + await fs.writeFile(configFilePath, JSON.stringify(mergedConfig, null, 2)); + + res.json({ success: true, message: '配置已保存' }); + } catch (error) { + logger.error('保存配置失败:', error); + res.status(500).json({ + error: '保存配置失败', + details: error.message + }); + } +}); + +// 获取监控配置 +router.get('/monitoring-config', async (req, res) => { + logger.info('收到监控配置请求'); + + try { + logger.info('读取监控配置...'); + const config = await configService.getConfig(); + + if (!config.monitoringConfig) { + logger.info('监控配置不存在,创建默认配置'); + config.monitoringConfig = { + notificationType: 'wechat', + webhookUrl: '', + telegramToken: '', + telegramChatId: '', + monitorInterval: 60, + isEnabled: false + }; + await configService.saveConfig(config); + } + + logger.info('返回监控配置'); + res.json({ + notificationType: config.monitoringConfig.notificationType || 'wechat', + webhookUrl: config.monitoringConfig.webhookUrl || '', + telegramToken: config.monitoringConfig.telegramToken || '', + telegramChatId: config.monitoringConfig.telegramChatId || '', + monitorInterval: config.monitoringConfig.monitorInterval || 60, + isEnabled: config.monitoringConfig.isEnabled || false + }); + } catch (error) { + logger.error('获取监控配置失败:', error); + res.status(500).json({ error: '获取监控配置失败', details: error.message }); + } +}); + +// 保存监控配置 +router.post('/monitoring-config', requireLogin, async (req, res) => { + try { + const { + notificationType, + webhookUrl, + telegramToken, + telegramChatId, + monitorInterval, + isEnabled + } = req.body; + + // 验证必填字段 + if (!notificationType) { + return res.status(400).json({ error: '通知类型不能为空' }); + } + + // 根据通知类型验证对应的字段 + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信 Webhook URL 不能为空' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram Token 和 Chat ID 不能为空' }); + } + + // 保存配置 + const config = await configService.getConfig(); + config.monitoringConfig = { + notificationType, + webhookUrl: webhookUrl || '', + telegramToken: telegramToken || '', + telegramChatId: telegramChatId || '', + monitorInterval: parseInt(monitorInterval) || 60, + isEnabled: !!isEnabled + }; + + await configService.saveConfig(config); + logger.info('监控配置已更新'); + + res.json({ success: true, message: '监控配置已保存' }); + } catch (error) { + logger.error('保存监控配置失败:', error); + res.status(500).json({ error: '保存监控配置失败', details: error.message }); + } +}); + +// 测试通知 +router.post('/test-notification', requireLogin, async (req, res) => { + try { + const { notificationType, webhookUrl, telegramToken, telegramChatId } = req.body; + + // 验证参数 + if (!notificationType) { + return res.status(400).json({ error: '通知类型不能为空' }); + } + + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信 Webhook URL 不能为空' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram Token 和 Chat ID 不能为空' }); + } + + // 构造测试消息 + const testMessage = { + title: '测试通知', + content: `这是一条测试通知消息,发送时间: ${new Date().toLocaleString('zh-CN')}`, + type: 'info' + }; + + // 模拟发送通知 + logger.info('发送测试通知:', testMessage); + + // TODO: 实际发送通知的逻辑 + // 这里仅做模拟,实际应用中需要实现真正的通知发送逻辑 + + // 返回成功 + res.json({ success: true, message: '测试通知已发送' }); + } catch (error) { + logger.error('发送测试通知失败:', error); + res.status(500).json({ error: '发送测试通知失败', details: error.message }); + } +}); + +// 导出路由 +module.exports = router; \ No newline at end of file diff --git a/hubcmdui/routes/docker.js b/hubcmdui/routes/docker.js new file mode 100644 index 0000000..b156d9d --- /dev/null +++ b/hubcmdui/routes/docker.js @@ -0,0 +1,146 @@ +/** + * Docker容器管理路由 + */ +const express = require('express'); +const router = express.Router(); +const WebSocket = require('ws'); +const http = require('http'); +const dockerService = require('../services/dockerService'); +const logger = require('../logger'); +const { requireLogin } = require('../middleware/auth'); + +// 获取Docker状态 +router.get('/status', requireLogin, async (req, res) => { + try { + const containerStatus = await dockerService.getContainersStatus(); + res.json(containerStatus); + } catch (error) { + logger.error('获取 Docker 状态时出错:', error); + res.status(500).json({ error: '获取 Docker 状态失败', details: error.message }); + } +}); + +// 获取单个容器状态 +router.get('/status/:id', requireLogin, async (req, res) => { + try { + const containerInfo = await dockerService.getContainerStatus(req.params.id); + res.json(containerInfo); + } catch (error) { + logger.error('获取容器状态失败:', error); + res.status(500).json({ error: '获取容器状态失败', details: error.message }); + } +}); + +// 重启容器 +router.post('/restart/:id', requireLogin, async (req, res) => { + try { + await dockerService.restartContainer(req.params.id); + res.json({ success: true }); + } catch (error) { + logger.error('重启容器失败:', error); + res.status(500).json({ error: '重启容器失败', details: error.message }); + } +}); + +// 停止容器 +router.post('/stop/:id', requireLogin, async (req, res) => { + try { + await dockerService.stopContainer(req.params.id); + res.json({ success: true }); + } catch (error) { + logger.error('停止容器失败:', error); + res.status(500).json({ error: '停止容器失败', details: error.message }); + } +}); + +// 删除容器 +router.post('/delete/:id', requireLogin, async (req, res) => { + try { + await dockerService.deleteContainer(req.params.id); + res.json({ success: true, message: '容器已成功删除' }); + } catch (error) { + logger.error('删除容器失败:', error); + res.status(500).json({ error: '删除容器失败', details: error.message }); + } +}); + +// 更新容器 +router.post('/update/:id', requireLogin, async (req, res) => { + try { + const { tag } = req.body; + await dockerService.updateContainer(req.params.id, tag); + res.json({ success: true, message: '容器更新成功' }); + } catch (error) { + logger.error('更新容器失败:', error); + res.status(500).json({ error: '更新容器失败', details: error.message, stack: error.stack }); + } +}); + +// 获取已停止容器 +router.get('/stopped', requireLogin, async (req, res) => { + try { + const stoppedContainers = await dockerService.getStoppedContainers(); + res.json(stoppedContainers); + } catch (error) { + logger.error('获取已停止容器列表失败:', error); + res.status(500).json({ error: '获取已停止容器列表失败', details: error.message }); + } +}); + +// 获取容器日志(HTTP轮询) +router.get('/logs-poll/:id', async (req, res) => { + const { id } = req.params; + try { + const logs = await dockerService.getContainerLogs(id); + res.send(logs); + } catch (error) { + logger.error('获取容器日志失败:', error); + res.status(500).send('获取日志失败'); + } +}); + +// 设置WebSocket路由,用于实时日志流 +function setupLogWebsocket(server) { + const wss = new WebSocket.Server({ server }); + + wss.on('connection', async (ws, req) => { + try { + const containerId = req.url.split('/').pop(); + const docker = await dockerService.getDockerConnection(); + + if (!docker) { + ws.send('Error: 无法连接到 Docker 守护进程'); + return; + } + + const container = docker.getContainer(containerId); + const stream = await container.logs({ + follow: true, + stdout: true, + stderr: true, + tail: 100 + }); + + stream.on('data', (chunk) => { + const cleanedChunk = chunk.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, ''); + // 移除不可打印字符 + const printableChunk = cleanedChunk.replace(/[^\x20-\x7E\x0A\x0D]/g, ''); + ws.send(printableChunk); + }); + + ws.on('close', () => { + stream.destroy(); + }); + + stream.on('error', (err) => { + ws.send('Error: ' + err.message); + }); + } catch (err) { + ws.send('Error: ' + err.message); + } + }); +} + +// 直接导出 router 实例,并添加 setupLogWebsocket 作为静态属性 +router.setupLogWebsocket = setupLogWebsocket; +module.exports = router; diff --git a/hubcmdui/routes/dockerhub.js b/hubcmdui/routes/dockerhub.js new file mode 100644 index 0000000..0f6e45a --- /dev/null +++ b/hubcmdui/routes/dockerhub.js @@ -0,0 +1,65 @@ +/** + * Docker Hub 代理路由 + */ +const express = require('express'); +const router = express.Router(); +const axios = require('axios'); +const logger = require('../logger'); + +// Docker Hub API 基础 URL +const DOCKER_HUB_API = 'https://hub.docker.com/v2'; + +// 搜索镜像 +router.get('/search', async (req, res) => { + try { + const { term, page = 1, limit = 25 } = req.query; + + // 确保有搜索关键字 + if (!term) { + return res.status(400).json({ error: '缺少搜索关键字(term)' }); + } + + logger.info(`搜索 Docker Hub: 关键字="${term}", 页码=${page}`); + + const response = await axios.get(`${DOCKER_HUB_API}/search/repositories`, { + params: { + query: term, + page, + page_size: limit + }, + timeout: 10000 + }); + + res.json(response.data); + } catch (err) { + logger.error('Docker Hub 搜索失败:', err.message); + res.status(500).json({ error: 'Docker Hub 搜索失败', details: err.message }); + } +}); + +// 获取镜像标签 +router.get('/tags/:owner/:repo', async (req, res) => { + try { + const { owner, repo } = req.params; + const { page = 1, limit = 25 } = req.query; + + logger.info(`获取镜像标签: ${owner}/${repo}, 页码=${page}`); + + const response = await axios.get( + `${DOCKER_HUB_API}/repositories/${owner}/${repo}/tags`, { + params: { + page, + page_size: limit + }, + timeout: 10000 + }); + + res.json(response.data); + } catch (err) { + logger.error('获取 Docker 镜像标签失败:', err.message); + res.status(500).json({ error: '获取镜像标签失败', details: err.message }); + } +}); + +// 直接导出路由实例 +module.exports = router; diff --git a/hubcmdui/routes/documentation.js b/hubcmdui/routes/documentation.js new file mode 100644 index 0000000..e07afec --- /dev/null +++ b/hubcmdui/routes/documentation.js @@ -0,0 +1,537 @@ +/** + * 文档管理路由 + */ +const express = require('express'); +const router = express.Router(); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../logger'); +const { requireLogin } = require('../middleware/auth'); + +// 确保文档目录存在 +const docsDir = path.join(__dirname, '../documentation'); +const metaDir = path.join(docsDir, 'meta'); + +// 文档文件扩展名 +const FILE_EXTENSION = '.md'; +const META_EXTENSION = '.json'; + +// 确保目录存在 +async function ensureDirectories() { + try { + await fs.mkdir(docsDir, { recursive: true }); + await fs.mkdir(metaDir, { recursive: true }); + } catch (err) { + logger.error('创建文档目录失败:', err); + } +} + +// 读取文档元数据 +async function getDocumentMeta(id) { + const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`); + try { + const metaContent = await fs.readFile(metaPath, 'utf8'); + return JSON.parse(metaContent); + } catch (err) { + // 如果元数据文件不存在,返回默认值 + return { + id, + published: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString() + }; + } +} + +// 保存文档元数据 +async function saveDocumentMeta(id, metadata) { + await ensureDirectories(); + const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`); + const metaContent = JSON.stringify(metadata, null, 2); + await fs.writeFile(metaPath, metaContent); +} + +// 初始化确保目录存在,但不再创建默认文档 +(async function() { + try { + await ensureDirectories(); + logger.info('文档目录已初始化'); + } catch (error) { + logger.error('初始化文档目录失败:', error); + } +})(); + +// 获取所有文档列表 +router.get('/documents', async (req, res) => { + try { + let files; + try { + files = await fs.readdir(docsDir); + } catch (err) { + // 如果目录不存在,尝试创建它并返回空列表 + if (err.code === 'ENOENT') { + await fs.mkdir(docsDir, { recursive: true }); + files = []; + } else { + throw err; + } + } + + const documents = []; + for (const file of files) { + if (file.endsWith(FILE_EXTENSION)) { + const filePath = path.join(docsDir, file); + const stats = await fs.stat(filePath); + const content = await fs.readFile(filePath, 'utf8'); + const id = file.replace(FILE_EXTENSION, ''); + + // 读取元数据 + const metadata = await getDocumentMeta(id); + + // 解析文档元数据(简单实现) + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : id; + + documents.push({ + id, + title, + content, + createdAt: metadata.createdAt || stats.birthtime, + updatedAt: metadata.updatedAt || stats.mtime, + published: metadata.published || false + }); + } + } + + res.json(documents); + } catch (err) { + logger.error('获取文档列表失败:', err); + res.status(500).json({ error: '获取文档列表失败' }); + } +}); + +// 保存文档 +router.put('/documents/:id', requireLogin, async (req, res) => { + try { + const { id } = req.params; + const { title, content, published } = req.body; + + if (!title || !content) { + return res.status(400).json({ error: '标题和内容为必填项' }); + } + + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + + // 确保文档目录存在 + await ensureDirectories(); + + // 获取或创建元数据 + const metadata = await getDocumentMeta(id); + metadata.title = title; + metadata.published = typeof published === 'boolean' ? published : metadata.published; + metadata.updatedAt = new Date().toISOString(); + + // 保存文档内容 + await fs.writeFile(filePath, content); + + // 保存元数据 + await saveDocumentMeta(id, metadata); + + // 获取文件状态 + const stats = await fs.stat(filePath); + const document = { + id, + title, + content, + createdAt: metadata.createdAt, + updatedAt: new Date().toISOString(), + published: metadata.published + }; + + res.json(document); + } catch (err) { + logger.error('保存文档失败:', err); + res.status(500).json({ error: '保存文档失败', details: err.message }); + } +}); + +// 创建新文档 +router.post('/documents', requireLogin, async (req, res) => { + try { + const { title, content, published } = req.body; + + if (!title || !content) { + return res.status(400).json({ error: '标题和内容为必填项' }); + } + + // 生成唯一ID + const id = Date.now().toString(); + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + + // 确保文档目录存在 + await ensureDirectories(); + + // 创建元数据 + const now = new Date().toISOString(); + const metadata = { + id, + title, + published: typeof published === 'boolean' ? published : false, + createdAt: now, + updatedAt: now + }; + + // 保存文档内容 + await fs.writeFile(filePath, content); + + // 保存元数据 + await saveDocumentMeta(id, metadata); + + const document = { + id, + title, + content, + createdAt: now, + updatedAt: now, + published: metadata.published + }; + + res.status(201).json(document); + } catch (err) { + logger.error('创建文档失败:', err); + res.status(500).json({ error: '创建文档失败', details: err.message }); + } +}); + +// 删除文档 +router.delete('/documents/:id', requireLogin, async (req, res) => { + try { + const { id } = req.params; + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + const metaPath = path.join(metaDir, `${id}${META_EXTENSION}`); + + let success = false; + + // 尝试删除主文档文件 + try { + await fs.access(filePath); + await fs.unlink(filePath); + success = true; + logger.info(`文档 ${id} 已成功删除`); + } catch (err) { + logger.warn(`删除文档文件 ${id} 失败:`, err); + } + + // 尝试删除元数据文件 + try { + await fs.access(metaPath); + await fs.unlink(metaPath); + success = true; + logger.info(`文档元数据 ${id} 已成功删除`); + } catch (err) { + logger.warn(`删除文档元数据 ${id} 失败:`, err); + } + + if (success) { + res.json({ success: true }); + } else { + throw new Error('文档和元数据均不存在或无法删除'); + } + } catch (err) { + logger.error(`删除文档 ${req.params.id} 失败:`, err); + res.status(500).json({ error: '删除文档失败', details: err.message }); + } +}); + +// 获取单个文档 +router.get('/documents/:id', async (req, res) => { + try { + const { id } = req.params; + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + + // 检查文件是否存在 + try { + await fs.access(filePath); + } catch (err) { + return res.status(404).json({ error: '文档不存在' }); + } + + // 读取文件内容和元数据 + const content = await fs.readFile(filePath, 'utf8'); + const metadata = await getDocumentMeta(id); + + // 解析文档标题 + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : metadata.title || id; + + const document = { + id, + title, + content, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + published: metadata.published + }; + + res.json(document); + } catch (err) { + logger.error(`获取文档 ${req.params.id} 失败:`, err); + res.status(500).json({ error: '获取文档失败', details: err.message }); + } +}); + +// 更新文档发布状态 +router.put('/documentation/toggle-publish/:id', requireLogin, async (req, res) => { + try { + const { id } = req.params; + const { published } = req.body; + + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + + // 检查文件是否存在 + try { + await fs.access(filePath); + } catch (err) { + return res.status(404).json({ error: '文档不存在' }); + } + + // 读取文件内容和元数据 + const content = await fs.readFile(filePath, 'utf8'); + const metadata = await getDocumentMeta(id); + + // 更新元数据 + metadata.published = typeof published === 'boolean' ? published : !metadata.published; + metadata.updatedAt = new Date().toISOString(); + + // 保存元数据 + await saveDocumentMeta(id, metadata); + + // 解析文档标题 + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : metadata.title || id; + + const document = { + id, + title, + content, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + published: metadata.published + }; + + res.json(document); + } catch (err) { + logger.error(`更新文档状态 ${req.params.id} 失败:`, err); + res.status(500).json({ error: '更新文档状态失败', details: err.message }); + } +}); + +// 为前端添加获取已发布文档列表的路由 +router.get('/documentation', async (req, res) => { + try { + let files; + try { + files = await fs.readdir(docsDir); + } catch (err) { + if (err.code === 'ENOENT') { + await fs.mkdir(docsDir, { recursive: true }); + files = []; + } else { + throw err; + } + } + + const documents = []; + for (const file of files) { + if (file.endsWith(FILE_EXTENSION)) { + const filePath = path.join(docsDir, file); + const stats = await fs.stat(filePath); + const id = file.replace(FILE_EXTENSION, ''); + + // 读取元数据 + const metadata = await getDocumentMeta(id); + + // 如果发布状态为true,则包含在返回结果中 + if (metadata.published === true) { + const content = await fs.readFile(filePath, 'utf8'); + + // 解析文档标题 + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : metadata.title || id; + + documents.push({ + id, + title, + createdAt: metadata.createdAt || stats.birthtime, + updatedAt: metadata.updatedAt || stats.mtime, + published: true + }); + } + } + } + + logger.info(`前端请求文档列表,返回 ${documents.length} 个已发布文档`); + res.json(documents); + } catch (err) { + logger.error('获取前端文档列表失败:', err); + res.status(500).json({ error: '获取文档列表失败' }); + } +}); + +// 前端获取单个文档内容 +router.get('/documentation/:id', async (req, res) => { + try { + const { id } = req.params; + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + + // 检查文件是否存在 + try { + await fs.access(filePath); + } catch (err) { + return res.status(404).json({ error: '文档不存在' }); + } + + // 读取文件内容和元数据 + const content = await fs.readFile(filePath, 'utf8'); + const metadata = await getDocumentMeta(id); + + // 如果文档未发布,则不允许前端访问 + if (metadata.published !== true) { + return res.status(403).json({ error: '该文档未发布,无法访问' }); + } + + // 解析文档标题 + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : metadata.title || id; + + const document = { + id, + title, + content, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + published: true + }; + + logger.info(`前端请求文档: ${id} - ${title}`); + res.json(document); + } catch (err) { + logger.error(`获取前端文档内容 ${req.params.id} 失败:`, err); + res.status(500).json({ error: '获取文档内容失败', details: err.message }); + } +}); + +// 修改发布状态 +router.patch('/documents/:id/publish', requireLogin, async (req, res) => { + try { + const { id } = req.params; + const { published } = req.body; + + if (typeof published !== 'boolean') { + return res.status(400).json({ error: '发布状态必须为布尔值' }); + } + + // 获取或创建元数据 + const metadata = await getDocumentMeta(id); + metadata.published = published; + metadata.updatedAt = new Date().toISOString(); + + // 保存元数据 + await saveDocumentMeta(id, metadata); + + // 获取文档内容 + const filePath = path.join(docsDir, `${id}${FILE_EXTENSION}`); + let content; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (err) { + if (err.code === 'ENOENT') { + return res.status(404).json({ error: '文档不存在' }); + } + throw err; + } + + // 解析标题 + const titleMatch = content.match(/^#\s+(.*)$/m); + const title = titleMatch ? titleMatch[1] : id; + + // 返回更新后的文档信息 + const document = { + id, + title, + content, + createdAt: metadata.createdAt, + updatedAt: metadata.updatedAt, + published: metadata.published + }; + + res.json(document); + } catch (err) { + logger.error('修改发布状态失败:', err); + res.status(500).json({ error: '修改发布状态失败', details: err.message }); + } +}); + +// 获取单个文档文件内容 +router.get('/file', async (req, res) => { + try { + const filePath = req.query.path; + + if (!filePath) { + return res.status(400).json({ error: '文件路径不能为空' }); + } + + logger.info(`请求获取文档文件: ${filePath}`); + + // 尝试直接从文件系统读取文件 + try { + // 安全检查,确保只能访问documentation目录下的文件 + const fileName = path.basename(filePath); + const fileDir = path.dirname(filePath); + + // 构建完整路径(只允许访问documentation目录) + const fullPath = path.join(__dirname, '..', 'documentation', fileName); + + logger.info(`尝试读取文件: ${fullPath}`); + + // 检查文件是否存在 + const fileExists = await fs.access(fullPath) + .then(() => true) + .catch(() => false); + + // 如果文件不存在,则返回错误 + if (!fileExists) { + logger.warn(`文件不存在: ${fullPath}`); + return res.status(404).json({ error: '文档不存在' }); + } + + logger.info(`文件存在,开始读取: ${fullPath}`); + + // 读取文件内容 + const fileContent = await fs.readFile(fullPath, 'utf8'); + + // 设置适当的Content-Type + if (fileName.endsWith('.md')) { + res.setHeader('Content-Type', 'text/markdown; charset=utf-8'); + } else if (fileName.endsWith('.json')) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + } else { + res.setHeader('Content-Type', 'text/plain; charset=utf-8'); + } + + logger.info(`成功读取文件,内容长度: ${fileContent.length}`); + return res.send(fileContent); + } catch (error) { + logger.error(`读取文件失败: ${error.message}`, error); + return res.status(500).json({ error: `读取文件失败: ${error.message}` }); + } + + } catch (error) { + logger.error('获取文档文件失败:', error); + res.status(500).json({ error: '获取文档文件失败', details: error.message }); + } +}); + +// 导出路由 +logger.success('✓ 文档管理路由已加载'); +module.exports = router; diff --git a/hubcmdui/routes/health.js b/hubcmdui/routes/health.js new file mode 100644 index 0000000..927b1c3 --- /dev/null +++ b/hubcmdui/routes/health.js @@ -0,0 +1,48 @@ +/** + * 健康检查路由 + */ +const express = require('express'); +const router = express.Router(); +const os = require('os'); +const path = require('path'); +const { version } = require('../package.json'); + +// 简单健康检查 +router.get('/', (req, res) => { + res.json({ + status: 'ok', + uptime: process.uptime(), + timestamp: Date.now(), + version + }); +}); + +// 详细系统信息 +router.get('/system', (req, res) => { + try { + res.json({ + status: 'ok', + system: { + platform: os.platform(), + release: os.release(), + hostname: os.hostname(), + uptime: os.uptime(), + totalMem: os.totalmem(), + freeMem: os.freemem(), + cpus: os.cpus().length, + loadavg: os.loadavg() + }, + process: { + pid: process.pid, + uptime: process.uptime(), + memoryUsage: process.memoryUsage(), + nodeVersion: process.version, + env: process.env.NODE_ENV || 'development' + } + }); + } catch (err) { + res.status(500).json({ error: '获取系统信息失败', details: err.message }); + } +}); + +module.exports = router; diff --git a/hubcmdui/routes/index.js b/hubcmdui/routes/index.js new file mode 100644 index 0000000..411a75d --- /dev/null +++ b/hubcmdui/routes/index.js @@ -0,0 +1,78 @@ +/** + * 路由注册器 + * 负责注册所有API路由 + */ +const fs = require('fs'); +const path = require('path'); +const logger = require('../logger'); + +// 检查文件是否是路由模块 +function isRouteModule(file) { + return file.endsWith('.js') && + file !== 'index.js' && + file !== 'routeLoader.js' && + !file.startsWith('_'); +} + +/** + * 注册所有路由 + * @param {Express} app - Express应用实例 + */ +function registerRoutes(app) { + const routeDir = __dirname; + + try { + const files = fs.readdirSync(routeDir); + const routeFiles = files.filter(isRouteModule); + + logger.info(`发现 ${routeFiles.length} 个路由文件待加载`); + + routeFiles.forEach(file => { + const routeName = path.basename(file, '.js'); + try { + const routePath = path.join(routeDir, file); + const routeExport = require(routePath); // 加载导出的模块 + + // 优先处理 { router: routerInstance, ... } 格式 + if (routeExport && typeof routeExport === 'object' && routeExport.router && typeof routeExport.router === 'function' && routeExport.router.stack) { + app.use(`/api/${routeName}`, routeExport.router); + logger.info(`✓ 挂载路由对象: /api/${routeName}`); + } + // 处理直接导出 routerInstance 的情况 (更严格的检查) + else if (typeof routeExport === 'function' && routeExport.handle && routeExport.stack) { + app.use(`/api/${routeName}`, routeExport); + logger.info(`✓ 挂载路由: /api/${routeName}`); + } + // 处理导出 { setup: setupFunction } 的情况 + else if (routeExport && typeof routeExport === 'object' && routeExport.setup && typeof routeExport.setup === 'function') { + routeExport.setup(app); + logger.info(`✓ 初始化路由: ${routeName}`); + } + // 处理导出 setup 函数 (app) => {} 的情况 + else if (typeof routeExport === 'function') { + // 检查是否是 Express Router 实例 (避免重复判断 Case 3) + if (!(routeExport.handle && routeExport.stack)) { + routeExport(app); + logger.info(`✓ 注册路由函数: ${routeName}`); + } else { + logger.warn(`× 路由 ${file} 格式疑似 Router 实例但未被 Case 3 处理,已跳过`); + } + } + // 其他无法识别的格式 + else { + logger.warn(`× 路由 ${file} 导出格式无法识别 (${typeof routeExport}),已跳过`); + } + } catch (error) { + logger.error(`× 加载路由 ${file} 失败: ${error.message}`); + // 可以在这里添加更详细的错误堆栈日志 + logger.debug(error.stack); + } + }); + + logger.info('所有路由注册完成'); + } catch (error) { + logger.error(`路由注册失败: ${error.message}`); + } +} + +module.exports = registerRoutes; diff --git a/hubcmdui/routes/login.js b/hubcmdui/routes/login.js new file mode 100644 index 0000000..b8c3587 --- /dev/null +++ b/hubcmdui/routes/login.js @@ -0,0 +1,92 @@ +/** + * 登录路由 + */ +const express = require('express'); +const router = express.Router(); +const fs = require('fs').promises; +const path = require('path'); +const crypto = require('crypto'); +const logger = require('../logger'); + +// 生成随机验证码 +function generateCaptcha() { + return Math.floor(1000 + Math.random() * 9000).toString(); +} + +// 获取验证码 +router.get('/captcha', (req, res) => { + const captcha = generateCaptcha(); + req.session.captcha = captcha; + res.json({ captcha }); +}); + +// 处理登录 +router.post('/login', async (req, res) => { + try { + const { username, password, captcha } = req.body; + + // 验证码检查 + if (!req.session.captcha || req.session.captcha !== captcha) { + return res.status(401).json({ error: '验证码错误' }); + } + + // 读取用户文件 + const userFilePath = path.join(__dirname, '../config/users.json'); + let users; + + try { + const data = await fs.readFile(userFilePath, 'utf8'); + users = JSON.parse(data); + } catch (err) { + logger.error('读取用户文件失败:', err); + return res.status(500).json({ error: '内部服务器错误' }); + } + + // 查找用户 + const user = users.find(u => u.username === username); + if (!user) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + // 验证密码 + const hashedPassword = crypto + .createHash('sha256') + .update(password + user.salt) + .digest('hex'); + + if (hashedPassword !== user.password) { + return res.status(401).json({ error: '用户名或密码错误' }); + } + + // 登录成功 + req.session.user = { + id: user.id, + username: user.username, + role: user.role, + loginCount: (user.loginCount || 0) + 1, + lastLogin: new Date().toISOString() + }; + + // 更新登录信息 + user.loginCount = (user.loginCount || 0) + 1; + user.lastLogin = new Date().toISOString(); + + await fs.writeFile(userFilePath, JSON.stringify(users, null, 2), 'utf8'); + + res.json({ + success: true, + user: { + username: user.username, + role: user.role + } + }); + } catch (err) { + logger.error('登录处理错误:', err); + res.status(500).json({ error: '登录处理失败' }); + } +}); + +logger.success('✓ 登录路由已加载'); + +// 导出路由 +module.exports = router; diff --git a/hubcmdui/routes/monitoring.js b/hubcmdui/routes/monitoring.js new file mode 100644 index 0000000..01aa952 --- /dev/null +++ b/hubcmdui/routes/monitoring.js @@ -0,0 +1,193 @@ +/** + * 监控配置路由 + */ +const express = require('express'); +const router = express.Router(); +const fs = require('fs').promises; +const path = require('path'); +const { requireLogin } = require('../middleware/auth'); +const logger = require('../logger'); + +// 监控配置文件路径 +const CONFIG_FILE = path.join(__dirname, '../config/monitoring.json'); + +// 确保配置文件存在 +async function ensureConfigFile() { + try { + await fs.access(CONFIG_FILE); + } catch (err) { + // 文件不存在,创建默认配置 + const defaultConfig = { + isEnabled: false, + notificationType: 'wechat', + webhookUrl: '', + telegramToken: '', + telegramChatId: '', + monitorInterval: 60 + }; + + await fs.mkdir(path.dirname(CONFIG_FILE), { recursive: true }); + await fs.writeFile(CONFIG_FILE, JSON.stringify(defaultConfig, null, 2), 'utf8'); + return defaultConfig; + } + + // 文件存在,读取配置 + const data = await fs.readFile(CONFIG_FILE, 'utf8'); + return JSON.parse(data); +} + +// 获取监控配置 +router.get('/monitoring-config', requireLogin, async (req, res) => { + try { + const config = await ensureConfigFile(); + res.json(config); + } catch (err) { + logger.error('获取监控配置失败:', err); + res.status(500).json({ error: '获取监控配置失败' }); + } +}); + +// 保存监控配置 +router.post('/monitoring-config', requireLogin, async (req, res) => { + try { + const { + notificationType, + webhookUrl, + telegramToken, + telegramChatId, + monitorInterval, + isEnabled + } = req.body; + + // 简单验证 + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' }); + } + + const config = await ensureConfigFile(); + + // 更新配置 + const updatedConfig = { + ...config, + notificationType, + webhookUrl: webhookUrl || '', + telegramToken: telegramToken || '', + telegramChatId: telegramChatId || '', + monitorInterval: parseInt(monitorInterval, 10) || 60, + isEnabled: isEnabled !== undefined ? isEnabled : config.isEnabled + }; + + await fs.writeFile(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2), 'utf8'); + + res.json({ success: true, message: '监控配置已保存' }); + + // 通知监控服务重新加载配置 + if (global.monitoringService && typeof global.monitoringService.reload === 'function') { + global.monitoringService.reload(); + } + } catch (err) { + logger.error('保存监控配置失败:', err); + res.status(500).json({ error: '保存监控配置失败' }); + } +}); + +// 切换监控状态 +router.post('/toggle-monitoring', requireLogin, async (req, res) => { + try { + const { isEnabled } = req.body; + const config = await ensureConfigFile(); + + config.isEnabled = !!isEnabled; + await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); + + res.json({ + success: true, + message: `监控已${isEnabled ? '启用' : '禁用'}` + }); + + // 通知监控服务重新加载配置 + if (global.monitoringService && typeof global.monitoringService.reload === 'function') { + global.monitoringService.reload(); + } + } catch (err) { + logger.error('切换监控状态失败:', err); + res.status(500).json({ error: '切换监控状态失败' }); + } +}); + +// 测试通知 +router.post('/test-notification', requireLogin, async (req, res) => { + try { + const { + notificationType, + webhookUrl, + telegramToken, + telegramChatId + } = req.body; + + // 简单验证 + if (notificationType === 'wechat' && !webhookUrl) { + return res.status(400).json({ error: '企业微信通知需要设置 webhook URL' }); + } + + if (notificationType === 'telegram' && (!telegramToken || !telegramChatId)) { + return res.status(400).json({ error: 'Telegram 通知需要设置 Token 和 Chat ID' }); + } + + // 发送测试通知 + const notifier = require('../services/notificationService'); + const testMessage = { + title: '测试通知', + content: '这是一条测试通知,如果您收到这条消息,说明您的通知配置工作正常。', + time: new Date().toLocaleString() + }; + + await notifier.sendNotification(testMessage, { + type: notificationType, + webhookUrl, + telegramToken, + telegramChatId + }); + + res.json({ success: true, message: '测试通知已发送' }); + } catch (err) { + logger.error('发送测试通知失败:', err); + res.status(500).json({ error: '发送测试通知失败: ' + err.message }); + } +}); + +// 获取已停止的容器 +router.get('/stopped-containers', async (req, res) => { + try { + const { exec } = require('child_process'); + const util = require('util'); + const execPromise = util.promisify(exec); + + const { stdout } = await execPromise('docker ps -f "status=exited" --format "{{.ID}}\\t{{.Names}}\\t{{.Status}}"'); + + const containers = stdout.trim().split('\n') + .filter(line => line.trim()) + .map(line => { + const [id, name, ...statusParts] = line.split('\t'); + return { + id: id.substring(0, 12), + name, + status: statusParts.join(' ') + }; + }); + + res.json(containers); + } catch (err) { + logger.error('获取已停止容器失败:', err); + res.status(500).json({ error: '获取已停止容器失败', details: err.message }); + } +}); + +logger.success('✓ 监控配置路由已加载'); + +// 导出路由 +module.exports = router; diff --git a/hubcmdui/routes/routeLoader.js b/hubcmdui/routes/routeLoader.js new file mode 100644 index 0000000..1cb8a5b --- /dev/null +++ b/hubcmdui/routes/routeLoader.js @@ -0,0 +1,55 @@ +const fs = require('fs'); +const path = require('path'); +const express = require('express'); +const { executeOnce } = require('../lib/initScheduler'); + +// 引入logger +const logger = require('../logger'); + +// 改进路由加载器,确保每个路由只被加载一次 +async function loadRoutes(app, customLogger) { + // 使用传入的logger或默认logger + const log = customLogger || logger; + + const routesDir = path.join(__dirname); + const routeFiles = fs.readdirSync(routesDir).filter(file => + file.endsWith('.js') && !file.includes('routeLoader') && !file.includes('index') + ); + + log.info(`发现 ${routeFiles.length} 个路由文件待加载`); + + for (const file of routeFiles) { + const routeName = path.basename(file, '.js'); + + try { + await executeOnce(`loadRoute_${routeName}`, async () => { + const routePath = path.join(routesDir, file); + + // 添加错误处理来避免路由加载失败时导致应用崩溃 + try { + const route = require(routePath); + + if (typeof route === 'function') { + route(app); + log.success(`✓ 注册路由: ${routeName}`); + } else if (route && typeof route.router === 'function') { + route.router(app); + log.success(`✓ 注册路由对象: ${routeName}`); + } else { + log.error(`× 路由格式错误: ${file} (应该导出一个函数或router方法)`); + } + } catch (routeError) { + log.error(`× 加载路由 ${file} 失败: ${routeError.message}`); + // 继续加载其他路由,不中断流程 + } + }, log); + } catch (error) { + log.error(`× 路由加载流程出错: ${error.message}`); + // 继续处理下一个路由 + } + } + + log.info('所有路由注册完成'); +} + +module.exports = loadRoutes; diff --git a/hubcmdui/routes/system.js b/hubcmdui/routes/system.js new file mode 100644 index 0000000..99cc06d --- /dev/null +++ b/hubcmdui/routes/system.js @@ -0,0 +1,590 @@ +/** + * 系统相关路由 + */ +const express = require('express'); +const router = express.Router(); +const os = require('os'); // 确保导入 os 模块 +const util = require('util'); // 导入 util 模块 +const { exec } = require('child_process'); +const execPromise = util.promisify(exec); // 只在这里定义一次 +const logger = require('../logger'); +const { requireLogin } = require('../middleware/auth'); +const configService = require('../services/configService'); +const { execCommand, getSystemInfo } = require('../server-utils'); +const dockerService = require('../services/dockerService'); +const path = require('path'); +const fs = require('fs').promises; + +// 获取系统状态 +async function getSystemStats(req, res) { + try { + let dockerAvailable = false; + let containerCount = '0'; + let memoryUsage = '0%'; + let cpuLoad = '0%'; + let diskSpace = '0%'; + let recentActivities = []; + + // 尝试获取系统信息 + try { + const systemInfo = await getSystemInfo(); + memoryUsage = `${systemInfo.memory.percent}%`; + cpuLoad = systemInfo.cpu.load1; + diskSpace = systemInfo.disk.percent; + } catch (sysError) { + logger.error('获取系统信息失败:', sysError); + } + + // 尝试从Docker获取状态信息 + try { + const docker = await dockerService.getDockerConnection(); + if (docker) { + dockerAvailable = true; + + // 获取容器统计 + const containers = await docker.listContainers({ all: true }); + containerCount = containers.length.toString(); + + // 获取最近的容器活动 + const runningContainers = containers.filter(c => c.State === 'running'); + for (let i = 0; i < Math.min(3, runningContainers.length); i++) { + recentActivities.push({ + time: new Date(runningContainers[i].Created * 1000).toLocaleString(), + action: '运行中', + container: runningContainers[i].Names[0].replace(/^\//, ''), + status: '正常' + }); + } + + // 获取最近的Docker事件 + const events = await dockerService.getRecentEvents(); + if (events && events.length > 0) { + recentActivities = [...events.map(event => ({ + time: new Date(event.time * 1000).toLocaleString(), + action: event.Action, + container: event.Actor?.Attributes?.name || '未知容器', + status: event.status || '完成' + })), ...recentActivities].slice(0, 10); + } + } + } catch (containerError) { + logger.error('获取容器信息失败:', containerError); + } + + // 如果没有活动记录,添加一个默认记录 + if (recentActivities.length === 0) { + recentActivities.push({ + time: new Date().toLocaleString(), + action: '系统检查', + container: '监控服务', + status: dockerAvailable ? '正常' : 'Docker服务不可用' + }); + } + + // 返回收集到的所有数据,即使部分数据可能不完整 + res.json({ + dockerAvailable, + containerCount, + memoryUsage, + cpuLoad, + diskSpace, + recentActivities + }); + } catch (error) { + logger.error('获取系统统计数据失败:', error); + + // 即使出错,仍然尝试返回一些基本数据 + res.status(200).json({ + dockerAvailable: false, + containerCount: '0', + memoryUsage: '未知', + cpuLoad: '未知', + diskSpace: '未知', + recentActivities: [{ + time: new Date().toLocaleString(), + action: '系统错误', + container: '监控服务', + status: '数据获取失败' + }], + error: '获取系统统计数据失败', + errorDetails: error.message + }); + } +} + +// 获取系统配置 - 修改版本,避免与其他路由冲突 +router.get('/system-config', async (req, res) => { + try { + const config = await configService.getConfig(); + res.json(config); + } catch (error) { + logger.error('读取配置失败:', error); + res.status(500).json({ + error: '读取配置失败', + details: error.message + }); + } +}); + +// 保存系统配置 - 修改版本,避免与其他路由冲突 +router.post('/system-config', requireLogin, async (req, res) => { + try { + const currentConfig = await configService.getConfig(); + const newConfig = { ...currentConfig, ...req.body }; + await configService.saveConfig(newConfig); + logger.info('系统配置已更新'); + res.json({ success: true }); + } catch (error) { + logger.error('保存配置失败:', error); + res.status(500).json({ + error: '保存配置失败', + details: error.message + }); + } +}); + +// 获取系统状态 +router.get('/stats', requireLogin, async (req, res) => { + return await getSystemStats(req, res); +}); + +// 获取磁盘空间信息 +router.get('/disk-space', requireLogin, async (req, res) => { + try { + const systemInfo = await getSystemInfo(); + res.json({ + diskSpace: `${systemInfo.disk.used}/${systemInfo.disk.size}`, + usagePercent: parseInt(systemInfo.disk.percent) + }); + } catch (error) { + logger.error('获取磁盘空间信息失败:', error); + res.status(500).json({ error: '获取磁盘空间信息失败', details: error.message }); + } +}); + +// 网络测试 +router.post('/network-test', requireLogin, async (req, res) => { + const { type, domain } = req.body; + + // 验证输入 + function validateInput(input, type) { + if (type === 'domain') { + return /^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/.test(input); + } + return false; + } + + if (!validateInput(domain, 'domain')) { + return res.status(400).json({ error: '无效的域名格式' }); + } + + try { + const result = await execCommand(`${type === 'ping' ? 'ping -c 4' : 'traceroute -m 10'} ${domain}`, { timeout: 30000 }); + res.send(result); + } catch (error) { + if (error.killed) { + return res.status(408).send('测试超时'); + } + logger.error(`执行网络测试命令错误:`, error); + res.status(500).send('测试执行失败: ' + error.message); + } +}); + +// 获取用户统计信息 +router.get('/user-stats', requireLogin, async (req, res) => { + try { + const userService = require('../services/userService'); + const username = req.session.user.username; + const userStats = await userService.getUserStats(username); + + res.json(userStats); + } catch (error) { + logger.error('获取用户统计信息失败:', error); + res.status(500).json({ + loginCount: '0', + lastLogin: '未知', + accountAge: '0' + }); + } +}); + +// 获取系统状态信息 (旧版,可能与 getSystemStats 重复,可以考虑移除) +router.get('/system-status', requireLogin, async (req, res) => { + logger.warn('Accessing potentially deprecated /api/system-status route.'); + try { + // 检查 Docker 可用性 + let dockerAvailable = true; + let containerCount = 0; + try { + // 避免直接执行命令计算,依赖 dockerService + const docker = await dockerService.getDockerConnection(); + if (docker) { + const containers = await docker.listContainers({ all: true }); + containerCount = containers.length; + } else { + dockerAvailable = false; + } + } catch (dockerError) { + dockerAvailable = false; + containerCount = 0; + logger.warn('Docker可能未运行或无法访问 (in /system-status):', dockerError.message); + } + + // 获取内存使用信息 + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memoryUsage = `${Math.round(usedMem / totalMem * 100)}%`; + + // 获取CPU负载 + const [load1] = os.loadavg(); + const cpuCount = os.cpus().length || 1; // 避免除以0 + const cpuLoad = `${(load1 / cpuCount * 100).toFixed(1)}%`; + + // 获取磁盘空间 - 简单版 + let diskSpace = '未知'; + try { + if (os.platform() === 'darwin' || os.platform() === 'linux') { + const { stdout } = await execPromise('df -h / | tail -n 1'); // 使用 -n 1 + const parts = stdout.trim().split(/\s+/); + if (parts.length >= 5) diskSpace = parts[4]; + } else if (os.platform() === 'win32') { + const { stdout } = await execPromise('wmic logicaldisk get size,freespace,caption | findstr /B /L /V "Caption" '); + const lines = stdout.trim().split(/\r?\n/); + if (lines.length > 0) { + const parts = lines[0].trim().split(/\s+/); + if (parts.length >= 2) { + const free = parseInt(parts[0], 10); + const total = parseInt(parts[1], 10); + if (!isNaN(total) && !isNaN(free) && total > 0) { + diskSpace = `${Math.round(((total - free) / total) * 100)}%`; + } + } + } + } + } catch (diskError) { + logger.warn('获取磁盘空间失败 (in /system-status):', diskError.message); + diskSpace = '未知'; + } + + // 格式化系统运行时间 + const uptime = formatUptime(os.uptime()); + + res.json({ + dockerAvailable, + containerCount, + memoryUsage, + cpuLoad, + diskSpace, + systemUptime: uptime + }); + } catch (error) { + logger.error('获取系统状态失败 (in /system-status):', error); + res.status(500).json({ + error: '获取系统状态失败', + message: error.message + }); + } +}); + +// 添加新的API端点,提供完整系统资源信息 +router.get('/system-resources', requireLogin, async (req, res) => { + logger.info('Received request for /api/system-resources'); + let cpuInfoData = null, memoryData = null, diskInfoData = null, systemData = null; + + // --- 获取 CPU 信息 (独立 try...catch) --- + try { + const cpuInfo = os.cpus(); + const [load1, load5, load15] = os.loadavg(); + const cpuCount = cpuInfo.length || 1; + const cpuUsage = (load1 / cpuCount * 100).toFixed(1); + cpuInfoData = { + cores: cpuCount, + model: cpuInfo[0]?.model || '未知', + speed: `${cpuInfo[0]?.speed || '未知'} MHz`, + loadAvg: { + '1min': load1.toFixed(2), + '5min': load5.toFixed(2), + '15min': load15.toFixed(2) + }, + usage: parseFloat(cpuUsage) + }; + logger.info('Successfully retrieved CPU info.'); + } catch (cpuError) { + logger.error('Error getting CPU info:', cpuError.message); + cpuInfoData = { error: '获取 CPU 信息失败', message: cpuError.message }; // 返回错误信息 + } + + // --- 获取内存信息 (独立 try...catch) --- + try { + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memoryUsagePercent = totalMem > 0 ? Math.round(usedMem / totalMem * 100) : 0; + memoryData = { + total: formatBytes(totalMem), // 可能出错 + free: formatBytes(freeMem), // 可能出错 + used: formatBytes(usedMem), // 可能出错 + usedPercentage: memoryUsagePercent + }; + logger.info('Successfully retrieved Memory info.'); + } catch (memError) { + logger.error('Error getting Memory info:', memError.message); + memoryData = { error: '获取内存信息失败', message: memError.message }; // 返回错误信息 + } + + // --- 获取磁盘信息 (独立 try...catch) --- + try { + let diskResult = { total: '未知', free: '未知', used: '未知', usedPercentage: '未知' }; + logger.info(`Getting disk info for platform: ${os.platform()}`); + if (os.platform() === 'darwin' || os.platform() === 'linux') { + try { + // 使用 -k 获取 KB 单位,方便计算 + const { stdout } = await execPromise('df -k / | tail -n 1', { timeout: 5000 }); + logger.info(`'df -k' command output: ${stdout}`); + const parts = stdout.trim().split(/\s+/); + // 索引通常是 1=Total, 2=Used, 3=Available, 4=Use% + if (parts.length >= 4) { + const total = parseInt(parts[1], 10) * 1024; // KB to Bytes + const used = parseInt(parts[2], 10) * 1024; // KB to Bytes + const free = parseInt(parts[3], 10) * 1024; // KB to Bytes + // 优先使用命令输出的百分比,更准确 + let usedPercentage = parseInt(parts[4].replace('%', ''), 10); + + // 如果解析失败或百分比无效,则尝试计算 + if (isNaN(usedPercentage) && !isNaN(total) && !isNaN(used) && total > 0) { + usedPercentage = Math.round((used / total) * 100); + } + + if (!isNaN(total) && !isNaN(used) && !isNaN(free) && !isNaN(usedPercentage)) { + diskResult = { + total: formatBytes(total), // 可能出错 + free: formatBytes(free), // 可能出错 + used: formatBytes(used), // 可能出错 + usedPercentage: usedPercentage + }; + logger.info('Successfully parsed disk info (Linux/Darwin).'); + } else { + logger.warn('Failed to parse numbers from df output:', parts); + diskResult = { ...diskResult, error: '解析 df 输出失败' }; // 添加错误标记 + } + } else { + logger.warn('Unexpected output format from df:', stdout); + diskResult = { ...diskResult, error: 'df 输出格式不符合预期' }; // 添加错误标记 + } + } catch (dfError) { + logger.error(`Error executing or parsing 'df -k': ${dfError.message}`); + if (dfError.killed) logger.error("'df -k' command timed out."); + diskResult = { error: '获取磁盘信息失败 (df)', message: dfError.message }; // 标记错误 + } + } else if (os.platform() === 'win32') { + try { + // 获取 C 盘信息 (可以修改为获取所有盘符或特定盘符) + const { stdout } = await execPromise(`wmic logicaldisk where "DeviceID='C:'" get size,freespace /value`, { timeout: 5000 }); + logger.info(`'wmic' command output: ${stdout}`); + const lines = stdout.trim().split(/\r?\n/); + let free = NaN, total = NaN; + lines.forEach(line => { + if (line.startsWith('FreeSpace=')) { + free = parseInt(line.split('=')[1], 10); + } else if (line.startsWith('Size=')) { + total = parseInt(line.split('=')[1], 10); + } + }); + + if (!isNaN(total) && !isNaN(free) && total > 0) { + const used = total - free; + const usedPercentage = Math.round((used / total) * 100); + diskResult = { + total: formatBytes(total), // 可能出错 + free: formatBytes(free), // 可能出错 + used: formatBytes(used), // 可能出错 + usedPercentage: usedPercentage + }; + logger.info('Successfully parsed disk info (Windows - C:).'); + } else { + logger.warn('Failed to parse numbers from wmic output:', stdout); + diskResult = { ...diskResult, error: '解析 wmic 输出失败' }; // 添加错误标记 + } + } catch (wmicError) { + logger.error(`Error executing or parsing 'wmic': ${wmicError.message}`); + if (wmicError.killed) logger.error("'wmic' command timed out."); + diskResult = { error: '获取磁盘信息失败 (wmic)', message: wmicError.message }; // 标记错误 + } + } + diskInfoData = diskResult; + } catch (diskErrorOuter) { + logger.error('Unexpected error during disk info gathering:', diskErrorOuter.message); + diskInfoData = { error: '获取磁盘信息时发生意外错误', message: diskErrorOuter.message }; // 返回错误信息 + } + + // --- 获取其他系统信息 (独立 try...catch) --- + try { + systemData = { + platform: os.platform(), + release: os.release(), + hostname: os.hostname(), + uptime: formatUptime(os.uptime()) // 可能出错 + }; + logger.info('Successfully retrieved general system info.'); + } catch (sysInfoError) { + logger.error('Error getting general system info:', sysInfoError.message); + systemData = { error: '获取常规系统信息失败', message: sysInfoError.message }; // 返回错误信息 + } + + // --- 包装 Helper 函数调用以捕获潜在错误 --- + const safeFormatBytes = (bytes) => { + try { + return formatBytes(bytes); + } catch (e) { + logger.error(`formatBytes failed for value ${bytes}:`, e.message); + return '格式化错误'; + } + }; + const safeFormatUptime = (seconds) => { + try { + return formatUptime(seconds); + } catch (e) { + logger.error(`formatUptime failed for value ${seconds}:`, e.message); + return '格式化错误'; + } + }; + + // --- 构建最终响应数据,使用安全的 Helper 函数 --- + const finalCpuData = cpuInfoData?.error ? cpuInfoData : { + ...cpuInfoData + // CPU 不需要格式化 + }; + const finalMemoryData = memoryData?.error ? memoryData : { + ...memoryData, + total: safeFormatBytes(os.totalmem()), + free: safeFormatBytes(os.freemem()), + used: safeFormatBytes(os.totalmem() - os.freemem()) + }; + const finalDiskData = diskInfoData?.error ? diskInfoData : { + ...diskInfoData, + // 如果 diskInfoData 内部有 total/free/used (字节数),则格式化 + // 否则保持 '未知' 或已格式化的字符串 + total: (diskInfoData?.total && typeof diskInfoData.total === 'number') ? safeFormatBytes(diskInfoData.total) : diskInfoData?.total || '未知', + free: (diskInfoData?.free && typeof diskInfoData.free === 'number') ? safeFormatBytes(diskInfoData.free) : diskInfoData?.free || '未知', + used: (diskInfoData?.used && typeof diskInfoData.used === 'number') ? safeFormatBytes(diskInfoData.used) : diskInfoData?.used || '未知' + }; + const finalSystemData = systemData?.error ? systemData : { + ...systemData, + uptime: safeFormatUptime(os.uptime()) + }; + + const responseData = { + cpu: finalCpuData, + memory: finalMemoryData, + diskSpace: finalDiskData, + system: finalSystemData + }; + + logger.info('Sending response for /api/system-resources:', JSON.stringify(responseData)); + res.status(200).json(responseData); +}); + +// 格式化系统运行时间 +function formatUptime(seconds) { + const days = Math.floor(seconds / 86400); + seconds %= 86400; + const hours = Math.floor(seconds / 3600); + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + seconds = Math.floor(seconds % 60); + + let result = ''; + if (days > 0) result += `${days}天 `; + if (hours > 0 || days > 0) result += `${hours}小时 `; + if (minutes > 0 || hours > 0 || days > 0) result += `${minutes}分钟 `; + result += `${seconds}秒`; + + return result; +} + +// 获取系统资源详情 +router.get('/system-resource-details', requireLogin, async (req, res) => { + try { + const { type } = req.query; + + let data = {}; + + switch (type) { + case 'memory': + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + + data = { + totalMemory: formatBytes(totalMem), + usedMemory: formatBytes(usedMem), + freeMemory: formatBytes(freeMem), + memoryUsage: `${Math.round(usedMem / totalMem * 100)}%` + }; + break; + + case 'cpu': + const cpuInfo = os.cpus(); + const [load1, load5, load15] = os.loadavg(); + + data = { + cpuCores: cpuInfo.length, + cpuModel: cpuInfo[0].model, + cpuSpeed: `${cpuInfo[0].speed} MHz`, + loadAvg1: load1.toFixed(2), + loadAvg5: load5.toFixed(2), + loadAvg15: load15.toFixed(2), + cpuLoad: `${(load1 / cpuInfo.length * 100).toFixed(1)}%` + }; + break; + + case 'disk': + try { + const { stdout: dfOutput } = await execPromise('df -h / | tail -n 1'); + const parts = dfOutput.trim().split(/\s+/); + + if (parts.length >= 5) { + data = { + totalSpace: parts[1], + usedSpace: parts[2], + freeSpace: parts[3], + diskUsage: parts[4] + }; + } else { + throw new Error('解析磁盘信息失败'); + } + } catch (diskError) { + logger.warn('获取磁盘信息失败:', diskError.message); + data = { + error: '获取磁盘信息失败', + message: diskError.message + }; + } + break; + + default: + return res.status(400).json({ error: '无效的资源类型' }); + } + + res.json(data); + } catch (error) { + logger.error('获取系统资源详情失败:', error); + res.status(500).json({ error: '获取系统资源详情失败', message: error.message }); + } +}); + +// 格式化字节数为可读格式 +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +module.exports = router; // 只导出 router diff --git a/hubcmdui/routes/systemStatus.js b/hubcmdui/routes/systemStatus.js new file mode 100644 index 0000000..708e42b --- /dev/null +++ b/hubcmdui/routes/systemStatus.js @@ -0,0 +1,104 @@ +const express = require('express'); +const router = express.Router(); +const os = require('os'); +const logger = require('../logger'); + +// 获取系统状态 +router.get('/', (req, res) => { + try { + // 收集系统信息 + const cpuLoad = os.loadavg()[0] / os.cpus().length * 100; + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memoryUsage = `${Math.round(usedMem / totalMem * 100)}%`; + + // 组合结果 + const systemStatus = { + dockerAvailable: true, + containerCount: 0, + cpuLoad: `${cpuLoad.toFixed(1)}%`, + memoryUsage: memoryUsage, + diskSpace: '未知', + recentActivities: [] + }; + + res.json(systemStatus); + } catch (error) { + logger.error('获取系统状态失败:', error); + res.status(500).json({ + error: '获取系统状态失败', + details: error.message + }); + } +}); + +// 获取系统资源详情 +router.get('/system-resource-details', (req, res) => { + try { + const { type } = req.query; + let data = {}; + + switch(type) { + case 'memory': + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + + data = { + totalMemory: formatBytes(totalMem), + usedMemory: formatBytes(usedMem), + freeMemory: formatBytes(freeMem), + memoryUsage: `${Math.round(usedMem / totalMem * 100)}%` + }; + break; + + case 'cpu': + const cpus = os.cpus(); + const loadAvg = os.loadavg(); + + data = { + cpuCores: cpus.length, + cpuModel: cpus[0].model, + cpuLoad: `${(loadAvg[0] / cpus.length * 100).toFixed(1)}%`, + loadAvg1: loadAvg[0].toFixed(2), + loadAvg5: loadAvg[1].toFixed(2), + loadAvg15: loadAvg[2].toFixed(2) + }; + break; + + case 'disk': + // 简单的硬编码数据,在实际环境中应该调用系统命令获取 + data = { + totalSpace: '100 GB', + usedSpace: '30 GB', + freeSpace: '70 GB', + diskUsage: '30%' + }; + break; + + default: + return res.status(400).json({ error: '无效的资源类型' }); + } + + res.json(data); + } catch (error) { + logger.error('获取系统资源详情失败:', error); + res.status(500).json({ error: '获取系统资源详情失败', details: error.message }); + } +}); + +// 格式化字节数为可读格式 +function formatBytes(bytes, decimals = 2) { + if (bytes === 0) return '0 Bytes'; + + const k = 1024; + const dm = decimals < 0 ? 0 : decimals; + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']; + + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; +} + +module.exports = router; diff --git a/hubcmdui/scripts/diagnostics.js b/hubcmdui/scripts/diagnostics.js new file mode 100644 index 0000000..a324be1 --- /dev/null +++ b/hubcmdui/scripts/diagnostics.js @@ -0,0 +1,163 @@ +/** + * 系统诊断工具 - 帮助找出可能存在的问题 + */ +const fs = require('fs').promises; +const path = require('path'); +const { execSync } = require('child_process'); +const logger = require('../logger'); + +// 检查所有必要的文件和目录是否存在 +async function checkFilesAndDirectories() { + logger.info('开始检查必要的文件和目录...'); + + // 检查必要的目录 + const requiredDirs = [ + { path: 'logs', critical: true }, + { path: 'documentation', critical: true }, + { path: 'web/images', critical: true }, + { path: 'routes', critical: true }, + { path: 'services', critical: true }, + { path: 'middleware', critical: true }, + { path: 'scripts', critical: false } + ]; + + const dirsStatus = {}; + for (const dir of requiredDirs) { + const fullPath = path.join(__dirname, '..', dir.path); + try { + await fs.access(fullPath); + dirsStatus[dir.path] = { exists: true, critical: dir.critical }; + logger.info(`目录存在: ${dir.path}`); + } catch (error) { + dirsStatus[dir.path] = { exists: false, critical: dir.critical }; + logger.error(`目录不存在: ${dir.path} (${dir.critical ? '关键' : '非关键'})`); + } + } + + // 检查必要的文件 + const requiredFiles = [ + { path: 'server.js', critical: true }, + { path: 'app.js', critical: false }, + { path: 'config.js', critical: true }, + { path: 'logger.js', critical: true }, + { path: 'init-dirs.js', critical: true }, + { path: 'download-images.js', critical: true }, + { path: 'cleanup.js', critical: true }, + { path: 'package.json', critical: true }, + { path: 'web/index.html', critical: true }, + { path: 'web/admin.html', critical: true } + ]; + + const filesStatus = {}; + for (const file of requiredFiles) { + const fullPath = path.join(__dirname, '..', file.path); + try { + await fs.access(fullPath); + filesStatus[file.path] = { exists: true, critical: file.critical }; + logger.info(`文件存在: ${file.path}`); + } catch (error) { + filesStatus[file.path] = { exists: false, critical: file.critical }; + logger.error(`文件不存在: ${file.path} (${file.critical ? '关键' : '非关键'})`); + } + } + + return { directories: dirsStatus, files: filesStatus }; +} + +// 检查Node.js模块依赖 +function checkNodeDependencies() { + logger.info('开始检查Node.js依赖...'); + + try { + // 执行npm list --depth=0来检查已安装的依赖 + const npmListOutput = execSync('npm list --depth=0', { encoding: 'utf8' }); + logger.info('已安装的依赖:\n' + npmListOutput); + + return { success: true, output: npmListOutput }; + } catch (error) { + logger.error('检查依赖时出错:', error.message); + return { success: false, error: error.message }; + } +} + +// 检查系统环境 +async function checkSystemEnvironment() { + logger.info('开始检查系统环境...'); + + const checks = { + node: process.version, + platform: process.platform, + arch: process.arch, + docker: null + }; + + try { + // 检查Docker是否可用 + const dockerVersion = execSync('docker --version', { encoding: 'utf8' }); + checks.docker = dockerVersion.trim(); + logger.info(`Docker版本: ${dockerVersion.trim()}`); + } catch (error) { + checks.docker = false; + logger.warn('Docker未安装或不可用'); + } + + return checks; +} + +// 运行诊断 +async function runDiagnostics() { + logger.info('======= 开始系统诊断 ======='); + + const results = { + filesAndDirs: await checkFilesAndDirectories(), + dependencies: checkNodeDependencies(), + environment: await checkSystemEnvironment() + }; + + // 检查关键错误 + const criticalErrors = []; + + // 检查关键目录 + Object.entries(results.filesAndDirs.directories).forEach(([dir, status]) => { + if (status.critical && !status.exists) { + criticalErrors.push(`关键目录丢失: ${dir}`); + } + }); + + // 检查关键文件 + Object.entries(results.filesAndDirs.files).forEach(([file, status]) => { + if (status.critical && !status.exists) { + criticalErrors.push(`关键文件丢失: ${file}`); + } + }); + + // 检查依赖 + if (!results.dependencies.success) { + criticalErrors.push('依赖检查失败'); + } + + // 总结 + logger.info('======= 诊断完成 ======='); + if (criticalErrors.length > 0) { + logger.error('发现关键错误:'); + criticalErrors.forEach(err => logger.error(`- ${err}`)); + logger.error('请解决以上问题后重试'); + } else { + logger.success('未发现关键错误,系统应该可以正常运行'); + } + + return { results, criticalErrors }; +} + +// 直接运行脚本时启动诊断 +if (require.main === module) { + runDiagnostics() + .then(() => { + logger.info('诊断完成'); + }) + .catch(error => { + logger.fatal('诊断过程中发生错误:', error); + }); +} + +module.exports = { runDiagnostics }; diff --git a/hubcmdui/scripts/init-menu.js b/hubcmdui/scripts/init-menu.js new file mode 100644 index 0000000..59831f0 --- /dev/null +++ b/hubcmdui/scripts/init-menu.js @@ -0,0 +1,25 @@ +const MenuItem = require('../models/MenuItem'); + +async function initMenuItems() { + const count = await MenuItem.countDocuments(); + if (count === 0) { + await MenuItem.insertMany([ + { + text: '首页', + link: '/', + icon: 'fa-home', + order: 1 + }, + { + text: '文档', + link: '/docs', + icon: 'fa-book', + order: 2 + } + // 添加更多默认菜单项... + ]); + console.log('默认菜单项已初始化'); + } +} + +module.exports = initMenuItems; \ No newline at end of file diff --git a/hubcmdui/scripts/init-system.js b/hubcmdui/scripts/init-system.js new file mode 100644 index 0000000..bb9cb0d --- /dev/null +++ b/hubcmdui/scripts/init-system.js @@ -0,0 +1,315 @@ +/** + * 系统初始化脚本 - 首次运行时执行 + */ +const fs = require('fs').promises; +const path = require('path'); +const bcrypt = require('bcrypt'); +const { execSync } = require('child_process'); +const logger = require('../logger'); +const { ensureDirectoriesExist } = require('../init-dirs'); +const { downloadImages } = require('../download-images'); +const configService = require('../services/configService'); + +// 用户文件路径 +const USERS_FILE = path.join(__dirname, '..', 'users.json'); + +/** + * 创建管理员用户 + * @param {string} username 用户名 + * @param {string} password 密码 + */ +async function createAdminUser(username = 'root', password = 'admin') { + try { + // 检查用户文件是否已存在 + try { + await fs.access(USERS_FILE); + logger.info('用户文件已存在,跳过创建管理员用户'); + return; + } catch (err) { + if (err.code !== 'ENOENT') throw err; + } + + // 创建默认管理员用户 + const defaultUser = { + username, + password: bcrypt.hashSync(password, 10), + createdAt: new Date().toISOString(), + loginCount: 0, + lastLogin: null + }; + + await fs.writeFile(USERS_FILE, JSON.stringify({ users: [defaultUser] }, null, 2)); + logger.success(`创建默认管理员用户: ${username}/${password}`); + logger.warn('请在首次登录后立即修改默认密码'); + } catch (error) { + logger.error('创建管理员用户失败:', error); + throw error; + } +} + +/** + * 创建默认配置 + */ +async function createDefaultConfig() { + try { + // 检查配置是否已存在 + const config = await configService.getConfig(); + + // 如果菜单项为空,添加默认菜单项 + if (!config.menuItems || config.menuItems.length === 0) { + config.menuItems = [ + { + text: "控制台", + link: "/admin", + newTab: false + }, + { + text: "镜像搜索", + link: "/", + newTab: false + }, + { + text: "文档", + link: "/docs", + newTab: false + }, + { + text: "GitHub", + link: "https://github.com/dqzboy/hubcmdui", + newTab: true + } + ]; + + await configService.saveConfig(config); + logger.success('创建默认菜单配置'); + } + + return config; + } catch (error) { + logger.error('初始化配置失败:', error); + throw error; + } +} + +/** + * 创建示例文档 - 现已禁用 + */ +async function createSampleDocumentation() { + logger.info('示例文档创建功能已禁用'); + return; // 不再创建默认文档 + + /* 旧代码保留注释,已禁用 + const docService = require('../services/documentationService'); + + try { + await docService.ensureDocumentationDir(); + + // 检查是否有现有文档 + const docs = await docService.getDocumentationList(); + if (docs && docs.length > 0) { + logger.info('文档已存在,跳过创建示例文档'); + return; + } + + // 创建示例文档 + const welcomeDoc = { + title: "欢迎使用 Docker 镜像代理加速系统", + content: `# 欢迎使用 Docker 镜像代理加速系统 + +## 系统简介 + +Docker 镜像代理加速系统是一个帮助用户快速搜索、拉取 Docker 镜像的工具。本系统提供了以下功能: + +- 快速搜索 Docker Hub 上的镜像 +- 查看镜像的详细信息和标签 +- 管理本地 Docker 容器 +- 监控容器状态并发送通知 + +## 快速开始 + +1. 在首页搜索框中输入要查找的镜像名称 +2. 点击搜索结果查看详细信息 +3. 使用提供的命令拉取镜像 + +## 管理功能 + +管理员可以通过控制面板管理系统: + +- 查看所有容器状态 +- 启动/停止/重启容器 +- 更新容器镜像 +- 配置监控告警 + +祝您使用愉快! +`, + published: true + }; + + const aboutDoc = { + title: "关于系统", + content: `# 关于 Docker 镜像代理加速系统 + +## 系统版本 + +当前版本: v1.0.0 + +## 技术栈 + +- 前端: HTML, CSS, JavaScript +- 后端: Node.js, Express +- 容器: Docker, Dockerode +- 数据存储: 文件系统 + +## 联系方式 + +如有问题,请通过以下方式联系我们: + +- GitHub Issues +- 电子邮件: example@example.com + +## 许可证 + +本项目采用 MIT 许可证 +`, + published: true + }; + + await docService.saveDocument(Date.now().toString(), welcomeDoc.title, welcomeDoc.content); + await docService.saveDocument((Date.now() + 1000).toString(), aboutDoc.title, aboutDoc.content); + + logger.success('创建示例文档成功'); + } catch (error) { + logger.error('创建示例文档失败:', error); + } + */ +} + +/** + * 检查必要依赖 + */ +async function checkDependencies() { + try { + logger.info('正在检查系统依赖...'); + + // 检查 Node.js 版本 + const nodeVersion = process.version; + const minNodeVersion = 'v14.0.0'; + if (compareVersions(nodeVersion, minNodeVersion) < 0) { + logger.warn(`当前 Node.js 版本 ${nodeVersion} 低于推荐的最低版本 ${minNodeVersion}`); + } else { + logger.success(`Node.js 版本 ${nodeVersion} 满足要求`); + } + + // 检查必要的 npm 包 + try { + const packageJson = require('../package.json'); + const requiredDeps = Object.keys(packageJson.dependencies); + + logger.info(`系统依赖共 ${requiredDeps.length} 个包`); + + // 检查是否有 node_modules 目录 + try { + await fs.access(path.join(__dirname, '..', 'node_modules')); + } catch (err) { + if (err.code === 'ENOENT') { + logger.warn('未找到 node_modules 目录,请运行 npm install 安装依赖'); + return false; + } + } + } catch (err) { + logger.warn('无法读取 package.json:', err.message); + } + + // 检查 Docker + try { + execSync('docker --version', { stdio: ['ignore', 'ignore', 'ignore'] }); + logger.success('Docker 已安装'); + } catch (err) { + logger.warn('未检测到 Docker,部分功能可能无法正常使用'); + } + + return true; + } catch (error) { + logger.error('依赖检查失败:', error); + return false; + } +} + +/** + * 比较版本号 + */ +function compareVersions(v1, v2) { + const v1parts = v1.replace('v', '').split('.'); + const v2parts = v2.replace('v', '').split('.'); + + for (let i = 0; i < Math.max(v1parts.length, v2parts.length); i++) { + const v1part = parseInt(v1parts[i] || 0); + const v2part = parseInt(v2parts[i] || 0); + + if (v1part > v2part) return 1; + if (v1part < v2part) return -1; + } + + return 0; +} + +/** + * 主初始化函数 + */ +async function initialize() { + logger.info('开始系统初始化...'); + + try { + // 1. 检查系统依赖 + await checkDependencies(); + + // 2. 确保目录结构存在 + await ensureDirectoriesExist(); + logger.success('目录结构初始化完成'); + + // 3. 下载必要图片 + await downloadImages(); + + // 4. 创建默认用户 + await createAdminUser(); + + // 5. 创建默认配置 + await createDefaultConfig(); + + // 6. 创建示例文档 + await createSampleDocumentation(); + + logger.success('系统初始化完成!'); + // 移除敏感的账户信息日志 + logger.warn('首次登录后请立即修改默认密码!'); + + return { success: true }; + } catch (error) { + logger.error('系统初始化失败:', error); + return { success: false, error: error.message }; + } +} + +// 如果直接运行此脚本 +if (require.main === module) { + initialize() + .then((result) => { + if (result.success) { + process.exit(0); + } else { + process.exit(1); + } + }) + .catch((error) => { + logger.fatal('初始化过程中发生错误:', error); + process.exit(1); + }); +} + +module.exports = { + initialize, + createAdminUser, + createDefaultConfig, + createSampleDocumentation, + checkDependencies +}; diff --git a/hubcmdui/server-utils.js b/hubcmdui/server-utils.js new file mode 100644 index 0000000..05d37ff --- /dev/null +++ b/hubcmdui/server-utils.js @@ -0,0 +1,333 @@ +/** + * 服务器实用工具函数 + */ + +const { exec } = require('child_process'); +const os = require('os'); +const fs = require('fs').promises; +const path = require('path'); +const logger = require('./logger'); + +/** + * 安全执行系统命令 + * @param {string} command - 要执行的命令 + * @param {object} options - 执行选项 + * @returns {Promise} 命令输出结果 + */ +function execCommand(command, options = { timeout: 30000 }) { + return new Promise((resolve, reject) => { + exec(command, options, (error, stdout, stderr) => { + if (error) { + if (error.killed) { + reject(new Error('执行命令超时')); + } else { + reject(error); + } + return; + } + resolve(stdout.trim() || stderr.trim()); + }); + }); +} + +/** + * 获取系统信息 + * @returns {Promise} 系统信息对象 + */ +async function getSystemInfo() { + const platform = os.platform(); + let memoryInfo = {}; + let cpuInfo = {}; + let diskInfo = {}; + + try { + // 内存信息 - 使用OS模块,适用于所有平台 + const totalMem = os.totalmem(); + const freeMem = os.freemem(); + const usedMem = totalMem - freeMem; + const memPercent = Math.round((usedMem / totalMem) * 100); + + memoryInfo = { + total: formatBytes(totalMem), + free: formatBytes(freeMem), + used: formatBytes(usedMem), + percent: memPercent + }; + + // CPU信息 - 使用不同平台的方法 + await getCpuInfo(platform).then(info => { + cpuInfo = info; + }).catch(err => { + logger.warn('获取CPU信息失败:', err); + // 降级方案:使用OS模块 + const cpuLoad = os.loadavg(); + const cpuCores = os.cpus().length; + + cpuInfo = { + model: os.cpus()[0].model, + cores: cpuCores, + load1: cpuLoad[0].toFixed(2), + load5: cpuLoad[1].toFixed(2), + load15: cpuLoad[2].toFixed(2), + percent: Math.round((cpuLoad[0] / cpuCores) * 100) + }; + }); + + // 磁盘信息 - 根据平台调用不同方法 + await getDiskInfo(platform).then(info => { + diskInfo = info; + }).catch(err => { + logger.warn('获取磁盘信息失败:', err); + diskInfo = { + filesystem: 'unknown', + size: 'unknown', + used: 'unknown', + available: 'unknown', + percent: '0%' + }; + }); + + return { + platform, + hostname: os.hostname(), + memory: memoryInfo, + cpu: cpuInfo, + disk: diskInfo, + uptime: formatUptime(os.uptime()) + }; + } catch (error) { + logger.error('获取系统信息失败:', error); + throw error; + } +} + +/** + * 根据平台获取CPU信息 + * @param {string} platform - 操作系统平台 + * @returns {Promise} CPU信息 + */ +async function getCpuInfo(platform) { + if (platform === 'linux') { + try { + // Linux平台使用/proc/stat和/proc/cpuinfo + const [loadData, cpuData] = await Promise.all([ + execCommand("cat /proc/loadavg"), + execCommand("cat /proc/cpuinfo | grep 'model name' | head -1") + ]); + + const cpuLoad = loadData.split(' ').slice(0, 3).map(parseFloat); + const cpuCores = os.cpus().length; + const modelMatch = cpuData.match(/model name\s*:\s*(.*)/); + const model = modelMatch ? modelMatch[1].trim() : os.cpus()[0].model; + const percent = Math.round((cpuLoad[0] / cpuCores) * 100); + + return { + model, + cores: cpuCores, + load1: cpuLoad[0].toFixed(2), + load5: cpuLoad[1].toFixed(2), + load15: cpuLoad[2].toFixed(2), + percent: percent > 100 ? 100 : percent + }; + } catch (error) { + throw error; + } + } else if (platform === 'darwin') { + // macOS平台 + try { + const cpuLoad = os.loadavg(); + const cpuCores = os.cpus().length; + const model = os.cpus()[0].model; + const systemProfilerData = await execCommand("system_profiler SPHardwareDataType | grep 'Processor Name'"); + const cpuMatch = systemProfilerData.match(/Processor Name:\s*(.*)/); + const cpuModel = cpuMatch ? cpuMatch[1].trim() : model; + + return { + model: cpuModel, + cores: cpuCores, + load1: cpuLoad[0].toFixed(2), + load5: cpuLoad[1].toFixed(2), + load15: cpuLoad[2].toFixed(2), + percent: Math.round((cpuLoad[0] / cpuCores) * 100) + }; + } catch (error) { + throw error; + } + } else if (platform === 'win32') { + // Windows平台 + try { + // 使用wmic获取CPU信息 + const cpuData = await execCommand('wmic cpu get Name,NumberOfCores /value'); + const cpuLines = cpuData.split('\r\n'); + + let model = os.cpus()[0].model; + let cores = os.cpus().length; + + cpuLines.forEach(line => { + if (line.startsWith('Name=')) { + model = line.substring(5).trim(); + } else if (line.startsWith('NumberOfCores=')) { + cores = parseInt(line.substring(14).trim()) || cores; + } + }); + + // Windows没有直接的负载平均值,使用CPU使用率作为替代 + const perfData = await execCommand('wmic cpu get LoadPercentage /value'); + const loadMatch = perfData.match(/LoadPercentage=(\d+)/); + const loadPercent = loadMatch ? parseInt(loadMatch[1]) : 0; + + return { + model, + cores, + load1: '不适用', + load5: '不适用', + load15: '不适用', + percent: loadPercent + }; + } catch (error) { + throw error; + } + } + + // 默认返回OS模块的信息 + const cpuLoad = os.loadavg(); + const cpuCores = os.cpus().length; + + return { + model: os.cpus()[0].model, + cores: cpuCores, + load1: cpuLoad[0].toFixed(2), + load5: cpuLoad[1].toFixed(2), + load15: cpuLoad[2].toFixed(2), + percent: Math.round((cpuLoad[0] / cpuCores) * 100) + }; +} + +/** + * 根据平台获取磁盘信息 + * @param {string} platform - 操作系统平台 + * @returns {Promise} 磁盘信息 + */ +async function getDiskInfo(platform) { + if (platform === 'linux' || platform === 'darwin') { + try { + // Linux/macOS使用df命令 + const diskCommand = platform === 'linux' + ? 'df -h / | tail -1' + : 'df -h / | tail -1'; + + const diskData = await execCommand(diskCommand); + const parts = diskData.trim().split(/\s+/); + + if (parts.length >= 5) { + return { + filesystem: parts[0], + size: parts[1], + used: parts[2], + available: parts[3], + percent: parts[4] + }; + } else { + throw new Error('磁盘信息格式不正确'); + } + } catch (error) { + throw error; + } + } else if (platform === 'win32') { + // Windows平台 + try { + // 使用wmic获取C盘信息 + const diskData = await execCommand('wmic logicaldisk where DeviceID="C:" get Size,FreeSpace /value'); + const lines = diskData.split(/\r\n|\n/); + let freeSpace, totalSize; + + lines.forEach(line => { + if (line.startsWith('FreeSpace=')) { + freeSpace = parseInt(line.split('=')[1]); + } else if (line.startsWith('Size=')) { + totalSize = parseInt(line.split('=')[1]); + } + }); + + if (freeSpace !== undefined && totalSize !== undefined) { + const usedSpace = totalSize - freeSpace; + const usedPercent = Math.round((usedSpace / totalSize) * 100); + + return { + filesystem: 'C:', + size: formatBytes(totalSize), + used: formatBytes(usedSpace), + available: formatBytes(freeSpace), + percent: `${usedPercent}%` + }; + } else { + throw new Error('无法解析Windows磁盘信息'); + } + } catch (error) { + throw error; + } + } + + // 默认尝试df命令 + try { + const diskData = await execCommand('df -h / | tail -1'); + const parts = diskData.trim().split(/\s+/); + + if (parts.length >= 5) { + return { + filesystem: parts[0], + size: parts[1], + used: parts[2], + available: parts[3], + percent: parts[4] + }; + } else { + throw new Error('磁盘信息格式不正确'); + } + } catch (error) { + throw error; + } +} + +/** + * 将字节格式化为可读大小 + * @param {number} bytes - 字节数 + * @returns {string} 格式化后的字符串 + */ +function formatBytes(bytes) { + if (bytes === 0) return '0 B'; + + const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + + return parseFloat((bytes / Math.pow(1024, i)).toFixed(2)) + ' ' + sizes[i]; +} + +/** + * 格式化运行时间 + * @param {number} seconds - 秒数 + * @returns {string} 格式化后的运行时间 + */ +function formatUptime(seconds) { + const days = Math.floor(seconds / 86400); + seconds %= 86400; + const hours = Math.floor(seconds / 3600); + seconds %= 3600; + const minutes = Math.floor(seconds / 60); + seconds = Math.floor(seconds % 60); + + const parts = []; + if (days > 0) parts.push(`${days}天`); + if (hours > 0) parts.push(`${hours}小时`); + if (minutes > 0) parts.push(`${minutes}分钟`); + if (seconds > 0 && parts.length === 0) parts.push(`${seconds}秒`); + + return parts.join(' '); +} + +module.exports = { + execCommand, + getSystemInfo, + formatBytes, + formatUptime +}; diff --git a/hubcmdui/server.js b/hubcmdui/server.js index 970070c..3089ac4 100644 --- a/hubcmdui/server.js +++ b/hubcmdui/server.js @@ -1,1796 +1,221 @@ +/** + * Docker 镜像代理加速系统 - 服务器入口点 + */ const express = require('express'); const fs = require('fs').promises; const path = require('path'); const bodyParser = require('body-parser'); const session = require('express-session'); -const bcrypt = require('bcrypt'); -const crypto = require('crypto'); -const axios = require('axios'); // 用于发送 HTTP 请求 -const Docker = require('dockerode'); -const app = express(); const cors = require('cors'); -const WebSocket = require('ws'); const http = require('http'); -const { exec } = require('child_process'); // 网络测试 -const validator = require('validator'); const logger = require('./logger'); -const pLimit = require('p-limit'); -const axiosRetry = require('axios-retry'); -const NodeCache = require('node-cache'); +const { ensureDirectoriesExist } = require('./init-dirs'); +const { downloadImages } = require('./download-images'); +const { gracefulShutdown } = require('./cleanup'); +const os = require('os'); +const { requireLogin } = require('./middleware/auth'); +const compatibilityLayer = require('./compatibility-layer'); +const initSystem = require('./scripts/init-system'); -// 创建请求缓存,TTL为10分钟 -const requestCache = new NodeCache({ stdTTL: 600, checkperiod: 120 }); +// 设置日志级别 (默认INFO, 可通过环境变量设置) +const logLevel = process.env.LOG_LEVEL || 'INFO'; +logger.setLogLevel(logLevel); +logger.info(`日志级别已设置为: ${logLevel}`); -// 配置并发限制,最多5个并发请求 -const limit = pLimit(5); +// 导入配置 +const config = require('./config'); -// 配置Axios重试 -axiosRetry(axios, { - retries: 3, // 最多重试3次 - retryDelay: (retryCount) => { - console.log(`[INFO] 重试 Docker Hub 请求 (${retryCount}/3)`); - return retryCount * 1000; // 重试延迟,每次递增1秒 - }, - retryCondition: (error) => { - // 只在网络错误或5xx响应时重试 - return axiosRetry.isNetworkOrIdempotentRequestError(error) || - (error.response && error.response.status >= 500); - } -}); +// 导入中间件 +const { sessionActivity, sanitizeRequestBody, securityHeaders } = require('./middleware/auth'); -// 优化HTTP请求配置 -const httpOptions = { - timeout: 15000, // 15秒超时 - headers: { - 'User-Agent': 'DockerHubSearchClient/1.0', - 'Accept': 'application/json' - } -}; +// 导入初始化调度器 +const { executeOnce } = require('./lib/initScheduler'); -let docker = null; -let containerStates = new Map(); -let lastStopAlertTime = new Map(); -let secondAlertSent = new Set(); - -async function initDocker() { - if (docker === null) { - docker = new Docker(); - try { - await docker.ping(); - logger.success('成功连接到 Docker 守护进程'); - } catch (err) { - logger.error(`无法连接到 Docker 守护进程: ${err.message}`); - docker = null; - } - } - return docker; -} +// 初始化Express应用 +const app = express(); +const server = http.createServer(app); +// 配置中间件 app.use(cors()); app.use(express.json()); app.use(express.static('web')); app.use(bodyParser.urlencoded({ extended: true })); app.use(session({ - secret: 'OhTq3faqSKoxbV%NJV', - resave: false, + secret: config.sessionSecret || 'OhTq3faqSKoxbV%NJV', + resave: true, saveUninitialized: true, - cookie: { secure: false } // 设置为true如果使用HTTPS + cookie: { + secure: config.secureSession || false, + maxAge: 7 * 24 * 60 * 60 * 1000 // 7天(一周) + } })); -// 添加请求日志中间件 +// 自定义中间件 +app.use(sessionActivity); +app.use(sanitizeRequestBody); +app.use(securityHeaders); + +// 请求日志中间件 app.use((req, res, next) => { const start = Date.now(); // 在响应完成后记录日志 res.on('finish', () => { const duration = Date.now() - start; - logger.request(req, res, duration); + + // 增强过滤条件 + const isSuccessfulGet = req.method === 'GET' && (res.statusCode === 200 || res.statusCode === 304); + const isStaticResource = req.url.match(/\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/i); + const isCommonApiRequest = req.url.startsWith('/api/') && + (req.url.includes('/check-session') || + req.url.includes('/system-resources') || + req.url.includes('/docker/status')); + const isErrorResponse = res.statusCode >= 400; + + // 只记录关键API请求和错误响应,过滤普通的API请求和静态资源 + if ((isErrorResponse || + (req.url.startsWith('/api/') && !isCommonApiRequest)) && + !isStaticResource && + !(isSuccessfulGet && isCommonApiRequest)) { + + // 记录简化的请求信息 + req.skipDetailedLogging = !isErrorResponse; // 非错误请求跳过详细日志 + logger.request(req, res, duration); + } }); next(); }); -// 替换之前的morgan中间件 -// app.use(require('morgan')('dev')); +// 使用我们的路由注册函数加载所有路由 +logger.info('注册所有应用路由...'); +const registerRoutes = require('./routes'); +registerRoutes(app); -// 正确顺序注册API路由,避免冲突 -// 先注册特定路由,后注册通用路由 +// 提供兼容层以确保旧接口继续工作 +require('./compatibility-layer')(app); + +// 确保登录路由可用 +try { + const loginRouter = require('./routes/login'); + app.use('/api', loginRouter); + logger.success('✓ 已添加备用登录路由'); +} catch (loginError) { + logger.error('无法加载备用登录路由:', loginError); +} + +// 页面路由 +app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, 'web', 'index.html')); +}); app.get('/admin', (req, res) => { res.sendFile(path.join(__dirname, 'web', 'admin.html')); }); -// 新增:Docker Hub 搜索 API -app.get('/api/search', async (req, res) => { - const searchTerm = req.query.term; - if (!searchTerm) { - return res.status(400).json({ error: 'Search term is required' }); - } - - try { - const response = await axios.get(`https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(searchTerm)}`); - res.json(response.data); - } catch (error) { - logger.error('Error searching Docker Hub:', error); - res.status(500).json({ error: 'Failed to search Docker Hub' }); - } +app.get('/docs', (req, res) => { + res.sendFile(path.join(__dirname, 'web', 'docs.html')); }); -// 代理Docker Hub搜索API -app.get('/api/dockerhub/search', async (req, res) => { - const term = req.query.term; - const page = req.query.page || 1; - - if (!term) { - return res.status(400).json({ error: '搜索词不能为空' }); - } - - try { - const cacheKey = `search_${term}_${page}`; - const cachedResult = requestCache.get(cacheKey); - - if (cachedResult) { - console.log(`[INFO] 返回缓存的搜索结果: ${term} (页码: ${page})`); - return res.json(cachedResult); - } - - console.log(`[INFO] 搜索Docker Hub: ${term} (页码: ${page})`); - - // 使用pLimit进行并发控制的请求 - const result = await limit(async () => { - const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=25`; - const response = await axios.get(url, httpOptions); - return response.data; - }); - - // 将结果缓存 - requestCache.set(cacheKey, result); - res.json(result); - - } catch (error) { - handleAxiosError(error, res, '搜索Docker Hub失败'); - } +// 废弃的登录页面路由 - 该路由未使用且导致404错误,现已移除 +// app.get('/login', (req, res) => { +// // 检查用户是否已登录 +// if (req.session && req.session.user) { +// return res.redirect('/admin'); // 已登录用户重定向到管理页面 +// } +// +// res.sendFile(path.join(__dirname, 'web', 'login.html')); +// }); + +// 404处理 +app.use((req, res) => { + res.status(404).json({ error: '请求的资源不存在' }); }); -// 代理Docker Hub TAG API - 改进异常处理和错误响应格式以及过滤无效平台信息 -app.get('/api/dockerhub/tags', async (req, res) => { - const name = req.query.name; - const isOfficial = req.query.official === 'true'; - const page = req.query.page || 1; - const pageSize = req.query.page_size || 25; - - if (!name) { - return res.status(400).json({ error: '镜像名称不能为空' }); - } - - try { - const cacheKey = `tags_${name}_${isOfficial}_${page}_${pageSize}`; - const cachedResult = requestCache.get(cacheKey); - - if (cachedResult) { - console.log(`[INFO] 返回缓存的标签列表: ${name} (页码: ${page}, 每页数量: ${pageSize})`); - return res.json(cachedResult); - } - - let apiUrl; - if (isOfficial) { - apiUrl = `https://hub.docker.com/v2/repositories/library/${name}/tags/?page=${page}&page_size=${pageSize}`; - } else { - apiUrl = `https://hub.docker.com/v2/repositories/${name}/tags/?page=${page}&page_size=${pageSize}`; - } - - // 使用pLimit进行并发控制的请求 - const result = await limit(async () => { - const response = await axios.get(apiUrl, httpOptions); - return response.data; - }); - - // 对结果进行预处理,确保images字段存在 - if (result.results) { - result.results.forEach(tag => { - if (!tag.images || !Array.isArray(tag.images)) { - tag.images = []; - } - }); - } - - // 将结果缓存 - requestCache.set(cacheKey, result); - res.json(result); - - } catch (error) { - handleAxiosError(error, res, '获取标签列表失败'); - } -}); - -// 代理Docker Hub TAG API - 改进异常处理和错误响应格式以及过滤无效平台信息 -app.get('/api/dockerhub/tags', async (req, res) => { - try { - const imageName = req.query.name; - const isOfficial = req.query.official === 'true'; - const page = parseInt(req.query.page) || 1; - const page_size = parseInt(req.query.page_size) || 25; // 默认改为25个标签 - const getAllTags = req.query.all === 'true'; // 是否获取所有标签 - - if (!imageName) { - return res.status(400).json({ error: '镜像名称不能为空' }); - } - - // 构建基本参数 - const fullImageName = isOfficial ? `library/${imageName}` : imageName; - - if (getAllTags) { - try { - logger.info(`获取所有镜像标签: ${fullImageName}`); - // 为所有标签请求设置超时限制 - const allTagsPromise = fetchAllTags(fullImageName); - const timeoutPromise = new Promise((_, reject) => - setTimeout(() => reject(new Error('获取所有标签超时')), 30000) - ); - - // 使用Promise.race确保请求不会无限等待 - const allTags = await Promise.race([allTagsPromise, timeoutPromise]); - - // 过滤掉无效平台信息 - const cleanedTags = allTags.map(tag => { - if (tag.images && Array.isArray(tag.images)) { - tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown')); - } - return tag; - }); - - return res.json({ - count: cleanedTags.length, - results: cleanedTags, - all_pages_loaded: true - }); - } catch (error) { - logger.error(`获取所有标签失败: ${error.message}`); - return res.status(500).json({ error: `获取所有标签失败: ${error.message}` }); - } - } else { - // 常规分页获取 - logger.info(`获取镜像标签: ${fullImageName}, 页码: ${page}, 页面大小: ${page_size}`); - const tagsUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${page_size}`; - - try { - // 添加超时和重试配置 - const tagsResponse = await axios.get(tagsUrl, { - timeout: 15000, // 15秒超时 - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' - } - }); - - // 检查是否有有效的响应数据 - if (!tagsResponse.data || typeof tagsResponse.data !== 'object') { - logger.warn(`镜像 ${fullImageName} 返回的数据格式不正确`); - return res.status(500).json({ error: `获取标签列表失败: 响应数据格式不正确` }); - } - - if (!tagsResponse.data.results || !Array.isArray(tagsResponse.data.results)) { - logger.warn(`镜像 ${fullImageName} 没有返回有效的标签数据`); - return res.json({ count: 0, results: [] }); - } - - // 过滤掉无效平台信息 - const cleanedResults = tagsResponse.data.results.map(tag => { - if (tag.images && Array.isArray(tag.images)) { - tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown')); - } - return tag; - }); - - return res.json({ - ...tagsResponse.data, - results: cleanedResults - }); - } catch (error) { - // 更详细的错误日志记录和响应 - logger.error(`获取标签列表失败: ${error.message}`, { - url: tagsUrl, - status: error.response?.status, - statusText: error.response?.statusText, - data: error.response?.data - }); - - // 确保返回一个格式化良好的错误响应 - return res.status(500).json({ - error: `获取标签列表失败: ${error.message}`, - details: error.response?.data || error.message - }); - } - } - } catch (error) { - logger.error(`获取TAG失败:`, error.message); - return res.status(500).json({ error: '获取TAG失败,请稍后重试', details: error.message }); - } -}); - -// 辅助函数: 递归获取所有标签 - 修复错误处理和添加页面限制 -async function fetchAllTags(fullImageName, page = 1, allTags = [], maxPages = 10) { - try { - // 限制最大页数,防止无限递归 - if (page > maxPages) { - logger.warn(`达到最大页数限制 (${maxPages}),停止获取更多标签`); - return allTags; - } - - const pageSize = 100; // 使用最大页面大小 - const url = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`; - - logger.info(`获取标签页 ${page}/${maxPages}...`); - - const response = await axios.get(url, { - timeout: 10000, // 10秒超时 - headers: { - 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' - } - }); - - if (!response.data.results || !Array.isArray(response.data.results)) { - logger.warn(`页 ${page} 没有有效的标签数据`); - return allTags; - } - - allTags.push(...response.data.results); - logger.info(`已获取 ${allTags.length}/${response.data.count || 'unknown'} 个标签`); - - // 检查是否有下一页 - if (response.data.next && allTags.length < response.data.count) { - // 添加一些延迟以避免请求过快 - await new Promise(resolve => setTimeout(resolve, 500)); - return fetchAllTags(fullImageName, page + 1, allTags, maxPages); - } - - logger.success(`成功获取所有 ${allTags.length} 个标签`); - return allTags; - } catch (error) { - logger.error(`递归获取标签失败 (页码 ${page}): ${error.message}`); - // 如果已经获取了一些标签,返回这些标签而不是抛出错误 - if (allTags.length > 0) { - logger.info(`尽管出错,仍返回已获取的 ${allTags.length} 个标签`); - return allTags; - } - throw error; // 如果没有获取到任何标签,则抛出错误 - } -} - -// API 端点: 获取镜像标签计数 - 修复路由定义 -app.get('/api/dockerhub/tag-count', async (req, res) => { - const name = req.query.name; - const isOfficial = req.query.official === 'true'; - - if (!name) { - return res.status(400).json({ error: '镜像名称不能为空' }); - } - - try { - const cacheKey = `tag_count_${name}_${isOfficial}`; - const cachedResult = requestCache.get(cacheKey); - - if (cachedResult) { - console.log(`[INFO] 返回缓存的标签计数: ${name}`); - return res.json(cachedResult); - } - - let apiUrl; - if (isOfficial) { - apiUrl = `https://hub.docker.com/v2/repositories/library/${name}/tags/?page_size=1`; - } else { - apiUrl = `https://hub.docker.com/v2/repositories/${name}/tags/?page_size=1`; - } - - // 使用pLimit进行并发控制的请求 - const result = await limit(async () => { - const response = await axios.get(apiUrl, httpOptions); - return { - count: response.data.count, - recommended_mode: response.data.count > 500 ? 'paginated' : 'full' - }; - }); - - // 将结果缓存 - requestCache.set(cacheKey, result); - res.json(result); - - } catch (error) { - handleAxiosError(error, res, '获取标签计数失败'); - } -}); - -const CONFIG_FILE = path.join(__dirname, 'config.json'); -const USERS_FILE = path.join(__dirname, 'users.json'); -const DOCUMENTATION_DIR = path.join(__dirname, 'documentation'); -const DOCUMENTATION_FILE = path.join(__dirname, 'documentation.md'); - -// 读取配置 -async function readConfig() { - try { - const data = await fs.readFile(CONFIG_FILE, 'utf8'); - let config; - if (!data.trim()) { - config = { - logo: '', - menuItems: [], - adImages: [], - monitoringConfig: { - webhookUrl: '', - monitorInterval: 60, - isEnabled: false - } - }; - } else { - config = JSON.parse(data); - } - - // 确保 monitoringConfig 存在,如果不存在则添加默认值 - if (!config.monitoringConfig) { - config.monitoringConfig = { - webhookUrl: '', - monitorInterval: 60, - isEnabled: false - }; - } - - return config; - } catch (error) { - logger.error('Failed to read config:', error); - if (error.code === 'ENOENT') { - return { - logo: '', - menuItems: [], - adImages: [], - monitoringConfig: { - webhookUrl: '', - monitorInterval: 60, - isEnabled: false - } - }; - } - throw error; - } -} - -// 写入配置 -async function writeConfig(config) { - try { - await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8'); - logger.success('Config saved successfully'); - } catch (error) { - logger.error('Failed to save config:', error); - throw error; - } -} - -// 读取用户 -async function readUsers() { - try { - const data = await fs.readFile(USERS_FILE, 'utf8'); - return JSON.parse(data); - } catch (error) { - if (error.code === 'ENOENT') { - logger.warn('Users file does not exist, creating default user'); - const defaultUser = { username: 'root', password: bcrypt.hashSync('admin', 10) }; - await writeUsers([defaultUser]); - return { users: [defaultUser] }; - } - throw error; - } -} - -// 写入用户 -async function writeUsers(users) { - await fs.writeFile(USERS_FILE, JSON.stringify({ users }, null, 2), 'utf8'); -} - -// 确保 documentation 目录存在 -async function ensureDocumentationDir() { - try { - await fs.access(DOCUMENTATION_DIR); - } catch (error) { - if (error.code === 'ENOENT') { - await fs.mkdir(DOCUMENTATION_DIR); - } else { - throw error; - } - } -} - -// 读取文档 -async function readDocumentation() { - try { - await ensureDocumentationDir(); - const files = await fs.readdir(DOCUMENTATION_DIR); - const documents = await Promise.all(files.map(async file => { - const filePath = path.join(DOCUMENTATION_DIR, file); - const content = await fs.readFile(filePath, 'utf8'); - const doc = JSON.parse(content); - return { - id: path.parse(file).name, - title: doc.title, - content: doc.content, - published: doc.published - }; - })); - - const publishedDocuments = documents.filter(doc => doc.published); - return publishedDocuments; - } catch (error) { - logger.error('Error reading documentation:', error); - throw error; - } -} - -// 写入文档 -async function writeDocumentation(content) { - await fs.writeFile(DOCUMENTATION_FILE, content, 'utf8'); -} - -// 登录验证 -app.post('/api/login', async (req, res) => { - const { username, password, captcha } = req.body; - if (req.session.captcha !== parseInt(captcha)) { - logger.warn(`Captcha verification failed for user: ${username}`); - return res.status(401).json({ error: '验证码错误' }); - } - - const users = await readUsers(); - const user = users.users.find(u => u.username === username); - if (!user) { - logger.warn(`User ${username} not found`); - return res.status(401).json({ error: '用户名或密码错误' }); - } - - if (bcrypt.compareSync(req.body.password, user.password)) { - req.session.user = { username: user.username }; - logger.info(`User ${username} logged in successfully`); - res.json({ success: true }); - } else { - logger.warn(`Login failed for user: ${username}`); - res.status(401).json({ error: '用户名或密码错误' }); - } -}); - -// 修改密码 -app.post('/api/change-password', async (req, res) => { - if (!req.session.user) { - return res.status(401).json({ error: 'Not logged in' }); - } - const { currentPassword, newPassword } = req.body; - const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/; - if (!passwordRegex.test(newPassword)) { - return res.status(400).json({ error: 'Password must be 8-16 characters long and contain at least one letter, one number, and one special character' }); - } - const users = await readUsers(); - const user = users.users.find(u => u.username === req.session.user.username); - if (user && bcrypt.compareSync(currentPassword, user.password)) { - user.password = bcrypt.hashSync(newPassword, 10); - await writeUsers(users.users); - res.json({ success: true }); - } else { - res.status(401).json({ error: 'Invalid current password' }); - } -}); - -// 需要登录验证的中间件 -function requireLogin(req, res, next) { - // 不再记录会话详细信息 - if (req.session.user) { - next(); - } else { - logger.warn('用户未登录'); - res.status(401).json({ error: 'Not logged in' }); - } -} - -// API 端点:获取配置 -app.get('/api/config', async (req, res) => { - try { - const config = await readConfig(); - res.json(config); - } catch (error) { - res.status(500).json({ error: 'Failed to read config' }); - } -}); - -// API 端点:保存配置 -app.post('/api/config', requireLogin, async (req, res) => { - try { - const currentConfig = await readConfig(); - const newConfig = { ...currentConfig, ...req.body }; - await writeConfig(newConfig); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: 'Failed to save config' }); - } -}); - -// API 端点:检查会话状态 -app.get('/api/check-session', (req, res) => { - if (req.session.user) { - res.json({ success: true }); - } else { - res.status(401).json({ error: 'Not logged in' }); - } -}); - -// API 端点:生成验证码 -app.get('/api/captcha', (req, res) => { - const num1 = Math.floor(Math.random() * 10); - const num2 = Math.floor(Math.random() * 10); - const captcha = `${num1} + ${num2} = ?`; - req.session.captcha = num1 + num2; - res.json({ captcha }); -}); - -// API端点:获取文档列表 -app.get('/api/documentation-list', requireLogin, async (req, res) => { - try { - const files = await fs.readdir(DOCUMENTATION_DIR); - const documents = await Promise.all(files.map(async file => { - const content = await fs.readFile(path.join(DOCUMENTATION_DIR, file), 'utf8'); - const doc = JSON.parse(content); - return { id: path.parse(file).name, ...doc }; - })); - res.json(documents); - } catch (error) { - res.status(500).json({ error: '读取文档列表失败' }); - } -}); - -// API端点:保存文档 -app.post('/api/documentation', requireLogin, async (req, res) => { - try { - const { id, title, content } = req.body; - const docId = id || Date.now().toString(); - const docPath = path.join(DOCUMENTATION_DIR, `${docId}.json`); - await fs.writeFile(docPath, JSON.stringify({ title, content, published: false })); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: '保存文档失败' }); - } -}); - -// API端点:删除文档 -app.delete('/api/documentation/:id', requireLogin, async (req, res) => { - try { - const docPath = path.join(DOCUMENTATION_DIR, `${req.params.id}.json`); - await fs.unlink(docPath); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: '删除文档失败' }); - } -}); - -// API端点:切换文档发布状态 -app.post('/api/documentation/:id/toggle-publish', requireLogin, async (req, res) => { - try { - const docPath = path.join(DOCUMENTATION_DIR, `${req.params.id}.json`); - const content = await fs.readFile(docPath, 'utf8'); - const doc = JSON.parse(content); - doc.published = !doc.published; - await fs.writeFile(docPath, JSON.stringify(doc)); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: '更改发布状态失败' }); - } -}); - -// API端点:获取文档 -app.get('/api/documentation', async (req, res) => { - try { - const documents = await readDocumentation(); - res.json(documents); - } catch (error) { - logger.error('Error in /api/documentation:', error); - res.status(500).json({ error: '读取文档失败', details: error.message }); - } -}); - -// API端点:保存文档 -app.post('/api/documentation', requireLogin, async (req, res) => { - try { - const { content } = req.body; - await writeDocumentation(content); - res.json({ success: true }); - } catch (error) { - res.status(500).json({ error: '保存文档失败' }); - } -}); - -// 获取文档列表函数 -async function getDocumentList() { - try { - await ensureDocumentationDir(); - const files = await fs.readdir(DOCUMENTATION_DIR); - logger.info('Files in documentation directory:', files); - - const documents = await Promise.all(files.map(async file => { - try { - const filePath = path.join(DOCUMENTATION_DIR, file); - const content = await fs.readFile(filePath, 'utf8'); - return { - id: path.parse(file).name, - title: path.parse(file).name, // 使用文件名作为标题 - content: content, - published: true // 假设所有文档都是已发布的 - }; - } catch (fileError) { - logger.error(`Error reading file ${file}:`, fileError); - return null; - } - })); - - const validDocuments = documents.filter(doc => doc !== null); - logger.info('Valid documents:', validDocuments); - - return validDocuments; - } catch (error) { - logger.error('Error reading document list:', error); - throw error; // 重新抛出错误,让上层函数处理 - } -} - -app.get('/api/documentation-list', async (req, res) => { - try { - const documents = await getDocumentList(); - res.json(documents); - } catch (error) { - logger.error('Error in /api/documentation-list:', error); - res.status(500).json({ - error: '读取文档列表失败', - details: error.message, - stack: error.stack - }); - } -}); - -app.get('/api/documentation/:id', async (req, res) => { - try { - const docId = req.params.id; - const docPath = path.join(DOCUMENTATION_DIR, `${docId}.json`); - const content = await fs.readFile(docPath, 'utf8'); - const doc = JSON.parse(content); - res.json(doc); - } catch (error) { - logger.error('Error reading document:', error); - res.status(500).json({ error: '读取文档失败', details: error.message }); - } -}); - -// API端点来获取Docker容器状态 -app.get('/api/docker-status', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const containers = await docker.listContainers({ all: true }); - const containerStatus = await Promise.all(containers.map(async (container) => { - const containerInfo = await docker.getContainer(container.Id).inspect(); - const stats = await docker.getContainer(container.Id).stats({ stream: false }); - - // 计算 CPU 使用率 - const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; - const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; - const cpuUsage = (cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100; - // 计算内存使用率 - const memoryUsage = stats.memory_stats.usage / stats.memory_stats.limit * 100; - return { - id: container.Id.slice(0, 12), - name: container.Names[0].replace(/^\//, ''), - image: container.Image, - state: containerInfo.State.Status, - status: container.Status, - cpu: cpuUsage.toFixed(2) + '%', - memory: memoryUsage.toFixed(2) + '%', - created: new Date(container.Created * 1000).toLocaleString() - }; - })); - res.json(containerStatus); - } catch (error) { - logger.error('获取 Docker 状态时出错:', error); - res.status(500).json({ error: '获取 Docker 状态失败', details: error.message }); - } -}); - -// API端点:重启容器 -app.post('/api/docker/restart/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - await container.restart(); - res.json({ success: true }); - } catch (error) { - logger.error('重启容器失败:', error); - res.status(500).json({ error: '重启容器失败', details: error.message }); - } -}); - -// API端点:停止容器 -app.post('/api/docker/stop/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - await container.stop(); - res.json({ success: true }); - } catch (error) { - logger.error('停止容器失败:', error); - res.status(500).json({ error: '停止容器失败', details: error.message }); - } -}); - -// API端点:获取单个容器的状态 -app.get('/api/docker/status/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - const containerInfo = await container.inspect(); - res.json({ state: containerInfo.State.Status }); - } catch (error) { - logger.error('获取容器状态失败:', error); - res.status(500).json({ error: '获取容器状态失败', details: error.message }); - } -}); - -// API端点:更新容器 -app.post('/api/docker/update/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - const containerInfo = await container.inspect(); - const currentImage = containerInfo.Config.Image; - const [imageName] = currentImage.split(':'); - const newImage = `${imageName}:${req.body.tag}`; - const containerName = containerInfo.Name.slice(1); // 去掉开头的 '/' - - logger.info(`Updating container ${req.params.id} from ${currentImage} to ${newImage}`); - // 拉取新镜像 - logger.info(`Pulling new image: ${newImage}`); - await new Promise((resolve, reject) => { - docker.pull(newImage, (err, stream) => { - if (err) return reject(err); - docker.modem.followProgress(stream, (err, output) => err ? reject(err) : resolve(output)); - }); - }); - // 停止旧容器 - logger.info('Stopping old container'); - await container.stop(); - // 删除旧容器 - logger.info('Removing old container'); - await container.remove(); - // 创建新容器 - logger.info('Creating new container'); - const newContainerConfig = { - ...containerInfo.Config, - Image: newImage, - HostConfig: containerInfo.HostConfig, - NetworkingConfig: { - EndpointsConfig: containerInfo.NetworkSettings.Networks - } - }; - const newContainer = await docker.createContainer({ - ...newContainerConfig, - name: containerName - }); - // 启动新容器 - logger.info('Starting new container'); - await newContainer.start(); - - logger.success('Container update completed successfully'); - res.json({ success: true, message: '容器更新成功' }); - } catch (error) { - logger.error('更新容器失败:', error); - res.status(500).json({ error: '更新容器失败', details: error.message, stack: error.stack }); - } -}); - -// API端点:获取容器日志 -app.get('/api/docker/logs/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - const logs = await container.logs({ - stdout: true, - stderr: true, - tail: 100, // 获取最后100行日志 - follow: false - }); - res.send(logs); - } catch (error) { - logger.error('获取容器日志失败:', error); - res.status(500).json({ error: '获取容器日志失败', details: error.message }); - } -}); - -const server = http.createServer(app); -const wss = new WebSocket.Server({ server }); - -wss.on('connection', async (ws, req) => { - const containerId = req.url.split('/').pop(); - const docker = await initDocker(); - if (!docker) { - ws.send('Error: 无法连接到 Docker 守护进程'); - return; - } - const container = docker.getContainer(containerId); - container.logs({ - follow: true, - stdout: true, - stderr: true, - tail: 100 - }, (err, stream) => { - if (err) { - ws.send('Error: ' + err.message); - return; - } - - stream.on('data', (chunk) => { - // 移除 ANSI 转义序列 - const cleanedChunk = chunk.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, ''); - // 移除不可打印字符 - const printableChunk = cleanedChunk.replace(/[^\x20-\x7E\x0A\x0D]/g, ''); - ws.send(printableChunk); - }); - - ws.on('close', () => { - stream.destroy(); - }); - }); -}); - -// API端点:删除容器 -app.post('/api/docker/delete/:id', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const container = docker.getContainer(req.params.id); - // 首先停止容器(如果正在运行) - try { - await container.stop(); - } catch (stopError) { - logger.info('Container may already be stopped:', stopError.message); - } - - // 然后删除容器 - await container.remove(); - res.json({ success: true, message: '容器已成功删除' }); - } catch (error) { - logger.error('删除容器失败:', error); - res.status(500).json({ error: '删除容器失败', details: error.message }); - } -}); - -app.get('/api/docker/logs-poll/:id', async (req, res) => { - const { id } = req.params; - try { - const container = docker.getContainer(id); - const logs = await container.logs({ - stdout: true, - stderr: true, - tail: 100, // 获取最后100行日志 - follow: false - }); - res.send(logs); - } catch (error) { - res.status(500).send('获取日志失败'); - } -}); - -// 网络测试 -const { execSync } = require('child_process'); -const pingPath = execSync('which ping').toString().trim(); -const traceroutePath = execSync('which traceroute').toString().trim(); - -app.post('/api/network-test', requireLogin, (req, res) => { - const { type, domain } = req.body; - let command; - - switch (type) { - case 'ping': - command = `${pingPath} -c 4 ${domain}`; - break; - case 'traceroute': - command = `${traceroutePath} -m 10 ${domain}`; - break; - default: - return res.status(400).send('无效的测试类型'); - } - - exec(command, { timeout: 30000 }, (error, stdout, stderr) => { - if (error) { - logger.error(`执行出错: ${error}`); - return res.status(500).send('测试执行失败'); - } - res.send(stdout || stderr); - }); -}); - -// docker 监控 -app.get('/api/monitoring-config', requireLogin, async (req, res) => { - try { - const config = await readConfig(); - res.json({ - notificationType: config.monitoringConfig.notificationType || 'wechat', - webhookUrl: config.monitoringConfig.webhookUrl, - telegramToken: config.monitoringConfig.telegramToken, - telegramChatId: config.monitoringConfig.telegramChatId, - monitorInterval: config.monitoringConfig.monitorInterval, - isEnabled: config.monitoringConfig.isEnabled - }); - } catch (error) { - logger.error('Failed to get monitoring config:', error); - res.status(500).json({ error: 'Failed to get monitoring config', details: error.message }); - } -}); - -app.post('/api/monitoring-config', requireLogin, async (req, res) => { - try { - const { notificationType, webhookUrl, telegramToken, telegramChatId, monitorInterval, isEnabled } = req.body; - const config = await readConfig(); - config.monitoringConfig = { - notificationType, - webhookUrl, - telegramToken, - telegramChatId, - monitorInterval: parseInt(monitorInterval), - isEnabled - }; - await writeConfig(config); - - // 重新启动监控 - await startMonitoring(); - - res.json({ success: true }); - } catch (error) { - logger.error('Failed to save monitoring config:', error); - res.status(500).json({ error: 'Failed to save monitoring config', details: error.message }); - } -}); - -// 发送告警的函数,包含重试逻辑 -async function sendAlertWithRetry(containerName, status, monitoringConfig, maxRetries = 6) { - const { notificationType, webhookUrl, telegramToken, telegramChatId } = monitoringConfig; - const cleanContainerName = containerName.replace(/^\//, ''); - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - if (notificationType === 'wechat') { - await sendWechatAlert(webhookUrl, cleanContainerName, status); - } else if (notificationType === 'telegram') { - await sendTelegramAlert(telegramToken, telegramChatId, cleanContainerName, status); - } - logger.success(`告警发送成功: ${cleanContainerName} ${status}`); - return; - } catch (error) { - if (attempt === maxRetries) { - logger.error(`达到最大重试次数,放弃发送告警: ${cleanContainerName} ${status}`); - return; - } - await new Promise(resolve => setTimeout(resolve, 10000)); - } - } -} - -async function sendWechatAlert(webhookUrl, containerName, status) { - const response = await axios.post(webhookUrl, { - msgtype: 'text', - text: { - content: `通知: 容器 ${containerName} ${status}` - } - }, { - timeout: 5000 - }); - - if (response.status !== 200 || response.data.errcode !== 0) { - throw new Error(`请求成功但返回错误:${response.data.errmsg}`); - } -} - -async function sendTelegramAlert(token, chatId, containerName, status) { - const url = `https://api.telegram.org/bot${token}/sendMessage`; - const response = await axios.post(url, { - chat_id: chatId, - text: `通知: 容器 ${containerName} ${status}` - }, { - timeout: 5000 - }); - - if (response.status !== 200 || !response.data.ok) { - throw new Error(`发送Telegram消息失败:${JSON.stringify(response.data)}`); - } -} - -app.post('/api/test-notification', requireLogin, async (req, res) => { - try { - const { notificationType, webhookUrl, telegramToken, telegramChatId } = req.body; - - if (notificationType === 'wechat') { - await sendWechatAlert(webhookUrl, 'Test Container', 'This is a test notification'); - } else if (notificationType === 'telegram') { - await sendTelegramAlert(telegramToken, telegramChatId, 'Test Container', 'This is a test notification'); - } else { - throw new Error('Unsupported notification type'); - } - res.json({ success: true, message: 'Test notification sent successfully' }); - } catch (error) { - logger.error('Failed to send test notification:', error); - res.status(500).json({ error: 'Failed to send test notification', details: error.message }); - } -}); - -let monitoringInterval; // 用于跟踪监控间隔 -let sentAlerts = new Set(); // 用于跟踪已发送的告警 - -async function startMonitoring() { - const config = await readConfig(); - const { notificationType, webhookUrl, telegramToken, telegramChatId, monitorInterval, isEnabled } = config.monitoringConfig || {}; - - if (isEnabled) { - const docker = await initDocker(); - if (docker) { - await initializeContainerStates(docker); - await checkContainerStates(docker, config.monitoringConfig); - if (monitoringInterval) { - clearInterval(monitoringInterval); - } - - const dockerEventStream = await docker.getEvents(); - - dockerEventStream.on('data', async (chunk) => { - const event = JSON.parse(chunk.toString()); - if (event.Type === 'container' && (event.Action === 'start' || event.Action === 'die')) { - await handleContainerEvent(docker, event, config.monitoringConfig); - } - }); - - monitoringInterval = setInterval(async () => { - await checkContainerStates(docker, config.monitoringConfig); - }, (monitorInterval || 60) * 1000); - } - } else if (monitoringInterval) { - clearInterval(monitoringInterval); - monitoringInterval = null; - } -} - -async function initializeContainerStates(docker) { - const containers = await docker.listContainers({ all: true }); - for (const container of containers) { - const containerInfo = await docker.getContainer(container.Id).inspect(); - containerStates.set(container.Id, containerInfo.State.Status); - } -} - -async function handleContainerEvent(docker, event, monitoringConfig) { - const containerId = event.Actor.ID; - const container = docker.getContainer(containerId); - const containerInfo = await container.inspect(); - const newStatus = containerInfo.State.Status; - const oldStatus = containerStates.get(containerId); - - if (oldStatus && oldStatus !== newStatus) { - if (newStatus === 'running') { - await sendAlertWithRetry(containerInfo.Name, `恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, monitoringConfig); - lastStopAlertTime.delete(containerInfo.Name); - secondAlertSent.delete(containerInfo.Name); - } else if (oldStatus === 'running') { - await sendAlertWithRetry(containerInfo.Name, `停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, monitoringConfig); - lastStopAlertTime.set(containerInfo.Name, Date.now()); - secondAlertSent.delete(containerInfo.Name); - } - containerStates.set(containerId, newStatus); - } -} - -async function checkContainerStates(docker, monitoringConfig) { - const containers = await docker.listContainers({ all: true }); - for (const container of containers) { - const containerInfo = await docker.getContainer(container.Id).inspect(); - const newStatus = containerInfo.State.Status; - const oldStatus = containerStates.get(container.Id); - - if (oldStatus && oldStatus !== newStatus) { - if (newStatus === 'running') { - await sendAlertWithRetry(containerInfo.Name, `恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, monitoringConfig); - lastStopAlertTime.delete(containerInfo.Name); - secondAlertSent.delete(containerInfo.Name); - } else if (oldStatus === 'running') { - await sendAlertWithRetry(containerInfo.Name, `停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, monitoringConfig); - lastStopAlertTime.set(containerInfo.Name, Date.now()); - secondAlertSent.delete(containerInfo.Name); - } - containerStates.set(container.Id, newStatus); - } else if (newStatus !== 'running') { - await checkSecondStopAlert(containerInfo.Name, newStatus, monitoringConfig); - } - } -} - -async function checkSecondStopAlert(containerName, currentStatus, monitoringConfig) { - const now = Date.now(); - const lastStopAlert = lastStopAlertTime.get(containerName) || 0; - // 如果距离上次停止告警超过1小时,且还没有发送过第二次告警,则发送第二次告警 - if (now - lastStopAlert >= 60 * 60 * 1000 && !secondAlertSent.has(containerName)) { - await sendAlertWithRetry(containerName, `仍未恢复 (当前状态: ${currentStatus})`, monitoringConfig); - secondAlertSent.add(containerName); // 标记已发送第二次告警 - } -} - -async function sendAlert(webhookUrl, containerName, status) { - try { - await axios.post(webhookUrl, { - msgtype: 'text', - text: { - content: `告警通知: 容器 ${containerName} 当前状态为 ${status}` - } - }); - } catch (error) { - logger.error('发送告警失败:', error); - } -} - -// API端点:切换监控状态 -app.post('/api/toggle-monitoring', requireLogin, async (req, res) => { - try { - const { isEnabled } = req.body; - const config = await readConfig(); - config.monitoringConfig.isEnabled = isEnabled; - await writeConfig(config); - - if (isEnabled) { - await startMonitoring(); - } else { - clearInterval(monitoringInterval); - monitoringInterval = null; - } - - res.json({ success: true, message: `Monitoring ${isEnabled ? 'enabled' : 'disabled'}` }); - } catch (error) { - logger.error('Failed to toggle monitoring:', error); - res.status(500).json({ error: 'Failed to toggle monitoring', details: error.message }); - } -}); - -app.get('/api/stopped-containers', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const containers = await docker.listContainers({ all: true }); - const stoppedContainers = containers - .filter(container => container.State !== 'running') - .map(container => ({ - id: container.Id.slice(0, 12), - name: container.Names[0].replace(/^\//, ''), - status: container.State - })); - res.json(stoppedContainers); - } catch (error) { - logger.error('获取已停止容器列表失败:', error); - res.status(500).json({ error: '获取已停止容器列表失败', details: error.message }); - } -}); - -app.get('/api/refresh-stopped-containers', requireLogin, async (req, res) => { - try { - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - const containers = await docker.listContainers({ all: true }); - const stoppedContainers = containers - .filter(container => container.State !== 'running') - .map(container => ({ - id: container.Id.slice(0, 12), - name: container.Names[0].replace(/^\//, ''), - status: container.State - })); - res.json(stoppedContainers); - } catch (error) { - logger.error('刷新已停止容器列表失败:', error); - res.status(500).json({ error: '刷新已停止容器列表失败', details: error.message }); - } -}); - -// 导出函数以供其他模块使用 -module.exports = { - startMonitoring, - sendAlertWithRetry -}; - -// 退出登录API -app.post('/api/logout', (req, res) => { - req.session.destroy(err => { - if (err) { - logger.error('销毁会话失败:', err); - return res.status(500).json({ error: 'Failed to logout' }); - } - res.clearCookie('connect.sid'); - logger.info('用户已退出登录'); - res.json({ success: true }); - }); -}); - -// 模拟系统状态API(如果实际不需要实现,可以移除) -app.get('/api/system-status', requireLogin, (req, res) => { - // 这里可以添加真实的系统状态获取逻辑 - // 当前返回模拟数据 - const containerCount = Math.floor(Math.random() * 10) + 1; - const memoryUsage = Math.floor(Math.random() * 30) + 40 + '%'; - const cpuLoad = Math.floor(Math.random() * 25) + 20 + '%'; - const diskSpace = Math.floor(Math.random() * 20) + 50 + '%'; - - const recentActivities = [ - { time: getFormattedTime(0), action: '启动', container: 'nginx', status: '成功' }, - { time: getFormattedTime(30), action: '更新', container: 'mysql', status: '成功' }, - { time: getFormattedTime(120), action: '停止', container: 'redis', status: '成功' } - ]; - - res.json({ - containerCount, - memoryUsage, - cpuLoad, - diskSpace, - recentActivities - }); -}); - -// 获取格式化的时间(例如:"今天 15:30") -function getFormattedTime(minutesAgo) { - const date = new Date(Date.now() - minutesAgo * 60 * 1000); - const hours = date.getHours().toString().padStart(2, '0'); - const minutes = date.getMinutes().toString().padStart(2, '0'); - - if (minutesAgo < 24 * 60) { - return `今天 ${hours}:${minutes}`; - } else { - return `昨天 ${hours}:${minutes}`; - } -} - -// 用户统计API -app.get('/api/user-stats', requireLogin, (req, res) => { - // 模拟数据 - res.json({ - loginCount: Math.floor(Math.random() * 20) + 1, - lastLogin: getFormattedTime(Math.floor(Math.random() * 48 * 60)), - accountAge: Math.floor(Math.random() * 100) + 1 - }); -}); - -// 获取真实系统状态的API -app.get('/api/system-status', requireLogin, async (req, res) => { - try { - // 初始化Docker客户端 - const docker = await initDocker(); - if (!docker) { - return res.status(503).json({ error: '无法连接到 Docker 守护进程' }); - } - - // 获取容器数量 - const containers = await docker.listContainers({ all: true }); - const runningContainers = containers.filter(c => c.State === 'running'); - const containerCount = containers.length; - - // 获取系统信息,使用child_process执行系统命令 - let memoryUsage = '未知'; - let cpuLoad = '未知'; - let diskSpace = '未知'; - - try { - // 获取内存使用情况 - const memInfo = await execPromise('free -m | grep Mem'); - const memParts = memInfo.split(/\s+/); - const totalMem = parseInt(memParts[1]); - const usedMem = parseInt(memParts[2]); - memoryUsage = Math.round((usedMem / totalMem) * 100) + '%'; - - // 获取CPU负载 - const loadInfo = await execPromise('cat /proc/loadavg'); - const loadParts = loadInfo.split(' '); - cpuLoad = loadParts[0]; - - // 获取磁盘空间 - const diskInfo = await execPromise('df -h | grep -E "/$|/home"'); - const diskParts = diskInfo.split(/\s+/); - diskSpace = diskParts[4]; // 使用百分比 - } catch (err) { - logger.error('获取系统信息时出错:', err); - - // 如果获取系统信息失败,尝试使用 Docker 的统计信息 - try { - const stats = await Promise.all(runningContainers.map(c => - docker.getContainer(c.Id).stats({ stream: false }) - )); - - // 计算平均CPU使用率 - const avgCpuUsage = stats.reduce((acc, stat) => { - const cpuDelta = stat.cpu_stats.cpu_usage.total_usage - stat.precpu_stats.cpu_usage.total_usage; - const systemDelta = stat.cpu_stats.system_cpu_usage - stat.precpu_stats.system_cpu_usage; - const usage = (cpuDelta / systemDelta) * stat.cpu_stats.online_cpus * 100; - return acc + usage; - }, 0) / (stats.length || 1); - - // 计算总内存使用率 - const totalMemoryUsage = stats.reduce((acc, stat) => { - const usage = stat.memory_stats.usage / stat.memory_stats.limit * 100; - return acc + usage; - }, 0) / (stats.length || 1); - - cpuLoad = avgCpuUsage.toFixed(2) + '%'; - memoryUsage = totalMemoryUsage.toFixed(2) + '%'; - } catch (statsErr) { - logger.error('获取Docker统计信息时出错:', statsErr); - } - } - - // 获取最近的容器活动(从Docker事件或日志中) - let recentActivities = []; - try { - // 尝试获取最近的Docker事件 - const eventList = await getRecentDockerEvents(docker); - recentActivities = eventList.slice(0, 10).map(event => ({ - time: new Date(event.time * 1000).toLocaleString(), - action: event.Action, - container: event.Actor?.Attributes?.name || '未知容器', - status: event.status || '完成' - })); - } catch (eventsErr) { - logger.error('获取Docker事件时出错:', eventsErr); - // 如果获取Docker事件失败,创建一个占位活动 - recentActivities = [ - { time: new Date().toLocaleString(), action: '系统', container: '监控服务', status: '活动' } - ]; - } - - res.json({ - containerCount, - memoryUsage, - cpuLoad, - diskSpace, - recentActivities - }); - } catch (error) { - logger.error('获取系统状态失败:', error); - res.status(500).json({ error: '获取系统状态失败', details: error.message }); - } -}); - -// 获取磁盘空间信息的辅助API -app.get('/api/disk-space', requireLogin, async (req, res) => { - try { - const diskInfo = await execPromise('df -h | grep -E "/$|/home"'); - const diskParts = diskInfo.split(/\s+/); - - res.json({ - diskSpace: diskParts[2] + '/' + diskParts[1], // 已用/总量 - usagePercent: parseInt(diskParts[4].replace('%', '')) // 使用百分比 - }); - } catch (error) { - logger.error('获取磁盘空间信息失败:', error); - res.status(500).json({ error: '获取磁盘空间信息失败', details: error.message }); - } -}); - -// 用户统计API - 使用真实数据 -app.get('/api/user-stats', requireLogin, async (req, res) => { - try { - // 这里可以添加从数据库或日志文件获取真实用户统计的代码 - // 暂时使用基本信息 - const username = req.session.user.username; - const loginCount = 1; // 这应该从会话或数据库中获取 - const lastLogin = '今天'; // 这应该从会话或数据库中获取 - const accountAge = 1; // 创建了多少天,这应该从用户记录中获取 - - res.json({ - username, - loginCount, - lastLogin, - accountAge - }); - } catch (error) { - logger.error('获取用户统计信息失败:', error); - res.status(500).json({ - loginCount: 1, - lastLogin: '今天', - accountAge: 1 - }); - } -}); - -// Promise化的exec -function execPromise(command) { - return new Promise((resolve, reject) => { - exec(command, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve(stdout.trim()); - }); - }); -} - -// 获取最近的Docker事件 -async function getRecentDockerEvents(docker) { - try { - // 注意:Dockerode目前的getEvents API可能不支持历史事件查询 - // 这是一个模拟实现,实际使用时可能需要适当调整 - const sinceTime = Math.floor(Date.now() / 1000) - 3600; // 一小时前 - - return [ - { - time: Math.floor(Date.now() / 1000) - 60, - Action: '启动', - Actor: { Attributes: { name: 'nginx' } }, - status: '成功' - }, - { - time: Math.floor(Date.now() / 1000) - 180, - Action: '重启', - Actor: { Attributes: { name: 'mysql' } }, - status: '成功' - }, - { - time: Math.floor(Date.now() / 1000) - 360, - Action: '更新', - Actor: { Attributes: { name: 'redis' } }, - status: '成功' - } - ]; - } catch (error) { - logger.error('获取Docker事件失败:', error); - return []; - } -} - -app.get('/api/system-stats', requireLogin, async (req, res) => { - try { - let dockerAvailable = false; - let containerCount = '0'; - let memoryUsage = '0%'; - let cpuLoad = '0%'; - let diskSpace = '0%'; - let recentActivities = []; - - // 尝试初始化Docker - const docker = await initDocker(); - if (docker) { - dockerAvailable = true; - - // 获取容器统计 - try { - const containers = await docker.listContainers({ all: true }); - containerCount = containers.length.toString(); - - // 获取最近的容器活动 - const runningContainers = containers.filter(c => c.State === 'running'); - for (let i = 0; i < Math.min(3, runningContainers.length); i++) { - recentActivities.push({ - time: new Date(runningContainers[i].Created * 1000).toLocaleString(), - action: '运行中', - container: runningContainers[i].Names[0].replace(/^\//, ''), - status: '正常' - }); - } - } catch (containerError) { - logger.error('获取容器信息失败:', containerError); - } - } - - // 即使Docker不可用,也尝试获取系统信息 - try { - // 获取内存使用情况 - const memInfo = await execPromise('free -m | grep Mem'); - if (memInfo) { - const memParts = memInfo.split(/\s+/); - if (memParts.length >= 3) { - const total = parseInt(memParts[1], 10); - const used = parseInt(memParts[2], 10); - memoryUsage = Math.round((used / total) * 100) + '%'; - } - } - - // 获取CPU负载 - const loadAvg = await execPromise('cat /proc/loadavg'); - if (loadAvg) { - const load = parseFloat(loadAvg.split(' ')[0]); - cpuLoad = (load * 100).toFixed(2) + '%'; - } - - // 获取磁盘空间 - const diskInfo = await execPromise('df -h | grep -E "/$|/home" | head -1'); - if (diskInfo) { - const diskParts = diskInfo.split(/\s+/); - if (diskParts.length >= 5) { - diskSpace = diskParts[4]; // 使用百分比 - } - } - } catch (sysError) { - logger.error('获取系统信息失败:', sysError); - } - - // 如果没有活动记录,添加一个默认记录 - if (recentActivities.length === 0) { - recentActivities.push({ - time: new Date().toLocaleString(), - action: '系统检查', - container: '监控服务', - status: dockerAvailable ? '正常' : 'Docker服务不可用' - }); - } - - // 返回收集到的所有数据,即使部分数据可能不完整 - res.json({ - dockerAvailable, - containerCount, - memoryUsage, - cpuLoad, - diskSpace, - recentActivities - }); - - } catch (error) { - logger.error('获取系统统计数据失败:', error); - - // 即使出错,仍然尝试返回一些基本数据 - res.status(200).json({ - dockerAvailable: false, - containerCount: '0', - memoryUsage: '未知', - cpuLoad: '未知', - diskSpace: '未知', - recentActivities: [{ - time: new Date().toLocaleString(), - action: '系统错误', - container: '监控服务', - status: '数据获取失败' - }], - error: '获取系统统计数据失败', - errorDetails: error.message - }); - } -}); - - -// 辅助函数 -function execPromise(cmd) { - return new Promise((resolve, reject) => { - exec(cmd, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve(stdout.trim()); - }); - }); -} - -// 执行系统命令的辅助函数 -async function execCommand(command) { - return new Promise((resolve, reject) => { - exec(command, (error, stdout, stderr) => { - if (error) { - reject(error); - return; - } - resolve({ stdout, stderr }); - }); - }); -} - -// API端点:获取用户信息 -app.get('/api/user-info', requireLogin, async (req, res) => { - try { - // 确保用户已登录 - if (!req.session.user) { - return res.status(401).json({ error: '未登录' }); - } - - const users = await readUsers(); - const user = users.users.find(u => u.username === req.session.user.username); - - if (!user) { - return res.status(404).json({ error: '用户不存在' }); - } - - // 计算账户年龄(如果有创建日期) - let accountAge = '0'; - if (user.createdAt) { - const createdDate = new Date(user.createdAt); - const currentDate = new Date(); - const diffTime = Math.abs(currentDate - createdDate); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - accountAge = diffDays.toString(); - } - - // 返回用户信息 - res.json({ - username: user.username, - loginCount: user.loginCount || '0', - lastLogin: user.lastLogin || '无记录', - accountAge - }); - } catch (error) { - logger.error('获取用户信息失败:', error); - res.status(500).json({ error: '获取用户信息失败', details: error.message }); - } +// 错误处理中间件 +app.use((err, req, res, next) => { + logger.error('应用错误:', err); + res.status(500).json({ error: '服务器内部错误', details: err.message }); }); // 启动服务器 const PORT = process.env.PORT || 3000; -server.listen(PORT, async () => { - logger.info(`Server is running on http://localhost:${PORT}`); - try { - await startMonitoring(); - } catch (error) { - logger.error('Failed to start monitoring:', error); + +async function startServer() { + server.listen(PORT, async () => { + logger.info(`服务器已启动并监听端口 ${PORT}`); + + try { + // 确保目录存在 + await ensureDirectoriesExist(); + logger.success('系统目录初始化完成'); + + // 下载必要资源 + await downloadImages(); + logger.success('资源下载完成'); + + // 初始化系统 + try { + const { initialize } = require('./scripts/init-system'); + await initialize(); + logger.success('系统初始化完成'); + } catch (initError) { + logger.warn('系统初始化遇到问题:', initError.message); + logger.warn('某些功能可能无法正常工作'); + } + + // 尝试启动监控 + try { + const monitoringService = require('./services/monitoringService'); + await monitoringService.startMonitoring(); + logger.success('监控服务已启动'); + } catch (monitoringError) { + logger.warn('监控服务启动失败:', monitoringError.message); + logger.warn('监控功能可能不可用'); + } + + // 尝试设置WebSocket + try { + const dockerRouter = require('./routes/docker'); + if (typeof dockerRouter.setupLogWebsocket === 'function') { + dockerRouter.setupLogWebsocket(server); + logger.success('WebSocket服务已启动'); + } + } catch (wsError) { + logger.warn('WebSocket服务启动失败:', wsError.message); + logger.warn('容器日志实时流可能不可用'); + } + + logger.success('服务器初始化完成,系统已准备就绪'); + } catch (error) { + logger.error('系统初始化失败,但服务仍将继续运行:', error); + } + }); +} + +startServer(); + +// 处理进程终止信号 +process.on('SIGINT', gracefulShutdown); +process.on('SIGTERM', gracefulShutdown); + +// 捕获未处理的Promise拒绝和未捕获的异常 +process.on('unhandledRejection', (reason, promise) => { + logger.error('未处理的Promise拒绝:', reason); + if (reason instanceof Error) { + logger.debug('拒绝原因堆栈:', reason.stack); } }); -// 统一的错误处理函数 -function handleAxiosError(error, res, message) { - let errorDetails = ''; - - if (error.response) { - // 服务器响应错误 - const status = error.response.status; - errorDetails = `状态码: ${status}`; - - if (error.response.data && error.response.data.message) { - errorDetails += `, 信息: ${error.response.data.message}`; - } - - console.error(`[ERROR] ${message}: ${errorDetails}`); - res.status(status).json({ - error: `${message} (${errorDetails})`, - details: error.response.data - }); - - } else if (error.request) { - // 请求已发送但没有收到响应 - if (error.code === 'ECONNRESET') { - errorDetails = '连接被重置,这可能是由于网络不稳定或服务端断开连接'; - } else if (error.code === 'ECONNABORTED') { - errorDetails = '请求超时,服务器响应时间过长'; - } else { - errorDetails = `${error.code || '未知错误代码'}: ${error.message}`; - } - - console.error(`[ERROR] ${message}: ${errorDetails}`); - res.status(503).json({ - error: `${message} (${errorDetails})`, - retryable: true - }); - - } else { - // 其他错误 - errorDetails = error.message; - console.error(`[ERROR] ${message}: ${errorDetails}`); - console.error(`[ERROR] 错误堆栈: ${error.stack}`); - - res.status(500).json({ - error: `${message} (${errorDetails})`, - retryable: true - }); - } -} \ No newline at end of file +process.on('uncaughtException', (error) => { + logger.error('未捕获的异常:', error); + logger.error('错误堆栈:', error.stack); + // 给日志一些时间写入后退出 + setTimeout(() => { + logger.fatal('由于未捕获的异常,系统将在3秒后退出'); + setTimeout(() => process.exit(1), 3000); + }, 1000); +}); + +// 导出服务器对象以供测试使用 +module.exports = server; \ No newline at end of file diff --git a/hubcmdui/services/configService.js b/hubcmdui/services/configService.js new file mode 100644 index 0000000..26999ed --- /dev/null +++ b/hubcmdui/services/configService.js @@ -0,0 +1,47 @@ +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../logger'); + +const CONFIG_FILE = path.join(__dirname, '../config.json'); +const DEFAULT_CONFIG = { + theme: 'light', + language: 'zh_CN', + notifications: true, + autoRefresh: true, + refreshInterval: 30000, + dockerHost: 'localhost', + dockerPort: 2375, + useHttps: false +}; + +async function ensureConfigFile() { + try { + await fs.access(CONFIG_FILE); + } catch (error) { + if (error.code === 'ENOENT') { + await fs.writeFile(CONFIG_FILE, JSON.stringify(DEFAULT_CONFIG, null, 2)); + } else { + throw error; + } + } +} + +async function getConfig() { + try { + await ensureConfigFile(); + const data = await fs.readFile(CONFIG_FILE, 'utf8'); + return JSON.parse(data); + } catch (error) { + logger.error('读取配置文件失败:', error); + return { ...DEFAULT_CONFIG, error: true }; + } +} + +module.exports = { + getConfig, + saveConfig: async (config) => { + await ensureConfigFile(); + await fs.writeFile(CONFIG_FILE, JSON.stringify(config, null, 2)); + }, + DEFAULT_CONFIG +}; diff --git a/hubcmdui/services/dockerHubService.js b/hubcmdui/services/dockerHubService.js new file mode 100644 index 0000000..0acec78 --- /dev/null +++ b/hubcmdui/services/dockerHubService.js @@ -0,0 +1,290 @@ +/** + * Docker Hub 服务模块 + */ +const axios = require('axios'); +const logger = require('../logger'); +const pLimit = require('p-limit'); +const axiosRetry = require('axios-retry'); + +// 配置并发限制,最多5个并发请求 +const limit = pLimit(5); + +// 优化HTTP请求配置 +const httpOptions = { + timeout: 15000, // 15秒超时 + headers: { + 'User-Agent': 'DockerHubSearchClient/1.0', + 'Accept': 'application/json' + } +}; + +// 配置Axios重试 +axiosRetry(axios, { + retries: 3, // 最多重试3次 + retryDelay: (retryCount) => { + console.log(`[INFO] 重试 Docker Hub 请求 (${retryCount}/3)`); + return retryCount * 1000; // 重试延迟,每次递增1秒 + }, + retryCondition: (error) => { + // 只在网络错误或5xx响应时重试 + return axiosRetry.isNetworkOrIdempotentRequestError(error) || + (error.response && error.response.status >= 500); + } +}); + +// 搜索仓库 +async function searchRepositories(term, page = 1, requestCache = null) { + const cacheKey = `search_${term}_${page}`; + let cachedResult = null; + + // 安全地检查缓存 + if (requestCache && typeof requestCache.get === 'function') { + cachedResult = requestCache.get(cacheKey); + } + + if (cachedResult) { + console.log(`[INFO] 返回缓存的搜索结果: ${term} (页码: ${page})`); + return cachedResult; + } + + console.log(`[INFO] 搜索Docker Hub: ${term} (页码: ${page})`); + + try { + // 使用更安全的直接请求方式,避免pLimit可能的问题 + const url = `https://hub.docker.com/v2/search/repositories/?query=${encodeURIComponent(term)}&page=${page}&page_size=25`; + const response = await axios.get(url, httpOptions); + const result = response.data; + + // 将结果缓存(如果缓存对象可用) + if (requestCache && typeof requestCache.set === 'function') { + requestCache.set(cacheKey, result); + } + + return result; + } catch (error) { + logger.error('搜索Docker Hub失败:', error.message); + // 重新抛出错误以便上层处理 + throw new Error(error.message || '搜索Docker Hub失败'); + } +} + +// 获取所有标签 +async function getAllTags(imageName, isOfficial) { + const fullImageName = isOfficial ? `library/${imageName}` : imageName; + logger.info(`获取所有镜像标签: ${fullImageName}`); + + // 为所有标签请求设置超时限制 + const allTagsPromise = fetchAllTags(fullImageName); + const timeoutPromise = new Promise((_, reject) => + setTimeout(() => reject(new Error('获取所有标签超时')), 30000) + ); + + try { + // 使用Promise.race确保请求不会无限等待 + const allTags = await Promise.race([allTagsPromise, timeoutPromise]); + + // 过滤掉无效平台信息 + const cleanedTags = allTags.map(tag => { + if (tag.images && Array.isArray(tag.images)) { + tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown')); + } + return tag; + }); + + return { + count: cleanedTags.length, + results: cleanedTags, + all_pages_loaded: true + }; + } catch (error) { + logger.error(`获取所有标签失败: ${error.message}`); + throw error; + } +} + +// 获取特定页的标签 +async function getTagsByPage(imageName, isOfficial, page, pageSize) { + const fullImageName = isOfficial ? `library/${imageName}` : imageName; + logger.info(`获取镜像标签: ${fullImageName}, 页码: ${page}, 页面大小: ${pageSize}`); + + const tagsUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`; + + try { + const tagsResponse = await axios.get(tagsUrl, { + timeout: 15000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' + } + }); + + // 检查响应数据有效性 + if (!tagsResponse.data || typeof tagsResponse.data !== 'object') { + logger.warn(`镜像 ${fullImageName} 返回的数据格式不正确`); + return { count: 0, results: [] }; + } + + if (!tagsResponse.data.results || !Array.isArray(tagsResponse.data.results)) { + logger.warn(`镜像 ${fullImageName} 没有返回有效的标签数据`); + return { count: 0, results: [] }; + } + + // 过滤掉无效平台信息 + const cleanedResults = tagsResponse.data.results.map(tag => { + if (tag.images && Array.isArray(tag.images)) { + tag.images = tag.images.filter(img => !(img.os === 'unknown' && img.architecture === 'unknown')); + } + return tag; + }); + + return { + ...tagsResponse.data, + results: cleanedResults + }; + } catch (error) { + logger.error(`获取标签列表失败: ${error.message}`, { + url: tagsUrl, + status: error.response?.status, + statusText: error.response?.statusText + }); + throw error; + } +} + +// 获取标签数量 +async function getTagCount(name, isOfficial, requestCache) { + const cacheKey = `tag_count_${name}_${isOfficial}`; + const cachedResult = requestCache?.get(cacheKey); + + if (cachedResult) { + console.log(`[INFO] 返回缓存的标签计数: ${name}`); + return cachedResult; + } + + const fullImageName = isOfficial ? `library/${name}` : name; + const apiUrl = `https://hub.docker.com/v2/repositories/${fullImageName}/tags/?page_size=1`; + + try { + const result = await limit(async () => { + const response = await axios.get(apiUrl, httpOptions); + return { + count: response.data.count, + recommended_mode: response.data.count > 500 ? 'paginated' : 'full' + }; + }); + + if (requestCache) { + requestCache.set(cacheKey, result); + } + + return result; + } catch (error) { + throw error; + } +} + +// 递归获取所有标签 +async function fetchAllTags(fullImageName, page = 1, allTags = [], maxPages = 10) { + if (page > maxPages) { + logger.warn(`达到最大页数限制 (${maxPages}),停止获取更多标签`); + return allTags; + } + + const pageSize = 100; // 使用最大页面大小 + const url = `https://hub.docker.com/v2/repositories/${fullImageName}/tags?page=${page}&page_size=${pageSize}`; + + try { + logger.info(`获取标签页 ${page}/${maxPages}...`); + + const response = await axios.get(url, { + timeout: 10000, + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.114 Safari/537.36' + } + }); + + if (!response.data.results || !Array.isArray(response.data.results)) { + logger.warn(`页 ${page} 没有有效的标签数据`); + return allTags; + } + + allTags.push(...response.data.results); + logger.info(`已获取 ${allTags.length}/${response.data.count || 'unknown'} 个标签`); + + // 检查是否有下一页 + if (response.data.next && allTags.length < response.data.count) { + // 添加一些延迟以避免请求过快 + await new Promise(resolve => setTimeout(resolve, 500)); + return fetchAllTags(fullImageName, page + 1, allTags, maxPages); + } + + logger.success(`成功获取所有 ${allTags.length} 个标签`); + return allTags; + } catch (error) { + logger.error(`递归获取标签失败 (页码 ${page}): ${error.message}`); + + // 如果已经获取了一些标签,返回这些标签而不是抛出错误 + if (allTags.length > 0) { + return allTags; + } + + // 如果没有获取到任何标签,则抛出错误 + throw error; + } +} + +// 统一的错误处理函数 +function handleAxiosError(error, res, message) { + let errorDetails = ''; + + if (error.response) { + // 服务器响应错误的错误处理函数 + const status = error.response.status; + errorDetails = `状态码: ${status}`; + + if (error.response.data && error.response.data.message) { + errorDetails += `, 信息: ${error.response.data.message}`; + } + + console.error(`[ERROR] ${message}: ${errorDetails}`); + + res.status(status).json({ + error: `${message} (${errorDetails})`, + details: error.response.data + }); + } else if (error.request) { + // 请求已发送但没有收到响应 + if (error.code === 'ECONNRESET') { + errorDetails = '连接被重置,这可能是由于网络不稳定或服务端断开连接'; + } else if (error.code === 'ECONNABORTED') { + errorDetails = '请求超时,服务器响应时间过长'; + } else { + errorDetails = `${error.code || '未知错误代码'}: ${error.message}`; + } + + console.error(`[ERROR] ${message}: ${errorDetails}`); + + res.status(503).json({ + error: `${message} (${errorDetails})`, + retryable: true + }); + } else { + // 其他错误 + errorDetails = error.message; + console.error(`[ERROR] ${message}: ${errorDetails}`); + console.error(`[ERROR] 错误堆栈: ${error.stack}`); + + res.status(500).json({ + error: `${message} (${errorDetails})`, + retryable: true + }); + } +} + +module.exports = { + searchRepositories, + getAllTags, + getTagsByPage, + getTagCount, + fetchAllTags, + handleAxiosError +}; diff --git a/hubcmdui/services/dockerService.js b/hubcmdui/services/dockerService.js new file mode 100644 index 0000000..c37ea84 --- /dev/null +++ b/hubcmdui/services/dockerService.js @@ -0,0 +1,476 @@ +/** + * Docker服务模块 - 处理Docker容器管理 + */ +const Docker = require('dockerode'); +const logger = require('../logger'); + +let docker = null; + +async function initDockerConnection() { + if (docker) return docker; + + try { + // 兼容MacOS的Docker socket路径 + const options = process.platform === 'darwin' + ? { socketPath: '/var/run/docker.sock' } + : null; + + docker = new Docker(options); + await docker.ping(); + logger.success('成功连接到Docker守护进程'); + return docker; + } catch (error) { + logger.error('Docker连接失败:', error.message); + return null; // 返回null而不是抛出错误 + } +} + +// 获取Docker连接 +async function getDockerConnection() { + if (!docker) { + docker = await initDockerConnection(); + } + return docker; +} + +// 修改其他Docker相关方法,添加更友好的错误处理 +async function getContainersStatus() { + const docker = await initDockerConnection(); + if (!docker) { + logger.warn('[getContainersStatus] Cannot connect to Docker daemon, returning error indicator.'); + // 返回带有特殊错误标记的数组,前端可以通过这个标记识别 Docker 不可用 + return [{ + id: 'n/a', + name: 'Docker 服务不可用', + image: 'n/a', + state: 'error', + status: 'Docker 服务未运行或无法连接', + error: 'DOCKER_UNAVAILABLE', // 特殊错误标记 + cpu: 'N/A', + memory: 'N/A', + created: new Date().toLocaleString() + }]; + } + + let containers = []; + try { + containers = await docker.listContainers({ all: true }); + logger.info(`[getContainersStatus] Found ${containers.length} containers.`); + } catch (listError) { + logger.error('[getContainersStatus] Error listing containers:', listError.message || listError); + // 使用同样的错误标记模式 + return [{ + id: 'n/a', + name: '容器列表获取失败', + image: 'n/a', + state: 'error', + status: `获取容器列表失败: ${listError.message}`, + error: 'CONTAINER_LIST_ERROR', + cpu: 'N/A', + memory: 'N/A', + created: new Date().toLocaleString() + }]; + } + + const containerPromises = containers.map(async (container) => { + try { + const containerInspectInfo = await docker.getContainer(container.Id).inspect(); + + let stats = {}; + let cpuUsage = 'N/A'; + let memoryUsage = 'N/A'; + + // 仅在容器运行时尝试获取 stats + if (containerInspectInfo.State.Running) { + try { + stats = await docker.getContainer(container.Id).stats({ stream: false }); + + // Safely calculate CPU usage + if (stats.precpu_stats && stats.cpu_stats && stats.cpu_stats.cpu_usage && stats.precpu_stats.cpu_usage && stats.cpu_stats.system_cpu_usage && stats.precpu_stats.system_cpu_usage) { + const cpuDelta = stats.cpu_stats.cpu_usage.total_usage - stats.precpu_stats.cpu_usage.total_usage; + const systemDelta = stats.cpu_stats.system_cpu_usage - stats.precpu_stats.system_cpu_usage; + if (systemDelta > 0 && stats.cpu_stats.online_cpus > 0) { + cpuUsage = ((cpuDelta / systemDelta) * stats.cpu_stats.online_cpus * 100.0).toFixed(2) + '%'; + } else { + cpuUsage = '0.00%'; // Handle division by zero or no change + } + } else { + logger.warn(`[getContainersStatus] Incomplete CPU stats for container ${container.Id}`); + } + + // Safely calculate Memory usage + if (stats.memory_stats && stats.memory_stats.usage && stats.memory_stats.limit) { + const memoryLimit = stats.memory_stats.limit; + if (memoryLimit > 0) { + memoryUsage = ((stats.memory_stats.usage / memoryLimit) * 100.0).toFixed(2) + '%'; + } else { + memoryUsage = '0.00%'; // Handle division by zero (unlikely) + } + } else { + logger.warn(`[getContainersStatus] Incomplete Memory stats for container ${container.Id}`); + } + + } catch (statsError) { + logger.warn(`[getContainersStatus] Failed to get stats for running container ${container.Id}: ${statsError.message}`); + // 保留 N/A 值 + } + } + + return { + id: container.Id.slice(0, 12), + name: container.Names && container.Names.length > 0 ? container.Names[0].replace(/^\//, '') : (containerInspectInfo.Name ? containerInspectInfo.Name.replace(/^\//, '') : 'N/A'), + image: container.Image || 'N/A', + state: containerInspectInfo.State.Status || container.State || 'N/A', + status: container.Status || 'N/A', + cpu: cpuUsage, + memory: memoryUsage, + created: container.Created ? new Date(container.Created * 1000).toLocaleString() : 'N/A' + }; + } catch(err) { + logger.warn(`[getContainersStatus] Failed to get inspect info for container ${container.Id}: ${err.message}`); + // 返回一个包含错误信息的对象,而不是让 Promise.all 失败 + return { + id: container.Id ? container.Id.slice(0, 12) : 'Unknown ID', + name: container.Names && container.Names.length > 0 ? container.Names[0].replace(/^\//, '') : 'Unknown Name', + image: container.Image || 'Unknown Image', + state: 'error', + status: `Error: ${err.message}`, + cpu: 'N/A', + memory: 'N/A', + created: container.Created ? new Date(container.Created * 1000).toLocaleString() : 'N/A' + }; + } + }); + + // 等待所有容器信息处理完成 + const results = await Promise.all(containerPromises); + // 可以选择过滤掉完全失败的结果(虽然上面已经处理了) + // return results.filter(r => r.state !== 'error'); + return results; // 返回所有结果,包括有错误的 +} + +// 获取单个容器状态 +async function getContainerStatus(id) { + const docker = await getDockerConnection(); + if (!docker) { + throw new Error('无法连接到 Docker 守护进程'); + } + + const container = docker.getContainer(id); + const containerInfo = await container.inspect(); + return { state: containerInfo.State.Status }; +} + +// 重启容器 +async function restartContainer(id) { + logger.info(`Attempting to restart container ${id}`); + const docker = await getDockerConnection(); + if (!docker) { + logger.error(`[restartContainer ${id}] Cannot connect to Docker daemon.`); + throw new Error('无法连接到 Docker 守护进程'); + } + + try { + const container = docker.getContainer(id); + await container.restart(); + logger.success(`Container ${id} restarted successfully.`); + return { success: true }; + } catch (error) { + logger.error(`[restartContainer ${id}] Error restarting container:`, error.message || error); + // 检查是否是容器不存在的错误 + if (error.statusCode === 404) { + throw new Error(`容器 ${id} 不存在`); + } + // 可以根据需要添加其他错误类型的检查 + throw new Error(`重启容器失败: ${error.message}`); + } +} + +// 停止容器 +async function stopContainer(id) { + logger.info(`Attempting to stop container ${id}`); + const docker = await getDockerConnection(); + if (!docker) { + logger.error(`[stopContainer ${id}] Cannot connect to Docker daemon.`); + throw new Error('无法连接到 Docker 守护进程'); + } + + try { + const container = docker.getContainer(id); + await container.stop(); + logger.success(`Container ${id} stopped successfully.`); + return { success: true }; + } catch (error) { + logger.error(`[stopContainer ${id}] Error stopping container:`, error.message || error); + // 检查是否是容器不存在或已停止的错误 + if (error.statusCode === 404) { + throw new Error(`容器 ${id} 不存在`); + } else if (error.statusCode === 304) { + logger.warn(`[stopContainer ${id}] Container already stopped.`); + return { success: true, message: '容器已停止' }; // 认为已停止也是成功 + } + throw new Error(`停止容器失败: ${error.message}`); + } +} + +// 删除容器 +async function deleteContainer(id) { + const docker = await getDockerConnection(); + if (!docker) { + throw new Error('无法连接到 Docker 守护进程'); + } + + const container = docker.getContainer(id); + + // 首先停止容器(如果正在运行) + try { + await container.stop(); + } catch (stopError) { + logger.info('Container may already be stopped:', stopError.message); + } + + // 然后删除容器 + await container.remove(); + return { success: true, message: '容器已成功删除' }; +} + +// 更新容器 +async function updateContainer(id, tag) { + const docker = await getDockerConnection(); + if (!docker) { + throw new Error('无法连接到 Docker 守护进程'); + } + + // 获取容器信息 + const container = docker.getContainer(id); + const containerInfo = await container.inspect(); + const currentImage = containerInfo.Config.Image; + const [imageName] = currentImage.split(':'); + const newImage = `${imageName}:${tag}`; + const containerName = containerInfo.Name.slice(1); // 去掉开头的 '/' + + logger.info(`Updating container ${id} from ${currentImage} to ${newImage}`); + + // 拉取新镜像 + logger.info(`Pulling new image: ${newImage}`); + await new Promise((resolve, reject) => { + docker.pull(newImage, (err, stream) => { + if (err) return reject(err); + docker.modem.followProgress(stream, (err, output) => err ? reject(err) : resolve(output)); + }); + }); + + // 停止旧容器 + logger.info('Stopping old container'); + await container.stop(); + + // 删除旧容器 + logger.info('Removing old container'); + await container.remove(); + + // 创建新容器 + logger.info('Creating new container'); + const newContainerConfig = { + ...containerInfo.Config, + Image: newImage, + HostConfig: containerInfo.HostConfig, + NetworkingConfig: { + EndpointsConfig: containerInfo.NetworkSettings.Networks + } + }; + + const newContainer = await docker.createContainer({ + ...newContainerConfig, + name: containerName + }); + + // 启动新容器 + logger.info('Starting new container'); + await newContainer.start(); + + logger.success('Container update completed successfully'); + return { success: true }; +} + +// 获取容器日志 +async function getContainerLogs(id, options = {}) { + logger.info(`Attempting to get logs for container ${id} with options:`, options); + const docker = await getDockerConnection(); + if (!docker) { + logger.error(`[getContainerLogs ${id}] Cannot connect to Docker daemon.`); + throw new Error('无法连接到 Docker 守护进程'); + } + + try { + const container = docker.getContainer(id); + const logOptions = { + stdout: true, + stderr: true, + tail: options.tail || 100, + follow: options.follow || false + }; + + // 修复日志获取方式 + if (!options.follow) { + // 对于非流式日志,直接等待返回 + try { + const logs = await container.logs(logOptions); + + // 如果logs是Buffer或字符串,直接处理 + if (Buffer.isBuffer(logs) || typeof logs === 'string') { + // 清理ANSI转义码 + const cleanedLogs = logs.toString('utf8').replace(/\x1B\[[0-9;]*[JKmsu]/g, ''); + logger.success(`Successfully retrieved logs for container ${id}`); + return cleanedLogs; + } + // 如果logs是流,转换为字符串 + else if (typeof logs === 'object' && logs !== null) { + return new Promise((resolve, reject) => { + let allLogs = ''; + + // 处理数据事件 + if (typeof logs.on === 'function') { + logs.on('data', chunk => { + allLogs += chunk.toString('utf8'); + }); + + logs.on('end', () => { + const cleanedLogs = allLogs.replace(/\x1B\[[0-9;]*[JKmsu]/g, ''); + logger.success(`Successfully retrieved logs for container ${id}`); + resolve(cleanedLogs); + }); + + logs.on('error', err => { + logger.error(`[getContainerLogs ${id}] Error reading log stream:`, err.message || err); + reject(new Error(`读取日志流失败: ${err.message}`)); + }); + } else { + // 如果不是标准流但返回了对象,尝试转换为字符串 + logger.warn(`[getContainerLogs ${id}] Logs object does not have stream methods, trying to convert`); + try { + const logStr = logs.toString(); + const cleanedLogs = logStr.replace(/\x1B\[[0-9;]*[JKmsu]/g, ''); + resolve(cleanedLogs); + } catch (convErr) { + logger.error(`[getContainerLogs ${id}] Failed to convert logs to string:`, convErr); + reject(new Error('日志格式转换失败')); + } + } + }); + } else { + logger.error(`[getContainerLogs ${id}] Unexpected logs response type:`, typeof logs); + throw new Error('日志响应格式错误'); + } + } catch (logError) { + logger.error(`[getContainerLogs ${id}] Error getting logs:`, logError); + throw logError; + } + } else { + // 对于流式日志,调整方式 + logger.info(`[getContainerLogs ${id}] Returning log stream for follow=true`); + const stream = await container.logs(logOptions); + return stream; // 直接返回流对象 + } + } catch (error) { + logger.error(`[getContainerLogs ${id}] Error getting container logs:`, error.message || error); + if (error.statusCode === 404) { + throw new Error(`容器 ${id} 不存在`); + } + throw new Error(`获取日志失败: ${error.message}`); + } +} + +// 获取已停止的容器 +async function getStoppedContainers() { + const docker = await getDockerConnection(); + if (!docker) { + throw new Error('无法连接到 Docker 守护进程'); + } + + const containers = await docker.listContainers({ + all: true, + filters: { status: ['exited', 'dead', 'created'] } + }); + + return containers.map(container => ({ + id: container.Id.slice(0, 12), + name: container.Names[0].replace(/^\//, ''), + status: container.State + })); +} + +// 获取最近的Docker事件 +async function getRecentEvents(limit = 10) { + const docker = await getDockerConnection(); + if (!docker) { + throw new Error('无法连接到 Docker 守护进程'); + } + + // 注意:Dockerode的getEvents API可能不支持历史事件查询 + // 以下代码是模拟生成最近事件,实际应用中可能需要其他方式实现 + + try { + const containers = await docker.listContainers({ + all: true, + limit: limit, + filters: { status: ['exited', 'created', 'running', 'restarting'] } + }); + + // 从容器状态转换为事件 + const events = containers.map(container => { + let action, status; + + switch(container.State) { + case 'running': + action = 'start'; + status = '运行中'; + break; + case 'exited': + action = 'die'; + status = '已停止'; + break; + case 'created': + action = 'create'; + status = '已创建'; + break; + case 'restarting': + action = 'restart'; + status = '重启中'; + break; + default: + action = 'update'; + status = container.Status; + } + + return { + time: container.Created, + Action: action, + status: status, + Actor: { + Attributes: { + name: container.Names[0].replace(/^\//, '') + } + } + }; + }); + + return events.sort((a, b) => b.time - a.time); + } catch (error) { + logger.error('获取Docker事件失败:', error); + return []; + } +} + +module.exports = { + initDockerConnection, + getDockerConnection, + getContainersStatus, + getContainerStatus, + restartContainer, + stopContainer, + deleteContainer, + updateContainer, + getContainerLogs, + getStoppedContainers, + getRecentEvents +}; diff --git a/hubcmdui/services/documentationService.js b/hubcmdui/services/documentationService.js new file mode 100644 index 0000000..23078fd --- /dev/null +++ b/hubcmdui/services/documentationService.js @@ -0,0 +1,324 @@ +/** + * 文档服务模块 - 处理文档管理功能 + */ +const fs = require('fs').promises; +const path = require('path'); +const logger = require('../logger'); + +const DOCUMENTATION_DIR = path.join(__dirname, '..', 'documentation'); +const META_DIR = path.join(DOCUMENTATION_DIR, 'meta'); + +// 确保文档目录存在 +async function ensureDocumentationDir() { + try { + await fs.access(DOCUMENTATION_DIR); + logger.debug('文档目录已存在'); + + // 确保meta目录存在 + try { + await fs.access(META_DIR); + logger.debug('文档meta目录已存在'); + } catch (error) { + if (error.code === 'ENOENT') { + await fs.mkdir(META_DIR, { recursive: true }); + logger.success('文档meta目录已创建'); + } else { + throw error; + } + } + } catch (error) { + if (error.code === 'ENOENT') { + await fs.mkdir(DOCUMENTATION_DIR, { recursive: true }); + logger.success('文档目录已创建'); + + // 创建meta目录 + await fs.mkdir(META_DIR, { recursive: true }); + logger.success('文档meta目录已创建'); + } else { + throw error; + } + } +} + +// 获取文档列表 +async function getDocumentationList() { + try { + await ensureDocumentationDir(); + const files = await fs.readdir(DOCUMENTATION_DIR); + + const documents = await Promise.all(files.map(async file => { + // 跳过目录和非文档文件 + if (file === 'meta' || file.startsWith('.')) return null; + + // 处理JSON文件 + if (file.endsWith('.json')) { + try { + const filePath = path.join(DOCUMENTATION_DIR, file); + const content = await fs.readFile(filePath, 'utf8'); + const doc = JSON.parse(content); + return { + id: path.parse(file).name, + title: doc.title, + published: doc.published, + createdAt: doc.createdAt || new Date().toISOString(), + updatedAt: doc.updatedAt || new Date().toISOString() + }; + } catch (fileError) { + logger.error(`读取JSON文档文件 ${file} 失败:`, fileError); + return null; + } + } + + // 处理MD文件 + if (file.endsWith('.md')) { + try { + const id = path.parse(file).name; + let metaData = { published: true, title: path.parse(file).name }; + + // 尝试读取meta数据 + try { + const metaPath = path.join(META_DIR, `${id}.json`); + const metaContent = await fs.readFile(metaPath, 'utf8'); + metaData = { ...metaData, ...JSON.parse(metaContent) }; + } catch (metaError) { + // meta文件不存在或无法解析,使用默认值 + logger.warn(`无法读取文档 ${id} 的meta数据:`, metaError.message); + } + + // 确保有发布状态 + if (typeof metaData.published !== 'boolean') { + metaData.published = true; + } + + return { + id, + title: metaData.title || id, + path: file, // 不直接加载内容,而是提供路径 + published: metaData.published, + createdAt: metaData.createdAt || new Date().toISOString(), + updatedAt: metaData.updatedAt || new Date().toISOString() + }; + } catch (mdError) { + logger.error(`处理MD文档 ${file} 失败:`, mdError); + return null; + } + } + + return null; + })); + + return documents.filter(doc => doc !== null); + } catch (error) { + logger.error('获取文档列表失败:', error); + throw error; + } +} + +// 获取已发布文档 +async function getPublishedDocuments() { + const documents = await getDocumentationList(); + return documents.filter(doc => doc.published); +} + +// 获取单个文档 +async function getDocument(id) { + try { + await ensureDocumentationDir(); + + // 首先尝试读取JSON文件 + try { + const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`); + const jsonContent = await fs.readFile(jsonPath, 'utf8'); + return JSON.parse(jsonContent); + } catch (jsonError) { + // JSON文件不存在,尝试读取MD文件 + if (jsonError.code === 'ENOENT') { + const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`); + const mdContent = await fs.readFile(mdPath, 'utf8'); + + // 读取meta数据 + let metaData = { published: true, title: id }; + try { + const metaPath = path.join(META_DIR, `${id}.json`); + const metaContent = await fs.readFile(metaPath, 'utf8'); + metaData = { ...metaData, ...JSON.parse(metaContent) }; + } catch (metaError) { + // meta文件不存在或无法解析,使用默认值 + logger.warn(`无法读取文档 ${id} 的meta数据:`, metaError.message); + } + + return { + id, + title: metaData.title || id, + content: mdContent, + published: metaData.published, + createdAt: metaData.createdAt || new Date().toISOString(), + updatedAt: metaData.updatedAt || new Date().toISOString() + }; + } + + // 其他错误,直接抛出 + throw jsonError; + } + } catch (error) { + logger.error(`获取文档 ${id} 失败:`, error); + throw error; + } +} + +// 保存文档 +async function saveDocument(id, title, content) { + try { + await ensureDocumentationDir(); + const docId = id || Date.now().toString(); + const docPath = path.join(DOCUMENTATION_DIR, `${docId}.json`); + + // 检查是否已存在,保留发布状态 + let published = false; + try { + const existingDoc = await fs.readFile(docPath, 'utf8'); + published = JSON.parse(existingDoc).published || false; + } catch (error) { + // 文件不存在,使用默认值 + } + + const now = new Date().toISOString(); + const docData = { + title, + content, + published, + createdAt: now, + updatedAt: now + }; + + await fs.writeFile( + docPath, + JSON.stringify(docData, null, 2), + 'utf8' + ); + + return { id: docId, ...docData }; + } catch (error) { + logger.error('保存文档失败:', error); + throw error; + } +} + +// 删除文档 +async function deleteDocument(id) { + try { + await ensureDocumentationDir(); + + // 删除JSON文件(如果存在) + try { + const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`); + await fs.unlink(jsonPath); + } catch (error) { + if (error.code !== 'ENOENT') { + logger.warn(`删除JSON文档 ${id} 失败:`, error); + } + } + + // 删除MD文件(如果存在) + try { + const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`); + await fs.unlink(mdPath); + } catch (error) { + if (error.code !== 'ENOENT') { + logger.warn(`删除MD文档 ${id} 失败:`, error); + } + } + + // 删除meta文件(如果存在) + try { + const metaPath = path.join(META_DIR, `${id}.json`); + await fs.unlink(metaPath); + } catch (error) { + if (error.code !== 'ENOENT') { + logger.warn(`删除文档 ${id} 的meta数据失败:`, error); + } + } + + return { success: true }; + } catch (error) { + logger.error(`删除文档 ${id} 失败:`, error); + throw error; + } +} + +// 切换文档发布状态 +async function toggleDocumentPublish(id) { + try { + await ensureDocumentationDir(); + + // 尝试读取JSON文件 + try { + const jsonPath = path.join(DOCUMENTATION_DIR, `${id}.json`); + const content = await fs.readFile(jsonPath, 'utf8'); + const doc = JSON.parse(content); + doc.published = !doc.published; + doc.updatedAt = new Date().toISOString(); + + await fs.writeFile(jsonPath, JSON.stringify(doc, null, 2), 'utf8'); + return doc; + } catch (jsonError) { + // 如果JSON文件不存在,尝试处理MD文件的meta数据 + if (jsonError.code === 'ENOENT') { + const mdPath = path.join(DOCUMENTATION_DIR, `${id}.md`); + + // 确认MD文件存在 + try { + await fs.access(mdPath); + } catch (mdError) { + throw new Error(`文档 ${id} 不存在`); + } + + // 获取或创建meta数据 + const metaPath = path.join(META_DIR, `${id}.json`); + let metaData = { published: true, title: id }; + + try { + const metaContent = await fs.readFile(metaPath, 'utf8'); + metaData = { ...metaData, ...JSON.parse(metaContent) }; + } catch (metaError) { + // meta文件不存在,使用默认值 + } + + // 切换发布状态 + metaData.published = !metaData.published; + metaData.updatedAt = new Date().toISOString(); + + // 保存meta数据 + await fs.writeFile(metaPath, JSON.stringify(metaData, null, 2), 'utf8'); + + // 获取MD文件内容 + const mdContent = await fs.readFile(mdPath, 'utf8'); + + return { + id, + title: metaData.title, + content: mdContent, + published: metaData.published, + createdAt: metaData.createdAt, + updatedAt: metaData.updatedAt + }; + } + + // 其他错误,直接抛出 + throw jsonError; + } + } catch (error) { + logger.error(`切换文档 ${id} 发布状态失败:`, error); + throw error; + } +} + +module.exports = { + ensureDocumentationDir, + getDocumentationList, + getPublishedDocuments, + getDocument, + saveDocument, + deleteDocument, + toggleDocumentPublish +}; diff --git a/hubcmdui/services/monitoringService.js b/hubcmdui/services/monitoringService.js new file mode 100644 index 0000000..ec7190d --- /dev/null +++ b/hubcmdui/services/monitoringService.js @@ -0,0 +1,331 @@ +/** + * 监控服务模块 - 处理容器状态监控和通知 + */ +const axios = require('axios'); +const logger = require('../logger'); +const configService = require('./configService'); +const dockerService = require('./dockerService'); + +// 监控相关状态映射 +let containerStates = new Map(); +let lastStopAlertTime = new Map(); +let secondAlertSent = new Set(); +let monitoringInterval = null; + +// 更新监控配置 +async function updateMonitoringConfig(config) { + try { + const currentConfig = await configService.getConfig(); + currentConfig.monitoringConfig = { + ...currentConfig.monitoringConfig, + ...config + }; + + await configService.saveConfig(currentConfig); + + // 重新启动监控 + await startMonitoring(); + + return { success: true }; + } catch (error) { + logger.error('更新监控配置失败:', error); + throw error; + } +} + +// 启动监控 +async function startMonitoring() { + try { + const config = await configService.getConfig(); + const { isEnabled, monitorInterval } = config.monitoringConfig || {}; + + // 如果监控已启用 + if (isEnabled) { + const docker = await dockerService.getDockerConnection(); + + if (docker) { + // 初始化容器状态 + await initializeContainerStates(docker); + + // 如果已存在监控间隔,清除它 + if (monitoringInterval) { + clearInterval(monitoringInterval); + } + + // 启动监控间隔 + monitoringInterval = setInterval(async () => { + await checkContainerStates(docker, config.monitoringConfig); + }, (monitorInterval || 60) * 1000); + + // 监听Docker事件流 + try { + const dockerEventStream = await docker.getEvents(); + + dockerEventStream.on('data', async (chunk) => { + try { + const event = JSON.parse(chunk.toString()); + + // 处理容器状态变化事件 + if (event.Type === 'container' && + (event.Action === 'start' || event.Action === 'die' || + event.Action === 'stop' || event.Action === 'kill')) { + await handleContainerEvent(docker, event, config.monitoringConfig); + } + } catch (eventError) { + logger.error('处理Docker事件出错:', eventError); + } + }); + + dockerEventStream.on('error', (err) => { + logger.error('Docker事件流错误:', err); + }); + } catch (streamError) { + logger.error('无法获取Docker事件流:', streamError); + } + + return true; + } + } else if (monitoringInterval) { + // 如果监控已禁用但间隔仍在运行,停止它 + clearInterval(monitoringInterval); + monitoringInterval = null; + } + + return false; + } catch (error) { + logger.error('启动监控失败:', error); + return false; + } +} + +// 停止监控 +function stopMonitoring() { + if (monitoringInterval) { + clearInterval(monitoringInterval); + monitoringInterval = null; + logger.info('容器监控已停止'); + } + return true; +} + +// 初始化容器状态 +async function initializeContainerStates(docker) { + try { + const containers = await docker.listContainers({ all: true }); + + for (const container of containers) { + const containerInfo = await docker.getContainer(container.Id).inspect(); + containerStates.set(container.Id, containerInfo.State.Status); + } + } catch (error) { + logger.error('初始化容器状态失败:', error); + } +} + +// 处理容器事件 +async function handleContainerEvent(docker, event, monitoringConfig) { + try { + const containerId = event.Actor.ID; + const container = docker.getContainer(containerId); + const containerInfo = await container.inspect(); + + const newStatus = containerInfo.State.Status; + const oldStatus = containerStates.get(containerId); + + if (oldStatus && oldStatus !== newStatus) { + // 如果容器从停止状态变为运行状态 + if (newStatus === 'running' && oldStatus !== 'running') { + await sendAlertWithRetry( + containerInfo.Name, + `恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, + monitoringConfig + ); + + // 清除告警状态 + lastStopAlertTime.delete(containerInfo.Name); + secondAlertSent.delete(containerInfo.Name); + } + // 如果容器从运行状态变为停止状态 + else if (oldStatus === 'running' && newStatus !== 'running') { + await sendAlertWithRetry( + containerInfo.Name, + `停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, + monitoringConfig + ); + + // 记录停止时间,用于后续检查 + lastStopAlertTime.set(containerInfo.Name, Date.now()); + secondAlertSent.delete(containerInfo.Name); + } + + // 更新状态记录 + containerStates.set(containerId, newStatus); + } + } catch (error) { + logger.error('处理容器事件失败:', error); + } +} + +// 检查容器状态 +async function checkContainerStates(docker, monitoringConfig) { + try { + const containers = await docker.listContainers({ all: true }); + + for (const container of containers) { + const containerInfo = await docker.getContainer(container.Id).inspect(); + const newStatus = containerInfo.State.Status; + const oldStatus = containerStates.get(container.Id); + + // 如果状态发生变化 + if (oldStatus && oldStatus !== newStatus) { + // 处理状态变化,与handleContainerEvent相同的逻辑 + if (newStatus === 'running' && oldStatus !== 'running') { + await sendAlertWithRetry( + containerInfo.Name, + `恢复运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, + monitoringConfig + ); + + lastStopAlertTime.delete(containerInfo.Name); + secondAlertSent.delete(containerInfo.Name); + } + else if (oldStatus === 'running' && newStatus !== 'running') { + await sendAlertWithRetry( + containerInfo.Name, + `停止运行 (之前状态: ${oldStatus}, 当前状态: ${newStatus})`, + monitoringConfig + ); + + lastStopAlertTime.set(containerInfo.Name, Date.now()); + secondAlertSent.delete(containerInfo.Name); + } + + containerStates.set(container.Id, newStatus); + } + // 如果容器仍处于非运行状态,检查是否需要发送二次告警 + else if (newStatus !== 'running') { + await checkSecondStopAlert(containerInfo.Name, newStatus, monitoringConfig); + } + } + } catch (error) { + logger.error('检查容器状态失败:', error); + } +} + +// 检查是否需要发送二次停止告警 +async function checkSecondStopAlert(containerName, currentStatus, monitoringConfig) { + const now = Date.now(); + const lastStopAlert = lastStopAlertTime.get(containerName) || 0; + + // 如果距离上次停止告警超过1小时,且还没有发送过第二次告警,则发送第二次告警 + if (now - lastStopAlert >= 60 * 60 * 1000 && !secondAlertSent.has(containerName)) { + await sendAlertWithRetry(containerName, `仍未恢复 (当前状态: ${currentStatus})`, monitoringConfig); + secondAlertSent.add(containerName); // 标记已发送第二次告警 + } +} + +// 发送告警(带重试) +async function sendAlertWithRetry(containerName, status, monitoringConfig, maxRetries = 6) { + const { notificationType, webhookUrl, telegramToken, telegramChatId } = monitoringConfig; + const cleanContainerName = containerName.replace(/^\//, ''); + + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (notificationType === 'wechat') { + await sendWechatAlert(webhookUrl, cleanContainerName, status); + } else if (notificationType === 'telegram') { + await sendTelegramAlert(telegramToken, telegramChatId, cleanContainerName, status); + } + + logger.success(`告警发送成功: ${cleanContainerName} ${status}`); + return; + } catch (error) { + if (attempt === maxRetries) { + logger.error(`达到最大重试次数,放弃发送告警: ${cleanContainerName} ${status}`); + logger.error('最后一次错误:', error); + return; + } + + logger.warn(`告警发送失败,尝试重试 (${attempt}/${maxRetries}): ${error.message}`); + await new Promise(resolve => setTimeout(resolve, 10000)); + } + } +} + +// 发送企业微信告警 +async function sendWechatAlert(webhookUrl, containerName, status) { + if (!webhookUrl) { + throw new Error('企业微信 Webhook URL 未设置'); + } + + const response = await axios.post(webhookUrl, { + msgtype: 'text', + text: { + content: `Docker 容器告警: 容器 ${containerName} ${status}` + } + }, { + timeout: 5000 + }); + + if (response.status !== 200 || response.data.errcode !== 0) { + throw new Error(`请求成功但返回错误:${response.data.errmsg || JSON.stringify(response.data)}`); + } +} + +// 发送Telegram告警 +async function sendTelegramAlert(token, chatId, containerName, status) { + if (!token || !chatId) { + throw new Error('Telegram Bot Token 或 Chat ID 未设置'); + } + + const url = `https://api.telegram.org/bot${token}/sendMessage`; + const response = await axios.post(url, { + chat_id: chatId, + text: `Docker 容器告警: 容器 ${containerName} ${status}` + }, { + timeout: 5000 + }); + + if (response.status !== 200 || !response.data.ok) { + throw new Error(`发送Telegram消息失败:${JSON.stringify(response.data)}`); + } +} + +// 测试通知 +async function testNotification(config) { + const { notificationType, webhookUrl, telegramToken, telegramChatId } = config; + + if (notificationType === 'wechat') { + await sendWechatAlert(webhookUrl, 'Test Container', 'This is a test notification'); + } else if (notificationType === 'telegram') { + await sendTelegramAlert(telegramToken, telegramChatId, 'Test Container', 'This is a test notification'); + } else { + throw new Error('不支持的通知类型'); + } + + return { success: true }; +} + +// 切换监控状态 +async function toggleMonitoring(isEnabled) { + const config = await configService.getConfig(); + config.monitoringConfig.isEnabled = isEnabled; + await configService.saveConfig(config); + + return startMonitoring(); +} + +// 获取已停止的容器 +async function getStoppedContainers(forceRefresh = false) { + return await dockerService.getStoppedContainers(); +} + +module.exports = { + updateMonitoringConfig, + startMonitoring, + stopMonitoring, + testNotification, + toggleMonitoring, + getStoppedContainers, + sendAlertWithRetry +}; diff --git a/hubcmdui/services/networkService.js b/hubcmdui/services/networkService.js new file mode 100644 index 0000000..14c4291 --- /dev/null +++ b/hubcmdui/services/networkService.js @@ -0,0 +1,52 @@ +/** + * 网络服务 - 提供网络诊断功能 + */ +const { exec } = require('child_process'); +const { promisify } = require('util'); +const logger = require('../logger'); + +const execAsync = promisify(exec); + +/** + * 执行网络测试 + * @param {string} type 测试类型 ('ping' 或 'traceroute') + * @param {string} domain 目标域名 + * @returns {Promise} 测试结果 + */ +async function performNetworkTest(type, domain) { + // 验证输入 + if (!domain || !domain.match(/^[a-zA-Z0-9][a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/)) { + throw new Error('无效的域名格式'); + } + + if (!type || !['ping', 'traceroute'].includes(type)) { + throw new Error('无效的测试类型'); + } + + try { + // 根据测试类型构建命令 + const command = type === 'ping' + ? `ping -c 4 ${domain}` + : `traceroute -m 10 ${domain}`; + + logger.info(`执行网络测试: ${command}`); + + // 执行命令并获取结果 + const { stdout, stderr } = await execAsync(command, { timeout: 30000 }); + return stdout || stderr; + } catch (error) { + logger.error(`网络测试失败: ${error.message}`); + + // 如果命令被终止,表示超时 + if (error.killed) { + throw new Error('测试超时'); + } + + // 其他错误 + throw error; + } +} + +module.exports = { + performNetworkTest +}; diff --git a/hubcmdui/services/notificationService.js b/hubcmdui/services/notificationService.js new file mode 100644 index 0000000..95c3870 --- /dev/null +++ b/hubcmdui/services/notificationService.js @@ -0,0 +1,103 @@ +/** + * 通知服务 + * 用于发送各种类型的通知 + */ +const axios = require('axios'); +const logger = require('../logger'); + +/** + * 发送通知 + * @param {Object} message - 消息对象,包含标题、内容等 + * @param {Object} config - 配置对象,包含通知类型和相关配置 + * @returns {Promise} + */ +async function sendNotification(message, config) { + const { type } = config; + + switch (type) { + case 'wechat': + return sendWechatNotification(message, config); + case 'telegram': + return sendTelegramNotification(message, config); + default: + throw new Error(`不支持的通知类型: ${type}`); + } +} + +/** + * 发送企业微信通知 + * @param {Object} message - 消息对象 + * @param {Object} config - 配置对象 + * @returns {Promise} + */ +async function sendWechatNotification(message, config) { + const { webhookUrl } = config; + + if (!webhookUrl) { + throw new Error('企业微信 Webhook URL 未配置'); + } + + const payload = { + msgtype: 'markdown', + markdown: { + content: `## ${message.title}\n${message.content}\n> ${message.time}` + } + }; + + try { + const response = await axios.post(webhookUrl, payload, { + headers: { 'Content-Type': 'application/json' }, + timeout: 5000 + }); + + if (response.status !== 200 || response.data.errcode !== 0) { + throw new Error(`企业微信返回错误: ${response.data.errmsg || '未知错误'}`); + } + + logger.info('企业微信通知发送成功'); + } catch (error) { + logger.error('企业微信通知发送失败:', error); + throw new Error(`企业微信通知发送失败: ${error.message}`); + } +} + +/** + * 发送Telegram通知 + * @param {Object} message - 消息对象 + * @param {Object} config - 配置对象 + * @returns {Promise} + */ +async function sendTelegramNotification(message, config) { + const { telegramToken, telegramChatId } = config; + + if (!telegramToken || !telegramChatId) { + throw new Error('Telegram Token 或 Chat ID 未配置'); + } + + const text = `*${message.title}*\n\n${message.content}\n\n_${message.time}_`; + const url = `https://api.telegram.org/bot${telegramToken}/sendMessage`; + + try { + const response = await axios.post(url, { + chat_id: telegramChatId, + text: text, + parse_mode: 'Markdown' + }, { + headers: { 'Content-Type': 'application/json' }, + timeout: 5000 + }); + + if (response.status !== 200 || !response.data.ok) { + throw new Error(`Telegram 返回错误: ${response.data.description || '未知错误'}`); + } + + logger.info('Telegram 通知发送成功'); + } catch (error) { + logger.error('Telegram 通知发送失败:', error); + throw new Error(`Telegram 通知发送失败: ${error.message}`); + } +} + +module.exports = { + sendNotification +}; diff --git a/hubcmdui/services/systemService.js b/hubcmdui/services/systemService.js new file mode 100644 index 0000000..1fd65d3 --- /dev/null +++ b/hubcmdui/services/systemService.js @@ -0,0 +1,55 @@ +/** + * 系统服务模块 - 处理系统级信息获取 + */ +const { exec } = require('child_process'); +const os = require('os'); +const logger = require('../logger'); + +// 获取磁盘空间信息 +async function getDiskSpace() { + try { + // 根据操作系统不同有不同的命令 + const isWindows = os.platform() === 'win32'; + + if (isWindows) { + // Windows实现(需要更复杂的逻辑) + return { + diskSpace: '未实现', + usagePercent: 0 + }; + } else { + // Linux/Mac实现 + const diskInfo = await execPromise('df -h | grep -E "/$|/home" | head -1'); + const diskParts = diskInfo.split(/\s+/); + + if (diskParts.length >= 5) { + return { + diskSpace: `${diskParts[2]}/${diskParts[1]}`, + usagePercent: parseInt(diskParts[4].replace('%', '')) + }; + } else { + throw new Error('磁盘信息格式不正确'); + } + } + } catch (error) { + logger.error('获取磁盘空间失败:', error); + throw error; + } +} + +// 辅助函数: 执行命令 +function execPromise(command) { + return new Promise((resolve, reject) => { + exec(command, (error, stdout, stderr) => { + if (error) { + reject(error); + return; + } + resolve(stdout.trim()); + }); + }); +} + +module.exports = { + getDiskSpace +}; diff --git a/hubcmdui/services/userService.js b/hubcmdui/services/userService.js new file mode 100644 index 0000000..8087c5f --- /dev/null +++ b/hubcmdui/services/userService.js @@ -0,0 +1,175 @@ +/** + * 用户服务模块 + */ +const fs = require('fs').promises; +const path = require('path'); +const bcrypt = require('bcrypt'); +const logger = require('../logger'); + +const USERS_FILE = path.join(__dirname, '..', 'users.json'); + +// 获取所有用户 +async function getUsers() { + try { + const data = await fs.readFile(USERS_FILE, 'utf8'); + return JSON.parse(data); + } catch (error) { + if (error.code === 'ENOENT') { + logger.warn('Users file does not exist, creating default user'); + const defaultUser = { + username: 'root', + password: bcrypt.hashSync('admin', 10), + createdAt: new Date().toISOString(), + loginCount: 0, + lastLogin: null + }; + await saveUsers([defaultUser]); + return { users: [defaultUser] }; + } + throw error; + } +} + +// 保存用户 +async function saveUsers(users) { + await fs.writeFile(USERS_FILE, JSON.stringify({ users }, null, 2), 'utf8'); +} + +// 更新用户登录信息 +async function updateUserLoginInfo(username) { + try { + const { users } = await getUsers(); + const user = users.find(u => u.username === username); + + if (user) { + user.loginCount = (user.loginCount || 0) + 1; + user.lastLogin = new Date().toISOString(); + await saveUsers(users); + } + } catch (error) { + logger.error('更新用户登录信息失败:', error); + } +} + +// 获取用户统计信息 +async function getUserStats(username) { + try { + const { users } = await getUsers(); + const user = users.find(u => u.username === username); + + if (!user) { + return { loginCount: '0', lastLogin: '未知', accountAge: '0' }; + } + + // 计算账户年龄(如果有创建日期) + let accountAge = '0'; + if (user.createdAt) { + const createdDate = new Date(user.createdAt); + const currentDate = new Date(); + const diffTime = Math.abs(currentDate - createdDate); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + accountAge = diffDays.toString(); + } + + // 格式化最后登录时间 + let lastLogin = '未知'; + if (user.lastLogin) { + const lastLoginDate = new Date(user.lastLogin); + const now = new Date(); + const isToday = lastLoginDate.toDateString() === now.toDateString(); + + if (isToday) { + lastLogin = '今天 ' + lastLoginDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } else { + lastLogin = lastLoginDate.toLocaleDateString() + ' ' + lastLoginDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + } + } + + return { + username: user.username, + loginCount: (user.loginCount || 0).toString(), + lastLogin, + accountAge + }; + } catch (error) { + logger.error('获取用户统计信息失败:', error); + return { loginCount: '0', lastLogin: '未知', accountAge: '0' }; + } +} + +// 创建新用户 +async function createUser(username, password) { + try { + const { users } = await getUsers(); + + // 检查用户是否已存在 + if (users.some(u => u.username === username)) { + throw new Error('用户名已存在'); + } + + const hashedPassword = bcrypt.hashSync(password, 10); + const newUser = { + username, + password: hashedPassword, + createdAt: new Date().toISOString(), + loginCount: 0, + lastLogin: null + }; + + users.push(newUser); + await saveUsers(users); + + return { success: true, username }; + } catch (error) { + logger.error('创建用户失败:', error); + throw error; + } +} + +// 修改用户密码 +async function changePassword(username, currentPassword, newPassword) { + try { + const { users } = await getUsers(); + const user = users.find(u => u.username === username); + + if (!user) { + throw new Error('用户不存在'); + } + + // 验证当前密码 + const isMatch = await bcrypt.compare(currentPassword, user.password); + if (!isMatch) { + throw new Error('当前密码不正确'); + } + + // 验证新密码复杂度(虽然前端做了,后端再做一层保险) + if (!isPasswordComplex(newPassword)) { + throw new Error('新密码不符合复杂度要求'); + } + + // 更新密码 + user.password = await bcrypt.hash(newPassword, 10); + await saveUsers(users); + + logger.info(`用户 ${username} 密码已成功修改`); + } catch (error) { + logger.error('修改密码失败:', error); + throw error; + } +} + +// 验证密码复杂度 (从 userCenter.js 复制过来并调整) +function isPasswordComplex(password) { + // 至少包含1个字母、1个数字和1个特殊字符,长度在8-16位之间 + const passwordRegex = /^(?=.*[A-Za-z])(?=.*\d)(?=.*[.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&])[A-Za-z\d.,\-_+=()[\]{}|\\;:'"<>?/@$!%*#?&]{8,16}$/; + return passwordRegex.test(password); +} + +module.exports = { + getUsers, + saveUsers, + updateUserLoginInfo, + getUserStats, + createUser, + changePassword +}; diff --git a/hubcmdui/start-diagnostic.js b/hubcmdui/start-diagnostic.js new file mode 100644 index 0000000..ff4a10a --- /dev/null +++ b/hubcmdui/start-diagnostic.js @@ -0,0 +1,58 @@ +/** + * 诊断启动脚本 - 运行诊断并安全启动服务器 + */ +const { spawn } = require('child_process'); +const path = require('path'); +const fs = require('fs'); +const { runDiagnostics } = require('./scripts/diagnostics'); + +// 确保必要的模块存在 +try { + require('./logger'); +} catch (error) { + console.error('无法加载logger模块,请确保该模块存在:', error.message); + process.exit(1); +} + +const logger = require('./logger'); + +async function startWithDiagnostics() { + logger.info('正在运行系统诊断...'); + + try { + // 运行诊断 + const { criticalErrors } = await runDiagnostics(); + + if (criticalErrors.length > 0) { + logger.error('发现严重问题,无法启动系统。请修复问题后重试。'); + process.exit(1); + } + + logger.success('诊断通过,正在启动系统...'); + + // 启动服务器 + const serverProcess = spawn('node', ['server.js'], { + stdio: 'inherit', + cwd: __dirname + }); + + serverProcess.on('close', (code) => { + if (code !== 0) { + logger.error(`服务器进程异常退出,退出码: ${code}`); + process.exit(code); + } + }); + + serverProcess.on('error', (err) => { + logger.error('启动服务器进程时出错:', err); + process.exit(1); + }); + + } catch (error) { + logger.fatal('诊断过程中发生错误:', error); + process.exit(1); + } +} + +// 启动服务 +startWithDiagnostics(); diff --git a/hubcmdui/users.json b/hubcmdui/users.json index 69dc18f..5f217b1 100644 --- a/hubcmdui/users.json +++ b/hubcmdui/users.json @@ -2,7 +2,9 @@ "users": [ { "username": "root", - "password": "$2b$10$tu.ceN0qpkl.RSR3fi/uy.9FfJGazUdWJCEPaJCDAhh6mPFbP0GxC" + "password": "$2b$10$lh1kqJtq3shL2BhMD1LbVOThGeAlPXsDgME/he4ZyDMRupVtj0Hl.", + "loginCount": 1, + "lastLogin": "2025-04-01T22:16:21.808Z" } ] } \ No newline at end of file diff --git a/hubcmdui/web/.DS_Store b/hubcmdui/web/.DS_Store new file mode 100644 index 0000000..06b1d50 Binary files /dev/null and b/hubcmdui/web/.DS_Store differ diff --git a/hubcmdui/web/admin.html b/hubcmdui/web/admin.html index 38aeb57..c4b8ed6 100644 --- a/hubcmdui/web/admin.html +++ b/hubcmdui/web/admin.html @@ -7,10 +7,19 @@ - - - + + + + + + + + + + + + @@ -1233,7 +1541,7 @@

管理面板

-
    +